Spec: LoRa Radio Mesh

Status: Draft v0.1 — May 2026 Prototype: 7 of 7 Platform: Android primary, iOS secondary; radio hardware is ESP32-S3 or nRF52840 Scope: Short encrypted text messages over a flooded LoRa mesh — no social feeds, no large payloads, no custom firmware


Overview

This document is the full technical specification for Prototype 7: an encrypted LoRa radio mesh that lets a small group exchange short text messages over kilometre-scale distances with no cellular network, no WiFi, and no infrastructure. The prototype goal is described in the Research Prototypes catalogue and the human scenario is told in Story 7: The Valley.

Each user carries a small LoRa radio module — a Heltec WiFi LoRa 32 V3 (~$19) or LilyGO T-Echo (~$45) — paired to their phone over Bluetooth. The phone encrypts a message, passes it to the radio over the Meshtastic BLE protobuf API, and the radio transmits a LoRa chirp into the ISM band. Other radios in range relay the packet hop by hop until it reaches the recipient's radio, which decrypts and delivers it to the paired phone. The entire path — phone to radio to air to radio to phone — carries ciphertext. Nobody handling a relay packet sees plaintext.

The prototype sits on top of Meshtastic firmware rather than implementing a custom mesh stack. This is a deliberate scope decision: validating the cryptographic envelope, the bandwidth budget, and the duty-cycle behaviour under real LoRa conditions is more valuable for this research stage than writing a custom mesh MAC. Custom firmware can come later if the transport layer proves sound.


API Surface / Key Interfaces

The phone communicates with the radio exclusively over BLE using the Meshtastic protobuf API. All interfaces below describe the boundary between the phone app and the Meshtastic firmware — not a custom wire protocol.

BLE Connection to Radio

/**
 * Discovers and connects to a paired Meshtastic radio over BLE.
 * The radio advertises a fixed service UUID (defined by Meshtastic firmware).
 *
 * Meshtastic BLE service UUID: 6ba1b218-15a8-461f-9fa8-5d36d067f813
 * FromRadio characteristic: 2c55e69e-4993-11ed-b878-0242ac120002 (NOTIFY)
 * ToRadio characteristic:   f75c76d2-129e-4dad-a1dd-7866124401e7 (WRITE)
 * FromNum characteristic:   ed9da18c-a800-4f66-a670-aa7547e34453 (NOTIFY)
 *
 * Connection is one-to-one: one phone to one radio.
 * The phone acts as a BLE central; the radio acts as a BLE peripheral.
 */
interface RadioBleConnection {
    /**
     * Scan for and connect to the nearest paired Meshtastic radio.
     *
     * @param deviceName  The BLE device name as configured in Meshtastic (e.g. "Nadia_Radio").
     * @return            Unit — connection is established and characteristics are ready.
     * @throws RadioNotFoundException   No Meshtastic device found within 30 s of scan.
     * @throws RadioConnectException    GATT connection failed after two retries.
     * @throws BlePermissionException   BLUETOOTH_SCAN or BLUETOOTH_CONNECT not granted.
     */
    suspend fun connect(deviceName: String): Unit

    /**
     * Register a callback for packets received from the mesh via the radio.
     * Invoked on every FromRadio notify event. May be called frequently on busy meshes.
     *
     * @param callback  Lambda receiving a raw MeshPacket protobuf from the radio.
     */
    fun onPacketReceived(callback: (MeshPacket) -> Unit)

    /**
     * Send an outbound protobuf packet to the radio for transmission over LoRa.
     * The packet must be pre-serialised as a ToRadio protobuf.
     *
     * @param packet  Serialised ToRadio protobuf bytes (max 512 bytes over BLE GATT write).
     * @throws RadioWriteException  GATT write failed; radio may be out of BLE range.
     */
    suspend fun sendPacket(packet: ByteArray): Unit

    /** Disconnect from the radio and release GATT resources. */
    fun disconnect()
}

Failure modes:


Message Envelope

The application wraps every user message in a fixed-size encrypted envelope before handing it to Meshtastic's payload field. Meshtastic carries this envelope as opaque bytes within its own packet structure — the firmware does not inspect the application payload.

LoRa Application Payload (max 200 bytes)
┌──────────────────────────────────────────────────────┐
│ version    1 byte  — 0x01 (envelope format version)  │
│ sender_id  8 bytes — first 8 bytes of SHA-256(sender │
│                      Ed25519 public key)              │
│ nonce     24 bytes — random, XChaCha20-Poly1305 nonce│
│ ciphertext N bytes — XChaCha20-Poly1305 AEAD output  │
│ mac       16 bytes — Poly1305 authentication tag      │
└──────────────────────────────────────────────────────┘

Total overhead: 1 + 8 + 24 + 16 = 49 bytes fixed
Max ciphertext payload: 200 - 49 = 151 bytes
At UTF-8: approximately 100–151 characters per message

