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:
RadioNotFoundException— the radio is not powered on or not in BLE range. Prompt user to turn on and bring within 5 m of the phone.RadioConnectException— GATT status 133 (common on Samsung). Retry once after 500 ms.RadioWriteException— the radio moved out of BLE range mid-transfer. Queue the packet and retry on reconnect.
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:
-
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
ContactsKeyderivation (X25519 DH + HKDF-SHA-256 with domain separator"ProximityApp_ContactsKey_v1"). -
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:
-
Envelope budget confirmed: A 100-character UTF-8 message produces a
LoraEnvelopeof 200 bytes or fewer. Measured by unit test and logged for each test message. -
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.
-
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.
-
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.
-
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. -
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.
-
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. -
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. AllLoraMessagerows contain only ciphertext. -
MAC tamper rejection: Flipping one byte in a stored ciphertext causes
AuthenticationExceptionduring decryption. The message is not displayed. No crash. -
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
- Research Prototypes — P7 summary and context alongside P1–P6
- Prototype User Stories — Story 7: The Valley — plain-language scenario this spec implements
- Proximity Networking — BLE, NFC, and WiFi Direct transport research context
- Encryption & Privacy — XChaCha20-Poly1305, X25519, HKDF background
- Feed Format Spec — the feed data model that LoRa's bandwidth constraints make impractical to carry in full
- Spec: BLE Dead Drop — structurally similar relay model using BLE instead of LoRa
- Meshtastic documentation — firmware, hardware, and BLE API reference
- Meshtastic firmware (GitHub)
- Meshtastic Python API
- Reticulum Network Stack — alternative LoRa transport with stronger privacy properties
- RNode firmware — open firmware for SX1262 boards used by Reticulum
- RadioLib (Arduino library) — reference for custom firmware path
- ETSI EN 300 220-2 V3.3.1 — EU ISM band duty-cycle regulations
- Semtech — Introduction to LoRa — modulation fundamentals
- Meshtastic range tests — community-contributed range measurements