Encrypted point-to-point messaging over raw LoRa, with reliable delivery of payloads far bigger than one packet. Everything runs on the microcontroller.
This came out of a personal messenger project (an ESP32-S3 handheld talking to an ESP32-C3 base station), where all of it has been proven on air. It's packaged here so the next person doesn't have to rebuild it.
lora_protocol.h: the wire format. A 9-byte authenticated header, message types for text, position, telemetry, acks and presets, a compose-time age field so a queued message still reports when it was written, and the fragmentation core for bulk payloads.lora_crypto.h: AES-CCM AEAD over mbedTLS (which the ESP32 core bundles). The header rides as authenticated cleartext, so routing and filtering work without decrypting; the payload is encrypted; the tag is 8 bytes and any flipped bit anywhere rejects the packet.lora_replay.h: the two pieces hobby LoRa crypto usually skips. A TX nonce counter that survives reboots (persisted in windows through two storage callbacks you provide: NVS, EEPROM, a file, whatever you have) and a strict monotonic replay filter. A nonce counter kept in RAM resets on every power cycle and quietly reuses its whole history, which breaks the crypto with no visible symptom. This header is why that can't happen here.lora_audio_tx.h: the reliable transfer. Burst every fragment fire-and-forget, ask the receiver once for a bitmap of what arrived, resend only the gaps. Radio-agnostic through six callbacks, which is also what makes it host-testable against a fake link that drops packets.emoji_table.h: optional. A 1-byte wire token scheme for a curated emoji set (a UTF-8 emoji costs 3 or 4 of your 50 payload bytes, a token costs 1), with 1-bit glyphs for small TFTs and a layout designed to be mirrored bridge-side with a drift test.
Everything except the examples runs on your desk with no hardware: make -C test check.
The space has good projects in it and it's worth being clear about the differences:
- If you want a many-node mesh with phone apps and a community, you want Meshtastic (https://meshtastic.org). Two caveats worth knowing: its stock channel encryption is AES-CTR without authentication and has documented forgery and replay weaknesses (https://meshtastic.org/docs/about/overview/encryption/limitations/), and its voice module is real-time streaming on 2.4 GHz hardware only.
- If you're happy having a phone or computer in the loop, Reticulum (https://reticulum.network) and its Sideband app already do encrypted codec2 voice notes over LoRa through an RNode, with a far richer cryptographic model than this library. The radio is a modem there; the work happens on the host.
- If you want a real-time PTT walkie-talkie, look at esp32_loradv (https://github.com/sh123/esp32_loradv). Streaming voice drops lost frames and moves on; there's no delivery guarantee, by design.
- If you want two or a few standalone microcontroller nodes, direct point to point, encrypted properly, with voice notes or other bulk payloads delivered reliably, all on the chip: that's this library. As far as I know nothing else packages that combination, and I'd genuinely like to hear about it if something does.
One shared AES-128 key for the whole group. Each packet's nonce is built from the sender id plus a persisted monotonic counter, the counter travels in the authenticated header, and receivers reject anything at or below the last counter they've seen from that sender.
What you get: confidentiality, integrity (the tamper test flips every single bit of a packet, all 8 positions of every byte, and expects every one rejected), replay rejection, and nonce safety across reboots and mid-write crashes.
What you don't get: forward secrecy, per-device identity, or key exchange. Any key holder can forge traffic as any node. Think of it as a locked WiFi network, not Signal. If a device is lost, rotate the key on every node.
The AEAD is verified against known-answer vectors generated by an independent implementation (OpenSSL, via tools/gen_aead_kat.py), so the test proves the wiring against something that isn't itself. The reboot tests run as separate processes, so the only thing that crosses a "reboot" is the persisted counter file, exactly like a device.
Every packet carries at most 52 payload bytes, so a fragment holds 48 bytes of content and a transfer is capped at 48 fragments (the received-set bitmap fits a uint64_t). The sender bursts everything, polls, gets a bitmap back, resends only the gaps. Numbers from the source project, measured on air at SF9, 917 MHz, RFM95W to RFM95W:
- a 4 second codec2 voice clip (700 B) is 15 fragments, delivered in one round in about 6.7 s
- a 6.2 second clip (1078 B) is 23 fragments, 10.5 s
- a lost fragment costs roughly one extra round (about 1.3 s); I've watched 13/15 arrive on air and both gaps recover in a single retransmit pass
- at 3 m I still lose about 1 fragment in 16. It isn't link budget (there's 20 dB of margin) and the cause is still under investigation; the reconcile pass recovers it every time, so it costs airtime, not correctness.
One hard-won behaviour is baked into the sender: it services the radio between fragments. Without that the burst itself is a deaf window (measured at 6.38 s for a 16-fragment clip) and anything the peer transmits meanwhile is silently lost, because the SX1276 FIFO holds exactly one packet.
The transfer moves bytes; it doesn't care what they are. For speech the source project pairs it with codec2 via the esp32_codec2 Arduino library at mode 1300: 8 kHz mono PCM in, about 175 bytes per second out, and the mode byte rides in every fragment so changing codec settings needs no protocol change. The ReliableBlobTx/ReliableBlobRx examples show the transport with a checksummed test blob so you can prove the link before adding a codec.
One trap worth knowing before you decode clips on a desktop: codec2's command line tools decide headered vs headerless format from the file suffix. A .c2 name is expected to carry a mode header; raw frames (which a microcontroller emits) want any other suffix. Get it wrong and c2dec decodes about 44% of the clip and exits 0.
Every deployment constant is a macro with a default, overridable before the include or with -D build flags. Both ends of a link must use the same values.
- Radio:
LORA_FREQ_MHZ(917.0),LORA_BW_KHZ(125.0),LORA_SF(9),LORA_CR(5),LORA_SYNC_WORD(0x4D),LORA_TX_DBM(20) - Protocol:
PROTO_GROUP_ID(pick your own byte),PROTO_MAX_TEXT(50),ACK_TIMEOUT_MS,TEXT_RETRIES,SOS_RETRIES - Replay/nonce:
LORA_COUNTER_WINDOW(256 counters per flash write),LORA_REPLAY_MAX_NODES(8) - Define
LORA_CUSTOM_PRESETSto supply your ownPRESET_MESSAGES[]table, andLORA_CUSTOM_NODESto supply your own node ids (the only wired-in meaning is thatto == 0broadcasts)
The defaults are an AU915 set because that's where the source project lives. Check your own regulations before transmitting; a frequency and power that are legal in one country are an offence in the next. And never power a LoRa module without an antenna attached.
I keep a hard line between "compiles", "ran on a bench" and "proven on air", because a library that overclaims is worse than no library.
- Proven on air (July 2026, in the source project): the v4 wire format, the AEAD in both directions, two-way text, voice clips in both directions including the retransmit path, and the radio-service-between-fragments fix.
- Host-tested here: everything in
test/(KATs against OpenSSL, the every-bit tamper sweep, replay, windowed counter continuation across real process restarts, and the full burst/poll/retransmit conversation against a fake lossy peer). - The examples have been run on hardware, not just compiled: MinimalTx/MinimalRx and ReliableBlobTx/ReliableBlobRx, on SX1276/RFM95W radios via RadioLib, talking to each other.
Until this is in the Library Manager: clone the repo into your Arduino libraries folder. Needs the ESP32 Arduino core (mbedTLS comes with it); the examples also need RadioLib. Host tests need mbedTLS on your machine (brew install mbedtls or apt install libmbedtls-dev).
MIT.