The sender_id is an 8-byte truncation of the sender's public key hash. It allows the recipient to look up the shared key but does not expose the full public key on-air. The ciphertext is produced with XChaCha20-Poly1305 (libsodium crypto_secretbox_xchacha20poly1305), using the per-contact shared secret derived during key exchange (Prototype 3 or manual QR scan).

data class LoraEnvelope(
    val version: Byte = 0x01,
    val senderId: ByteArray,    // 8 bytes
    val nonce: ByteArray,       // 24 bytes, random per message
    val ciphertext: ByteArray,  // ≤ 151 bytes
    val mac: ByteArray          // 16 bytes — appended by XChaCha20-Poly1305
) {
    companion object {
        const val MAX_PLAINTEXT_BYTES = 151
        const val ENVELOPE_OVERHEAD  = 49   // version + sender_id + nonce + mac
        const val MAX_TOTAL_BYTES    = 200
    }

    /**
     * Serialise the envelope to a flat byte array for inclusion in a Meshtastic payload.
     * Layout matches the diagram above — fields are concatenated in order, no padding.
     */
    fun toBytes(): ByteArray

    companion object {
        /**
         * Deserialise from a raw byte array received via the Meshtastic FromRadio callback.
         * Returns null if the byte array is shorter than ENVELOPE_OVERHEAD or version != 0x01.
         */
        fun fromBytes(bytes: ByteArray): LoraEnvelope?
    }
}

CryptoLayer

Identical in purpose to the BLE Messenger (Prototype 2) crypto layer; adapted to use XChaCha20-Poly1305 with a 24-byte nonce. The shared secret for each contact is derived using X25519 DH + HKDF-SHA-256, either imported from a completed key exchange (Prototype 3) or established manually via QR scan.

interface LoraCrypto {
    /**
     * Encrypt plaintext for a specific contact.
     *
     * @param plaintext     UTF-8 message text, max 151 bytes after encoding.
     * @param sharedSecret  32-byte symmetric secret shared with the recipient.
     * @return              LoraEnvelope with version, sender_id, random nonce, ciphertext, and mac.
     * @throws MessageTooLongException  Encoded plaintext exceeds MAX_PLAINTEXT_BYTES.
     * @throws CryptoException          libsodium not initialised or key length wrong.
     */
    fun encrypt(plaintext: String, sharedSecret: ByteArray): LoraEnvelope

    /**
     * Decrypt a received envelope.
     *
     * @param envelope      Deserialised LoraEnvelope from the mesh.
     * @param sharedSecret  32-byte shared secret for the sender identified by envelope.senderId.
     * @return              Decrypted UTF-8 plaintext.
     * @throws AuthenticationException  Poly1305 MAC verification failed — tampered or wrong key.
     * @throws CryptoException          Malformed ciphertext.
     */
    fun decrypt(envelope: LoraEnvelope, sharedSecret: ByteArray): String

    /**
     * Look up the shared secret for a given 8-byte sender_id.
     * Returns null if the sender is unknown (i.e. not in the contact book).
     *
     * @param senderId  8-byte truncated key hash from the envelope.
     * @return          32-byte shared secret, or null.
     */
    fun resolveSharedSecret(senderId: ByteArray): ByteArray?
}

MessageStore

SQLite-backed persistence for sent and received LoRa messages. Ciphertext is stored; plaintext is decrypted at display time.

data class LoraMessage(
    val id: String,            // UUID v4
    val contactId: String,     // hex of 8-byte sender_id for received; own sender_id for sent
    val direction: Direction,  // OUTBOUND or INBOUND
    val ciphertext: ByteArray, // stored encrypted — never plaintext
    val timestampMs: Long,
    val delivered: Boolean,    // true once the radio confirms the packet was queued for TX
    val hops: Int?             // hop count from received packet metadata, null for outbound
)

enum class Direction { OUTBOUND, INBOUND }

interface LoraMessageStore {
    fun storeSent(contactId: String, ciphertext: ByteArray): String
    fun storeReceived(contactId: String, ciphertext: ByteArray, hops: Int?): String
    fun markDelivered(messageId: String)
    fun getConversation(contactId: String): List<LoraMessage>
    fun getPending(): List<LoraMessage>  // outbound messages not yet delivered
}

Radio Hardware

Board MCU LoRa Chip Price Battery Display Notes
Heltec WiFi LoRa 32 V3 ESP32-S3 SX1262 ~$19 External (USB-C) OLED 128×64 Primary dev board; Meshtastic-supported; widely available
LilyGO T-Echo nRF52840 SX1262 ~$45 850 mAh (built-in) E-paper 212×104 Production-form-factor reference; ultra-low sleep current (~2 µA)
RAK WisBlock Starter nRF52840 SX1262 ~$25–60 Modular Optional Good for adding GPS or sensor modules
TTGO T-Beam ESP32 SX1262 ~$35 18650 cell OLED optional Includes GPS; useful for range testing with location data

Recommended for this prototype: Heltec WiFi LoRa 32 V3 (development) plus one LilyGO T-Echo (battery endurance reference). The Heltec is easier to flash and debug via USB; the T-Echo demonstrates realistic field battery life.

