Reticulum packets must be transmitted over physical media. This chapter explains how packets are framed for transmission and how different interface types work.
Packets are variable-length byte sequences. When sending over a byte stream (like TCP or a serial port), we need to answer:
Reticulum uses HDLC-like framing to solve these problems.
HDLC (High-Level Data Link Control) is a classic framing protocol. Reticulum uses a simplified version.
+------+-----------+------+
| 0x7E | Data | 0x7E |
| FLAG | (escaped) | FLAG |
+------+-----------+------+
Two bytes require special handling:
| Byte | Name | Action |
|---|---|---|
| 0x7E | FLAG | Escape as 0x7D 0x5E |
| 0x7D | ESCAPE | Escape as 0x7D 0x5D |
When the data contains 0x7E or 0x7D, we “escape” them:
Original: 0x7E → Escaped: 0x7D 0x5E
Original: 0x7D → Escaped: 0x7D 0x5D
The pattern: insert 0x7D, then XOR the byte with 0x20.
Frame a packet containing:
[0x01, 0x7E, 0x02, 0x7D, 0x03]
1. Start with FLAG: 0x7E
2. 0x01 - normal: 0x01
3. 0x7E - escape: 0x7D 0x5E
4. 0x02 - normal: 0x02
5. 0x7D - escape: 0x7D 0x5D
6. 0x03 - normal: 0x03
7. End with FLAG: 0x7E
Result: [0x7E, 0x01, 0x7D, 0x5E, 0x02, 0x7D, 0x5D, 0x03, 0x7E]
#define HDLC_FLAG 0x7E
#define HDLC_ESCAPE 0x7D
#define HDLC_XOR 0x20
size_t hdlc_frame(const uint8_t *packet, size_t packet_len,
uint8_t *output, size_t output_max) {
size_t out_idx = 0;
// Start flag
if (out_idx >= output_max) return 0;
output[out_idx++] = HDLC_FLAG;
// Frame data with escaping
for (size_t i = 0; i < packet_len; i++) {
uint8_t byte = packet[i];
if (byte == HDLC_FLAG || byte == HDLC_ESCAPE) {
// Need to escape this byte
if (out_idx + 2 > output_max) return 0;
output[out_idx++] = HDLC_ESCAPE;
output[out_idx++] = byte ^ HDLC_XOR;
} else {
// Normal byte
if (out_idx >= output_max) return 0;
output[out_idx++] = byte;
}
}
// End flag
if (out_idx >= output_max) return 0;
output[out_idx++] = HDLC_FLAG;
return out_idx;
}Deframing is more complex than framing because we’re processing a continuous byte stream and need to handle:
The state machine approach:
Why a state machine? Bytes arrive one at a time (especially on serial links). We can’t assume we’ll receive a complete frame in one read. The state machine lets us process partial data and resume when more arrives.
Resynchronization: If data gets corrupted mid-frame, we’ll eventually see a 0x7E flag (either the end of the corrupted frame or the start of a new one). The cryptographic integrity check (Fernet HMAC) will reject corrupted packets, so the state machine just needs to find frame boundaries—not guarantee data integrity.
typedef enum {
DEFRAME_HUNTING, // Looking for start flag
DEFRAME_RECEIVING, // Accumulating frame data
DEFRAME_ESCAPED, // Previous byte was escape
} DeframeState;
typedef struct {
DeframeState state;
uint8_t buffer[1024];
size_t buffer_len;
} Deframer;
void deframer_init(Deframer *d) {
d->state = DEFRAME_HUNTING;
d->buffer_len = 0;
}
// Returns: 0 = no frame yet, >0 = frame complete (returns length)
size_t deframer_process_byte(Deframer *d, uint8_t byte,
uint8_t *frame_out, size_t max_len) {
switch (d->state) {
case DEFRAME_HUNTING:
if (byte == HDLC_FLAG) {
d->state = DEFRAME_RECEIVING;
d->buffer_len = 0;
}
// Ignore non-flag bytes while hunting
return 0;
case DEFRAME_RECEIVING:
if (byte == HDLC_FLAG) {
// End of frame
if (d->buffer_len > 0) {
// Complete frame received
size_t len = d->buffer_len;
if (len <= max_len) {
memcpy(frame_out, d->buffer, len);
}
d->buffer_len = 0;
// Stay in RECEIVING state for next frame
return len;
}
// Empty frame (consecutive flags) - ignore
return 0;
}
if (byte == HDLC_ESCAPE) {
d->state = DEFRAME_ESCAPED;
return 0;
}
// Normal byte
if (d->buffer_len < sizeof(d->buffer)) {
d->buffer[d->buffer_len++] = byte;
}
return 0;
case DEFRAME_ESCAPED:
// De-escape the byte
byte ^= HDLC_XOR;
if (d->buffer_len < sizeof(d->buffer)) {
d->buffer[d->buffer_len++] = byte;
}
d->state = DEFRAME_RECEIVING;
return 0;
}
return 0;
}
// Process multiple bytes at once
void deframer_process(Deframer *d, const uint8_t *data, size_t len,
void (*on_frame)(const uint8_t*, size_t, void*),
void *ctx) {
uint8_t frame[1024];
for (size_t i = 0; i < len; i++) {
size_t frame_len = deframer_process_byte(d, data[i],
frame, sizeof(frame));
if (frame_len > 0) {
on_frame(frame, frame_len, ctx);
}
}
}Traditional HDLC includes a CRC (Cyclic Redundancy Check) for error detection. Reticulum omits it because:
If the underlying medium is unreliable (like radio), the encryption’s HMAC will reject corrupted packets anyway.
Reticulum supports various physical media through interfaces.
typedef struct Interface {
char *name;
bool enabled;
bool online;
// Statistics
uint64_t tx_bytes;
uint64_t rx_bytes;
// Methods
bool (*send)(struct Interface *iface, const uint8_t *data, size_t len);
void (*receive)(struct Interface *iface, const uint8_t *data, size_t len);
// Type-specific data
void *impl_data;
} Interface;Connects to a remote Reticulum node over TCP.
typedef struct {
int socket_fd;
char *host;
uint16_t port;
Deframer deframer;
} TCPClientInterface;
Interface* tcp_client_create(const char *host, uint16_t port) {
TCPClientInterface *impl = malloc(sizeof(TCPClientInterface));
impl->host = strdup(host);
impl->port = port;
deframer_init(&impl->deframer);
// Connect
impl->socket_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(port),
};
inet_pton(AF_INET, host, &addr.sin_addr);
connect(impl->socket_fd, (struct sockaddr*)&addr, sizeof(addr));
Interface *iface = malloc(sizeof(Interface));
iface->name = "TCPClient";
iface->enabled = true;
iface->online = true;
iface->send = tcp_client_send;
iface->impl_data = impl;
return iface;
}
bool tcp_client_send(Interface *iface, const uint8_t *packet, size_t len) {
TCPClientInterface *impl = iface->impl_data;
uint8_t framed[2048];
size_t framed_len = hdlc_frame(packet, len, framed, sizeof(framed));
ssize_t sent = send(impl->socket_fd, framed, framed_len, 0);
if (sent > 0) {
iface->tx_bytes += sent;
return true;
}
return false;
}
void tcp_client_receive(Interface *iface) {
TCPClientInterface *impl = iface->impl_data;
uint8_t recv_buf[1024];
ssize_t n = recv(impl->socket_fd, recv_buf, sizeof(recv_buf), MSG_DONTWAIT);
if (n > 0) {
iface->rx_bytes += n;
deframer_process(&impl->deframer, recv_buf, n,
on_frame_received, iface);
}
}Listens for incoming connections from other Reticulum nodes.
typedef struct {
int listen_fd;
int *client_fds;
size_t client_count;
uint16_t port;
} TCPServerInterface;
// Accepts multiple clients, broadcasts to allConnectionless interface, useful for local networks.
typedef struct {
int socket_fd;
uint16_t port;
struct sockaddr_in broadcast_addr;
} UDPInterface;
// Note: UDP doesn't need HDLC framing since datagrams
// preserve message boundaries. Reticulum may still use
// HDLC for consistency across interface types.For direct connections to radio modems or other devices.
typedef struct {
int serial_fd;
char *device; // e.g., "/dev/ttyUSB0"
int baudrate;
Deframer deframer;
} SerialInterface;
Interface* serial_create(const char *device, int baudrate) {
SerialInterface *impl = malloc(sizeof(SerialInterface));
impl->device = strdup(device);
impl->baudrate = baudrate;
deframer_init(&impl->deframer);
impl->serial_fd = open(device, O_RDWR | O_NOCTTY);
struct termios tty;
tcgetattr(impl->serial_fd, &tty);
cfsetospeed(&tty, baudrate_to_const(baudrate));
cfsetispeed(&tty, baudrate_to_const(baudrate));
tty.c_cflag |= (CLOCAL | CREAD);
tty.c_cflag &= ~PARENB;
tty.c_cflag &= ~CSTOPB;
tty.c_cflag &= ~CSIZE;
tty.c_cflag |= CS8;
tcsetattr(impl->serial_fd, TCSANOW, &tty);
// ... create Interface struct
}For long-range radio communication using LoRa modulation.
// LoRa interfaces typically use RNode firmware
// Communication is via serial to the RNode device
typedef struct {
int serial_fd;
uint32_t frequency;
uint8_t bandwidth;
uint8_t spreading_factor;
uint8_t coding_rate;
} LoRaInterface;IFAC protects local network segments by adding an authentication code and masking the packet contents.
When IFAC is enabled:
// Derive IFAC identity from passphrase
void derive_ifac_identity(const char *passphrase,
uint8_t ifac_key[64]) {
// Use HKDF with fixed salt
static const uint8_t IFAC_SALT[] = {
0xad, 0xf5, 0x4d, 0x88, 0x2c, 0x9a, 0x9b, 0x80,
0x77, 0x1e, 0xb4, 0x99, 0x5d, 0x70, 0x2d, 0x4a,
0x3e, 0x73, 0x33, 0x91, 0xb2, 0xa0, 0xf5, 0x3f,
0x41, 0x6d, 0x9f, 0x90, 0x7e, 0x55, 0xcf, 0xf8
};
hkdf_sha256(IFAC_SALT, 32,
(uint8_t*)passphrase, strlen(passphrase),
NULL, 0, // no info
ifac_key, 64);
}The IFAC tag is the last N bytes of an Ed25519 signature, not an HMAC:
void compute_ifac_tag(const uint8_t *ifac_signing_key,
const uint8_t *packet, size_t packet_len,
uint8_t *tag, size_t ifac_size) {
// Sign the packet with the IFAC identity
uint8_t signature[64];
crypto_sign_detached(signature, NULL,
packet, packet_len,
ifac_signing_key);
// Take the LAST ifac_size bytes of the signature
memcpy(tag, signature + (64 - ifac_size), ifac_size);
}IFAC also masks the packet using an HKDF-derived key, providing confidentiality on the local segment:
void ifac_mask_packet(const uint8_t *ifac_tag, size_t ifac_size,
uint8_t *packet, size_t packet_len) {
// Derive mask from IFAC tag
uint8_t mask[512]; // enough for max packet
hkdf_sha256(ifac_tag, ifac_size,
NULL, 0, // no salt
NULL, 0, // no info
mask, packet_len);
// XOR packet with mask
for (size_t i = 0; i < packet_len; i++) {
packet[i] ^= mask[i];
}
}+------+------+-----------+------+-----------+------+
| 0x7E |Header| IFAC Tag | Hops | Rest of | 0x7E |
| | 0x8x | 16 bytes | | (masked) | |
+------+------+-----------+------+-----------+------+
The header byte has bit 7 set (0x80) to indicate IFAC presence. Default IFAC size is 16 bytes.
When sending a packet, the transport layer must choose which interface(s) to use.
typedef enum {
MODE_FULL = 0x01, // Full node, send on all interfaces
MODE_POINT_TO_POINT= 0x02, // Direct peer connection
MODE_ACCESS_POINT = 0x03, // Access point mode
MODE_ROAMING = 0x04, // Mobile client
MODE_BOUNDARY = 0x05, // Network boundary
MODE_GATEWAY = 0x06, // Gateway between networks
} InterfaceMode;
void transport_send(Transport *t, Packet *pkt) {
switch (t->interface_mode) {
case IFACE_MODE_FULL:
// Broadcast to all enabled interfaces
for (size_t i = 0; i < t->interface_count; i++) {
if (t->interfaces[i]->enabled && t->interfaces[i]->online) {
t->interfaces[i]->send(t->interfaces[i],
pkt->raw, pkt->raw_len);
}
}
break;
case IFACE_MODE_ACCESS_POINT:
// Send only on designated interface
t->access_point_interface->send(t->access_point_interface,
pkt->raw, pkt->raw_len);
break;
case IFACE_MODE_ROAMING:
// Choose best interface based on path table
Interface *best = find_best_interface(t, pkt->destination);
best->send(best, pkt->raw, pkt->raw_len);
break;
}
}void on_packet_received(Interface *iface, const uint8_t *data, size_t len) {
// 1. Parse packet
Packet pkt;
if (!parse_packet(data, len, &pkt)) {
return; // Invalid packet
}
// 2. Check IFAC if required
if (iface->ifac_enabled) {
if (!verify_ifac(&pkt, iface->ifac_key)) {
return; // Auth failed
}
}
// 3. Check if we should forward
if (pkt.hops < MAX_HOPS && should_forward(&pkt)) {
pkt.hops++;
forward_packet(&pkt, iface); // Don't send back on same interface
}
// 4. Check if addressed to us
Destination *dest = lookup_destination(pkt.destination);
if (dest) {
deliver_to_destination(dest, &pkt);
}
}Some interfaces need flow control to prevent buffer overflow.
typedef struct {
uint8_t *packets[256];
size_t packet_lens[256];
size_t head;
size_t tail;
} TxQueue;
void queue_packet(TxQueue *q, const uint8_t *packet, size_t len) {
size_t next = (q->head + 1) % 256;
if (next == q->tail) {
// Queue full - drop oldest
free(q->packets[q->tail]);
q->tail = (q->tail + 1) % 256;
}
q->packets[q->head] = malloc(len);
memcpy(q->packets[q->head], packet, len);
q->packet_lens[q->head] = len;
q->head = next;
}For bandwidth-constrained links:
typedef struct {
uint64_t bytes_per_second;
uint64_t last_send_time;
uint64_t tokens;
} RateLimiter;
bool rate_limiter_allow(RateLimiter *rl, size_t bytes) {
uint64_t now = get_time_ms();
uint64_t elapsed = now - rl->last_send_time;
// Add tokens based on time elapsed
rl->tokens += (elapsed * rl->bytes_per_second) / 1000;
if (rl->tokens > rl->bytes_per_second) {
rl->tokens = rl->bytes_per_second; // Cap at 1 second buffer
}
rl->last_send_time = now;
if (rl->tokens >= bytes) {
rl->tokens -= bytes;
return true;
}
return false; // Would exceed rate limit
}| Component | Purpose |
|---|---|
| HDLC Framing | Packet delimiting on byte streams |
| 0x7E | Frame delimiter (FLAG) |
| 0x7D | Escape character |
| Byte stuffing | Encode special bytes in data |
| No CRC | Rely on cryptographic integrity (Fernet HMAC) |
| Interface Type | Use Case |
|---|---|
| TCP Client | Connect to remote node |
| TCP Server | Accept incoming connections |
| UDP | Local network broadcast |
| Serial | Radio modems, direct links |
| LoRa | Long-range radio |
| IFAC | Interface Authentication Code |
|---|---|
| Enabled | Bit 7 of header set |
| Tag | 16-byte Ed25519 signature (last N bytes) |
| Masking | Packet XORed with HKDF-derived mask |
| Purpose | Protect and obscure local network traffic |
The next chapter covers link establishment - creating encrypted bidirectional channels.