Firmware: Flash Meshtastic stable release (v2.5.x as of May 2026) from meshtastic.org/downloads. Do not use nightly builds — the BLE protobuf API has changed in breaking ways between nightly releases.


Radio Parameters & Regional Bands

LoRa radio parameters must match between all nodes in the mesh. Meshtastic preset selection determines the spreading factor (SF), bandwidth (BW), and coding rate (CR) automatically.

Regional frequency plans

Region Frequency Max EIRP Duty cycle Meshtastic preset
EU (most countries) 869.4–869.65 MHz 500 mW (+27 dBm) 10% EU_868
EU (868 MHz main) 867.9–868.2 MHz 25 mW (+14 dBm) 1% EU_868 (alternate sub-band)
US / Canada 902–928 MHz 1 W (+30 dBm) No limit (frequency hop) US_915
Australia 915–928 MHz 1 W No limit AU_915
Japan 920–928 MHz 20 mW ARIB limits apply JP_920

Important: The radio hardware must be ordered in the correct regional variant. An EU SX1262 board is hardware-limited to 868 MHz; a US board targets 915 MHz. The bands are not interchangeable.

LoRa modulation parameters (Meshtastic LongFast preset)

Parameter Value Effect
Spreading Factor SF10 Moderate range/speed balance
Bandwidth 250 kHz ~460 bps effective bit rate
Coding Rate 4/5 Moderate error correction
Max payload 255 bytes PHY Meshtastic header uses ~30–55 bytes; application payload ~200 bytes
Time on air (200-byte payload) ~1.0–1.4 s At SF10/250 kHz

At 10% EU duty cycle (869.4 MHz sub-band) and 1.2 s time on air per packet, a single node can transmit approximately 5 packets per minute. This is the effective send rate ceiling for EU operation. US operation has no duty cycle cap and can transmit continuously.

Open question: whether Meshtastic firmware automatically enforces EU duty-cycle limits, or whether it relies on the operator to select a compliant preset. The firmware has airtime_factor parameters; full automated enforcement behaviour should be confirmed before field deployment in the EU.

Spreading factor vs. range/speed trade-off

SF Bit rate (approx.) Link budget gain Time on air (200 bytes) Use case
SF7 ~5.5 kbps 0 dB ~120 ms Short range, fast, low duty-cycle impact
SF9 ~1.8 kbps +9 dB ~370 ms Urban range
SF10 ~980 bps +12 dB ~720 ms Meshtastic default — good balance
SF11 ~490 bps +15 dB ~1.4 s Suburban/rural
SF12 ~290 bps +18 dB ~2.5 s Maximum range; severe duty-cycle impact at 1%

For this prototype, SF10 with the EU 10%-duty-cycle sub-band is the baseline. SF12 may be tested for extreme-range measurements but is impractical as a daily send rate at 1% duty cycle (500-second wait between transmissions).


Mesh Topology & Routing

Meshtastic uses a flooded mesh with hop limit routing. There is no routing table, no link-state protocol, and no DHT. Every node that receives a packet retransmits it (after a random backoff delay) up to the hop limit. The originator sets the hop limit in the packet header; each relay decrements it. A packet with hop limit 0 is not retransmitted.

Topology diagram — Nadia's valley (6 nodes, mountainous terrain)

        [Nadia]
           |
      BLE (~5m)
           |
        [N-Radio]──────────────LoRa (1.2 km)──────────────[E-Radio]
        868 MHz                                               |
                                                          BLE (~5m)
                                                             |
                                                          [Eitan]

        [N-Radio] ──────LoRa (2.1 km)──── [C-Radio]
                                              |
                                          BLE (~5m)
                                              |
                                          [Clara]

                          [C-Radio] ──LoRa (0.8 km)── [E-Radio]
                                   (relay path available)

Message flow: Nadia → Eitan
─────────────────────────────
1. Nadia types message → phone encrypts → LoraEnvelope → BLE write to N-Radio
2. N-Radio transmits LoRa packet (hop limit = 3) on 868 MHz
3. C-Radio receives, RSSI checks OK, random 50–400 ms backoff, retransmits (hop limit = 2)
4. E-Radio receives direct from N-Radio AND relayed from C-Radio
   — duplicate suppression: E-Radio drops the second copy by packet ID
5. E-Radio delivers via BLE NOTIFY to Eitan's phone
6. Phone decrypts LoraEnvelope → displays plaintext

Total latency: 1.2 s (N-Radio TX) + 50–400 ms (backoff) + 1.2 s (C-Radio TX) = 2.5–4.5 s typical

Duplicate suppression: Meshtastic tracks recently seen packet IDs in a ring buffer and silently discards duplicates. The prototype relies entirely on this firmware-level deduplication and does not implement application-layer deduplication.

Hop limit guidance: Default 3 hops is appropriate for small groups (6–12 nodes). Increasing to 5 hops adds resilience in large areas but increases air-time consumption (O(n) retransmissions) and should only be used in low-density meshes.

LoRa vs. BLE vs. WiFi Direct comparison

Property LoRa (SF10/EU) BLE 5.0 WiFi Direct
Range (open space) 2–10 km 50–200 m 100–200 m
Range (urban/obstructed) 0.5–2 km 10–50 m 30–100 m
Max throughput ~1 kbps ~1 Mbps ~250 Mbps
Payload per frame ~200 bytes 244 bytes (DLE) Unlimited
Infrastructure needed None None None (direct mode)
Phone integration BLE bridge Native Android/iOS Native Android
Battery (radio/node) 2–5 mA active, <10 µA sleep 1–20 mA 50–200 mA
EU duty cycle 1–10% None None
Multi-hop relay Yes (flood) No (single hop) No
iOS support Yes (via BLE bridge) Background-limited No

LoRa occupies a unique niche: orders of magnitude longer range than BLE or WiFi, at the cost of orders of magnitude less throughput. It is a transport for short, infrequent, high-priority messages — not for social feeds, media, or chat at volume.


Security & Encryption

Encryption model

The phone applies XChaCha20-Poly1305 encryption before a message leaves the device. The ciphertext enters the radio over BLE and is transmitted over LoRa exactly as it was handed to the radio. Neither the radio firmware nor any relay node performs any cryptographic operation on the application payload — they relay opaque bytes.

This is structurally the same model as Prototype 6 (BLE Dead Drop): the transport layer carries sealed ciphertext, and only the intended recipient holds the key to open it.

[Nadia's phone]                [air]                 [Eitan's phone]
    plaintext                                            plaintext
       │                                                    ▲
  XChaCha20-Poly1305 encrypt                       XChaCha20-Poly1305 decrypt
       │                                                    │
  LoraEnvelope bytes                             LoraEnvelope bytes
       │                                                    │
  BLE write                                        BLE NOTIFY callback
       │                                                    │
[N-Radio firmware]     ─── LoRa TX/RX ───     [E-Radio firmware]
  (opaque payload)                               (opaque payload)

Meshtastic layer interaction

Meshtastic applies its own channel-level encryption (AES-256-CTR with a shared channel PSK) on top of all payloads. The project's XChaCha20-Poly1305 envelope is double-encrypted: first by the application layer, then by Meshtastic. An observer with the Meshtastic channel PSK can strip the outer Meshtastic layer and see the LoraEnvelope ciphertext — but cannot read the plaintext without also holding the per-contact shared secret.

Channel PSK management: For this prototype, all nodes in the group use a single Meshtastic channel configured with a strong random PSK (256-bit, generated in Meshtastic app). The PSK is distributed out-of-band (e.g. via QR scan in the Meshtastic app during the initial setup session). This is acceptable for a small trusted group. For larger or less-trusted deployments, the application-layer per-contact encryption provides confidentiality even if the channel PSK leaks.

Identity & key handling

Keys are established out of band before LoRa operation begins. Two paths:

  1. Import from Prototype 3 key exchange: If contacts were established using the NFC/QR key exchange prototype, the existing X25519 shared secrets can be imported directly. The LoRa prototype uses the same ContactsKey derivation (X25519 DH + HKDF-SHA-256 with domain separator "ProximityApp_ContactsKey_v1").

  2. Standalone QR scan: If Prototype 3 is not available, two users scan each other's QR codes (each QR encodes an X25519 public key) and the phone derives the shared secret via ECDH. No identity key (Ed25519) is required for the LoRa prototype — authentication is implicit in the shared secret derivation.

Private keys are stored in the Android Keystore (API 23+). Shared secrets are derived fresh on each app launch from stored public keys and the device's private key — they are never stored in SQLite.

No forward secrecy: This prototype uses static X25519 shared secrets (no Double Ratchet). A compromised device leaks all past LoRa messages if the ciphertext was retained. Forward secrecy over LoRa would require a ratchet key exchange round trip, consuming at least two LoRa packets and tens of seconds under duty-cycle constraints. This is flagged as an open question for future iteration.

Threat-model considerations

Passive radio interception: Any LoRa receiver tuned to the correct frequency, spreading factor, and bandwidth can capture all transmitted frames. This is physics — LoRa has no link-layer secrecy. The application-layer XChaCha20-Poly1305 envelope provides confidentiality against passive interception. The Meshtastic AES-256-CTR layer provides an additional barrier against non-targeted interception.

Traffic analysis: Even with both encryption layers active, the sender's Meshtastic node ID appears in the packet header (required for relay deduplication). An observer correlating node ID with physical location (by triangulation or prior knowledge) can build a partial social graph: who transmitted when. This is the same metadata-leakage constraint as BLE advertisement headers in Prototype 2. The sender_id field in the LoraEnvelope (8-byte truncated key hash) provides a secondary identifier that is not directly linkable to the Meshtastic node ID by a passive observer, but both appear in the same packet.

Relay node compromise: A relay node retransmits packets it cannot decrypt. A malicious relay can drop packets (denial of service) or selectively delay packets (deanonymisation via timing). Flood routing with multiple independent paths reduces (but does not eliminate) the impact of a single malicious relay.

Replay attacks: Meshtastic firmware maintains a per-node sequence counter. Replaying an old packet with the same packet ID is rejected by the firmware's duplicate-suppression ring buffer (covering the last ~100 packets per node). The XChaCha20-Poly1305 nonce uniqueness property additionally prevents attackers from inferring anything useful from replayed ciphertext.

Key distribution: The security of the entire system depends on the initial key exchange being performed correctly and out of band. If an attacker can intercept or substitute the QR code during setup, they can establish a shared secret with the victim. Use the Prototype 3 NFC tap when available, as it is harder to intercept than a QR code displayed on a screen.


Energy & Range Trade-offs

Battery life — radio hardware

Scenario Current draw Estimate on 850 mAh (T-Echo)
Transmitting (SF10, +20 dBm) 120 mA
Receiving (active listen) 5–7 mA
Meshtastic idle (periodic wake) ~2 mA average ~17 days
Meshtastic active mesh (frequent relay) ~15 mA average ~56 hours
Deep sleep (between receive windows) <10 µA

The LilyGO T-Echo's 850 mAh battery and nRF52840's ultra-low sleep current make it practical for multi-day deployment in a valley scenario without a charger. The Heltec requires external power (USB-C power bank) for field operation beyond a few hours.

Battery life — phone

The phone's battery impact is dominated by the BLE connection to the radio, not by LoRa transmission. A continuous BLE foreground service connection to the radio (GATT notifications active) draws approximately 3–7 mA — similar to a continuous BLE peripheral scan. This is substantially less than Prototype 2 (concurrent BLE scan + advertise + GATT) because the phone is connected to a single known device rather than scanning and maintaining multiple connections.

Range expectations

Range depends on terrain, antenna height, and spreading factor. Representative measurements from Meshtastic community range tests:

Environment Expected range per hop
Urban (street level, buildings) 0.5–1.5 km
Suburban / mixed 1.5–4 km
Rural (flat farmland) 4–8 km
Elevated position (hill, rooftop) 10–20 km
Mountain line-of-sight 50+ km (outliers reported)

For the valley scenario (Story 7), 1–2 km per hop in mountainous terrain is a realistic planning assumption. A 30 km2 valley with nodes positioned at farmhouse locations may require 4–6 hops, pushing against the default 3-hop limit. Increasing the hop limit to 5 and using SF11 instead of SF10 is the recommended configuration for mountainous terrain.


Failure Modes

Failure Cause Detection Recovery
Message not delivered Recipient out of range, no relay path, or duty-cycle limit hit No ACK within 60 s Show "delivery uncertain" in UI; user retries manually
Duty-cycle throttle (EU) Node transmitted too frequently Meshtastic airtime manager reports backoff UI shows "waiting — radio duty cycle"
BLE disconnection (phone to radio) Radio moved out of BLE range or phone killed foreground service RadioWriteException on send Re-scan and reconnect; queue outbound packets
Duplicate packet displayed Ring buffer miss on relay node Two identical plaintexts appear in chat App-layer deduplication by message UUID in LoraMessageStore
MAC verification failure Tampered packet, wrong key, or packet corruption AuthenticationException from LoraCrypto.decrypt() Silently discard packet; log warning; do not surface to user
Hop limit exhausted Destination is more than hopLimit hops away No delivery Increase hop limit via Meshtastic channel config; consider adding a relay node
Radio firmware crash / hang Meshtastic firmware bug or power interruption BLE connection drops; radio unresponsive to reconnect Power-cycle the radio; reflash firmware if crash is reproducible
Meshtastic channel PSK mismatch Node configured with wrong PSK All received packets fail Meshtastic-layer decryption silently Reconfigure channel via Meshtastic app; redistribute PSK out of band

Dependencies & Libraries

Phone App (Android)

Library Version Purpose License
Android BLE APIs (built-in) API 21+ BLE connection to radio Apache-2.0 (AOSP)
lazysodium-android 5.1.4 XChaCha20-Poly1305 encrypt/decrypt; X25519 DH; HKDF LGPL-2.1
meshtastic-protobufs 2.5.x Protobuf definitions for MeshPacket, ToRadio, FromRadio Apache-2.0
protobuf-javalite 4.26.1 Protobuf serialisation/deserialisation BSD-3-Clause
Room (AndroidX) 2.6.1 SQLite persistence for messages and contacts Apache-2.0
kotlinx-coroutines-android 1.8.1 Async BLE operations; foreground service Apache-2.0
zxing-android-embedded 4.3.0 QR scan for initial key exchange Apache-2.0

Radio Firmware

Firmware Version Purpose License
Meshtastic (ESP32 / nRF52840) 2.5.x stable LoRa mesh MAC, BLE GATT API, duty-cycle management GPL-3.0

Python Reference Path (Desktop / Termux on Android)

Library Version Purpose License
meshtastic (Python) 2.5.x High-level Python API over Meshtastic serial/TCP/BLE Apache-2.0
cryptography ^42 XChaCha20-Poly1305, X25519, HKDF Apache-2.0 / BSD
pyserial 3.5 Serial connection to radio for desktop testing BSD

The Python path (Meshtastic Python library + Termux on Android, or a desktop machine connected to the radio over USB serial) is recommended for initial envelope validation before implementing the full Android BLE path. The Meshtastic Python library exposes the same send/receive API and allows rapid iteration without building an Android app first.


Build & Test Instructions

Step 1: Flash Meshtastic firmware

# Download Meshtastic firmware flasher
# https://flasher.meshtastic.org (web-based, works on Chrome)
# Or use the Meshtastic Python CLI:
pip install meshtastic

# Connect Heltec LoRa 32 V3 via USB-C
# Select board: "Heltec WiFi LoRa 32 V3"
# Select region: EU_868 (Europe) or US_915 (US/Canada)
# Flash stable firmware (not nightly)
meshtastic --flash  # follows interactive prompts if using CLI flasher

Step 2: Configure Meshtastic channel

import meshtastic
import meshtastic.serial_interface

# Connect to radio over USB serial (development bench setup)
iface = meshtastic.serial_interface.SerialInterface()

# Set a channel with a strong random PSK
# All nodes in the group must use identical channel config
import secrets
psk = secrets.token_bytes(32)  # 256-bit random PSK

iface.localNode.setChannel({
    'role': 'PRIMARY',
    'settings': {
        'name': 'valley',
        'psk': psk,
        'moduleSettings': {
            'positionPrecision': 0  # disable GPS position sharing for privacy
        }
    }
})
iface.localNode.writeChannel(0)

print(f"Channel PSK (distribute out of band): {psk.hex()}")
iface.close()

Step 3: Validate envelope on Python path

# Install Python dependencies
pip install meshtastic cryptography

# Connect radio to laptop via USB serial
# (Or use Meshtastic TCP if radio is on local WiFi)
python -c "
import meshtastic
import meshtastic.serial_interface
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
import os, hashlib

# Simulate two nodes: Alice and Bob
alice_priv = X25519PrivateKey.generate()
bob_priv   = X25519PrivateKey.generate()
alice_pub  = alice_priv.public_key()
bob_pub    = bob_priv.public_key()

# Derive shared secret (X25519 DH)
shared_alice = alice_priv.exchange(bob_pub)
shared_bob   = bob_priv.exchange(alice_pub)
assert shared_alice == shared_bob  # ECDH symmetry

# Build LoraEnvelope
key        = shared_alice[:32]
nonce      = os.urandom(24)
plaintext  = b'hello from the valley'
sender_id  = hashlib.sha256(alice_pub.public_bytes_raw()).digest()[:8]

# XChaCha20-Poly1305: use libsodium (via ctypes) or pynacl
# Here using a simplified ChaCha20Poly1305 with 96-bit nonce as a stand-in
# Full implementation must use XChaCha20-Poly1305 (24-byte nonce) via libsodium
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
aead       = ChaCha20Poly1305(key)
ct         = aead.encrypt(nonce[:12], plaintext, None)  # stand-in: real impl uses xchacha20

envelope   = bytes([0x01]) + sender_id + nonce + ct
assert len(envelope) <= 200, f'Envelope too large: {len(envelope)} bytes'
print(f'Envelope size: {len(envelope)} bytes — OK')
print(f'Plaintext ({len(plaintext)} bytes) → ciphertext ({len(ct)} bytes)')
"

Step 4: Android project setup

# Create new Android project
# Language: Kotlin | Min SDK: API 26 | Target SDK: 34

# Add to app/build.gradle.kts:
dependencies {
    implementation("net.java.dev.jna:jna:5.14.0@aar")
    implementation("com.goterl:lazysodium-android:5.1.4@aar")
    implementation("com.google.protobuf:protobuf-javalite:4.26.1")
    implementation("com.journeyapps:zxing-android-embedded:4.3.0")
    implementation("androidx.room:room-runtime:2.6.1")
    implementation("androidx.room:room-ktx:2.6.1")
    kapt("androidx.room:room-compiler:2.6.1")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}

# Meshtastic protobufs — clone and compile locally or use community JitPack artifact:
# https://github.com/meshtastic/protobufs

Required Android permissions

<!-- BLE connection to radio -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
    android:usesPermissionFlags="neverForLocation" tools:targetApi="31" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<!-- Camera for QR key exchange -->
<uses-permission android:name="android.permission.CAMERA" />

<service
    android:name=".LoraRadioService"
    android:foregroundServiceType="connectedDevice"
    android:exported="false" />

Test matrix

Layer Test Assertion
Unit — envelope serialisation LoraEnvelope.toBytes()fromBytes() round-trip Byte-identical reconstruction; length ≤ 200 bytes
Unit — encrypt/decrypt encrypt(pt, key)decrypt(envelope, key) round-trip Plaintext matches; MAC over tampered ciphertext throws AuthenticationException
Unit — key derivation symmetry X25519(alice_priv, bob_pub) == X25519(bob_priv, alice_pub) Byte-identical shared secret
Unit — message too long encrypt() with 152-byte plaintext MessageTooLongException raised
Unit — sender_id resolution resolveSharedSecret(senderId) returns correct secret for known contact Correct 32-byte key returned; unknown sender returns null
Integration — BLE to radio sendPacket() delivers bytes to radio; radio logs the packet Meshtastic serial log shows packet queued for TX
Integration — two radios on bench Send LoraEnvelope from radio A; receive on radio B via BLE callback; decrypt Plaintext reconstructed; latency measured
Integration — hop relay Three radios; A sends with hop limit 2; C receives via relay through B C receives packet; hop count in metadata confirms relay path
Integration — duty cycle (EU) Send 10 packets rapidly; observe Meshtastic airtime log Packets spaced by airtime limiter; no duty-cycle violation
System — persistence Send message, force-kill app, relaunch Sent message appears in conversation history; ciphertext in DB not plaintext
System — MAC tamper rejection Flip one bit in stored ciphertext; attempt display AuthenticationException raised; message not displayed

Success Criteria

The prototype is successful when all of the following are demonstrated and documented:

  1. Envelope budget confirmed: A 100-character UTF-8 message produces a LoraEnvelope of 200 bytes or fewer. Measured by unit test and logged for each test message.

  2. BLE phone-to-radio path: The Android app connects to a Meshtastic radio via BLE, writes an outbound packet, and receives a response, with the full round-trip confirmed in logcat. No intermediate server or USB connection involved.

  3. Two-radio message delivery: A message encrypted on phone A is received, decrypted, and displayed on phone B via their respective paired radios. Two physical radios required; emulation is not sufficient.

  4. Relay hop confirmed: In a three-radio setup (A–B–C, where A and C are out of direct range), a message from A reaches C via relay through B. Confirmed by hop count in the received packet metadata.

  5. Delivery latency documented: Median delivery time for a single-hop message at SF10 and two-hop relayed message, measured over 20 transmissions each. Expected: 2–5 s single hop, 4–10 s two hop. Actual figures committed to findings/latency.md.

  6. EU duty-cycle compliance: 10 rapid messages sent on the EU 869.4 MHz sub-band. The Meshtastic airtime limiter correctly throttles transmission rather than allowing a duty-cycle violation. Confirmed from firmware serial output.

  7. Range characterisation: RSSI and delivery success rate measured at 100 m, 500 m, 1 km, and 2 km in an open outdoor space. Results committed to findings/range.md.

  8. No plaintext at rest: After a full send and receive session, the Room database file is inspected with a hex editor or strings; no message plaintext is present. All LoraMessage rows contain only ciphertext.

  9. MAC tamper rejection: Flipping one byte in a stored ciphertext causes AuthenticationException during decryption. The message is not displayed. No crash.

  10. Battery measurement: LilyGO T-Echo with default Meshtastic configuration (SF10, idle-listening, occasional relay) measured over 4 hours via USB current meter. Average current documented; extrapolated battery life calculated.


Risks & Unknowns

Risk Likelihood Impact Mitigation
EU duty-cycle limits make real-time chat feel broken (5–8 min wait between rapid messages at SF12) High High Use SF10 with the EU 869.4 MHz 10%-duty-cycle sub-band. Document the send rate ceiling. Set user expectations in the UI with a "transmitting" / "waiting" indicator.
Meshtastic BLE protobuf API changes between versions Medium High Pin to Meshtastic stable 2.5.x; do not pull nightly builds. Test on exact firmware version pinned in build scripts.
LoRa frame budget exhausted if future envelopes grow Medium Medium Current overhead is 49 bytes; remaining budget is 151 bytes. Any growth in key material (e.g. adding an Ed25519 signature: 64 bytes) would reduce plaintext to 87 bytes. Document the budget explicitly; any protocol change must be reviewed against the budget.
Hop count insufficient for valley terrain Medium Medium Test with hop limit 5 if 3-hop fails. Each additional hop adds ~2–5 s latency and increases air-time.
Meshtastic firmware does not automatically enforce EU duty cycle Medium High Confirm by testing: attempt to transmit at >10% and observe whether firmware throttles. If not automatic, add airtime limiting at the application layer. Flag as a regulatory blocker for any EU deployment.
Post-quantum keys (1184 bytes) do not fit in LoRa frame Certain Medium Post-quantum crypto is explicitly out of scope. Document the constraint: LoRa with today's frame sizes cannot carry ML-KEM-768 keys in a single packet. Multi-packet key exchange is possible but impractical under duty-cycle constraints.
Static shared secrets lack forward secrecy Certain Medium Document clearly. A device compromise leaks all past LoRa messages if ciphertext was recorded by a passive observer. Double Ratchet over LoRa would require a multi-packet, multi-minute key exchange handshake — explored in open questions.
Radio hardware availability / regional variant mismatch Low High Order hardware at project start; confirm EU vs US variant before purchasing. Meshtastic hardware guide at meshtastic.org/docs/hardware lists exact regional variants per board.
Meshtastic channel PSK distribution is manual and error-prone Medium Medium Provide a QR code in the app that encodes the channel name + PSK for one-tap Meshtastic channel import. This does not replace out-of-band security; it reduces configuration mistakes.

Open Questions

Forward secrecy over LoRa: Is a Double Ratchet key exchange feasible at LoRa bandwidth? A single X3DH setup message (Ed25519 identity key + X25519 pre-key + ML-KEM-768 ephemeral) requires 1,184+ bytes — at least 6 LoRa frames. At EU duty-cycle rates, a full handshake could take 30+ minutes. Is there a lightweight ratchet variant compatible with LoRa's constraints? This is worth exploring in a follow-on iteration.

Sender anonymity at the LoRa layer: The Meshtastic packet header includes the sender's node ID (a 4-byte integer assigned during configuration). This ID is transmitted in cleartext and is visible to any LoRa receiver. Can the node ID be rotated or pseudonymised without breaking relay functionality? Meshtastic's flood routing requires a stable node ID for duplicate suppression. A short-lived ephemeral node ID scheme with coordinated rotation is theoretically possible but requires firmware modifications — out of scope here.

Multi-packet chunking under duty cycle: For messages longer than 151 bytes (e.g. carrying a contact's full X25519 public key for a first key exchange), chunking across multiple LoRa frames under EU duty-cycle constraints could introduce minutes of latency between chunks. What is the minimum viable chunking protocol that handles packet loss and out-of-order delivery without a reliable transport layer? This is the primary open question for any future expansion of LoRa beyond short texts.

Reticulum as an alternative transport: The Reticulum Network Stack (Mark Qvist) provides initiator anonymity by default, Ed25519 signing, and HKDF-based key derivation — philosophically closer to this project's model than Meshtastic. It runs on RNode firmware (SX1262) and supports Python and a growing set of platform wrappers. A follow-on prototype using Reticulum instead of Meshtastic would validate whether Reticulum's transport model is compatible with the project's cryptographic envelope and whether its resource consumption is acceptable on mobile hardware.

Legal status of E2E encryption over ISM-band LoRa: In a small number of jurisdictions, laws restrict end-to-end encryption. Whether such restrictions apply specifically to ISM-band LoRa devices is unclear from publicly available sources. Flag for legal review before deploying in any jurisdiction with known encryption restrictions.


2-Week Day-by-Day Build Plan

Day Goal Deliverable Dependencies
1 Flash Meshtastic onto two radios; configure channel PSK; confirm BLE visibility Both radios visible in Meshtastic phone app; channel configured identically on both Hardware (2× Heltec or T-Echo); Android or iOS for Meshtastic app
2 Validate two-radio mesh: send a text from Meshtastic app on phone A, receive on phone B End-to-end message delivery confirmed using stock Meshtastic app — no custom code Day 1 complete
3 Android project scaffold; BLE permissions; connect to radio via RadioBleConnection App connects to Meshtastic radio over BLE; FromRadio NOTIFY events logged Android Studio; Meshtastic protobuf dependency
4 LoraCrypto.encrypt() + LoraEnvelope.toBytes() implemented and unit-tested Envelope byte output is ≤200 bytes; encrypt/decrypt round-trip passes; tamper test passes Day 3 (project exists); lazysodium dependency
5 sendPacket() sends a LoraEnvelope to radio; radio transmits; second radio receives Radio A transmits; radio B receives; confirmed via Meshtastic app on radio B's paired phone Day 4 (crypto); Day 3 (BLE)
6 onPacketReceived() callback parses FromRadio; LoraCrypto.decrypt() applied Received LoraEnvelope decrypted correctly; plaintext displayed in logcat Day 5 (send path complete)
7 LoraMessageStore (Room + SQLite); persist sent and received messages Messages survive app restart; ciphertext in DB (not plaintext); conversation query works Day 6 (receive path); Room library
8 Key exchange UI: QR display and scan for initial shared secret setup Two phones can complete QR-based key exchange; shared secret derived and stored Day 4 (crypto); ZXing library
9 Foreground service for background BLE connection to radio App backgrounded; radio still delivers messages via foreground service Day 6 (receive path)
10 Duty-cycle test (EU): rapid send sequence; verify airtime limiting 10 messages sent; Meshtastic firmware throttles correctly; no regulatory violation Two radios; EU firmware preset
11 Three-radio hop test: A sends to C via relay through B C receives relayed message; hop count confirmed in received metadata Third radio (borrow or purchase)
12 Range characterisation: outdoor measurement at 100 m, 500 m, 1 km, 2 km RSSI and delivery rate at each distance; data recorded in findings/range.md Outdoor space; two people with radios
13 Battery measurement: T-Echo under realistic idle-with-relay load for 4 hours USB current meter reading; average mA; extrapolated battery life T-Echo; USB current meter
14 No-plaintext-on-disk verification; success criteria checklist; findings/ documents committed All success criteria checked and documented; prototype tagged v0.1.0 Days 1–13 data

Further Reading