Messaging for links that barely work.

avianlink is a Rust library for end-to-end encrypted text over packet links that lose half of what you send, deliver the rest hours later, and disappear for days. It treats the network as a postal service, not a connection.

Rust 1.82 · MIT · #![no_std] core with alloc · builds for thumbv7em-none-eabihf and aarch64-apple-ios at every commit

48bytes
The smallest MTU the wire format fragments for — 12 bytes of text still fit in a packet.
50%+
Bursty packet loss with 100% eventual delivery, verified at a 64-byte MTU across repeated outages.
hours
Of one-way latency, treated as a normal Tuesday rather than a fault to report.
zero
Handshakes, sockets, connection timeouts or threads anywhere in the core.

Say what is true, quietly.

Every messaging app you know assumes something this one cannot.

That a message arrives in under a second, and that if it doesn't, something is wrong. Here a message may take an hour, a contact may be unreachable for days, and half the packets may never arrive. None of that is a fault — and an interface or protocol that treats it as one would be lying several times an hour.

  1. Sending cannot be an operation that fails. It is a local, durable write. send_text returns once the message is committed to storage; delivery is a separate event that may arrive much later.
  2. Time is not a single number. When a message was written and when it arrived are different facts, and both are sometimes interesting.
  3. Silence is information, not an error state. There is no connection to time out. A peer quiet for a week is still a peer.

The engine

A deterministic state machine you drive yourself.

The core does not own a socket, a clock, a thread or a filesystem. You hand it bytes, time and randomness; it hands you packets to transmit and events to show. The same code runs on a laptop, a phone and a microcontroller, because portability was a day-one constraint rather than a later port.

Sans-IO core

Inputs are handle_packet, send_text and handle_timeout. Outputs are poll_transmit, poll_event and next_timeout. No async, no sockets, no hidden state — so the whole protocol is testable in virtual time.

no_std, down to bare metal

#![no_std] with alloc, integer math only, no floating point in core logic. CI builds the core for thumbv7em-none-eabihf and aarch64-apple-ios on every commit, so the embedded target can never quietly rot.

Cannot panic on network input

Every byte off the wire is untrusted. The core is #![forbid(unsafe_code)] with no unwrap, no panicking indexing and no unchecked arithmetic on parsed values — enforced by lints, and checked by cargo-fuzz targets on every parser.

Encryption per packet, not per session

Every packet is independently decryptable under ChaCha20-Poly1305, with keys derived from two static identities via HKDF and nonces derived from a per-direction counter. There is no handshake to complete and no session to lose.

Content-addressed message IDs

A message ID is a truncated hash of its own encoded bytes, so duplicates are detected for free and a resend is idempotent. IDs are never put on the wire — both sides compute the same one from the content.

Causal order, exposed honestly

A Lamport clock per conversation, never wall-clock ordering. Messages are delivered to the host in arrival order together with their causal position, so a reply that shows up two hours late lands where it belongs instead of at the bottom.

Selective and per-fragment acks

Compact ACK bitmaps ride along with data whenever a packet is going out anyway; receipts are the lowest-priority traffic. A soft FRAG_ACK names which fragments landed, so a 25-fragment message under 50% loss converges in about 2× its size instead of resending everything each round.

Adaptive timers, no give-up

An RFC 6298-style estimator in integer milliseconds, sampled from packet counters rather than messages so retransmission ambiguity can't poison it. When a peer goes silent the contact drops to one probe packet per interval, capped at 30 s and never more than ~10% of a duty-cycled link's airtime.

A durable outbox

The engine writes its own state through a Storage trait you implement, in atomic batches committed before the matching in-memory change. Restore mid-flight and nothing is lost or duplicated; packet counters are reserved in blocks so a nonce is never reused.

Links are first-class

You register each link with its MTU, a rate or duty-cycle budget — airtime is often legally limited — and a relative cost. The engine fragments, paces and schedules per link. It never infers congestion from loss, because on these links loss isn't congestion.

Phase 4

Forward error correction

Most messages fit one packet, so repair is coded across messages: a sliding window emits XOR parity packets — Reed–Solomon later — so one repair recovers any one loss in its window, at a rate that adapts to each link's measured loss.

Phase 5

Summary-based sync

A peer returning after a week offline reconciles what each side holds using compact per-conversation hash ranges, rather than "send everything since X" — and makes progress even when only a fraction of its sync packets arrive.

Security model

What v1 protects, and what it plainly does not.

Identity is an Ed25519 keypair, used in its X25519 form for Diffie–Hellman. Contacts are added from a public key exchanged out of band — a QR code, a read-aloud fingerprint — and there is no handshake to intercept. The non-goals are stated as prominently as the goals, because a protocol that oversells itself is a hazard.

Guaranteed in v1

  • Confidentiality of everything but the 5–9 byte header: text, sequence numbers, Lamport values, timestamps and acks are all inside the AEAD.
  • Integrity and sender authenticity. A packet accepted as coming from a contact was produced by someone holding their static secret key or your own.
  • Replay rejection. A highest-counter watermark plus a 512-packet sliding bitmap, checked before decryption and advanced only after it succeeds.
  • Bounded attacker work. A 3-byte routing tag discards foreign traffic with a map lookup; what survives costs at most a few AEAD attempts. No input crashes the engine.

Not provided in v1

  • No forward secrecy. Traffic is protected by keys derived from two long-term keys. Anyone who later obtains either secret and recorded the traffic can read all of it. A ratchet that tolerates heavy reordering is a documented extension point.
  • No post-compromise security, and no protection against key-compromise impersonation — inherent to static-static Diffie–Hellman.
  • Limited metadata protection. Packet sizes, timing, counters and a static per-direction routing tag are visible to an observer and linkable.
  • No anonymity, deniability or group messaging, and data at rest is the host's job — the engine stores plaintext through your Storage, so encrypt it.
# pairwise key schedule, for contacts A and B
s        = X25519(sk_A, PK_B)            # reject all-zero output
prk      = HKDF-SHA256-Extract(salt = "avianlink/v1/pair", ikm = s)
key A→B  = HKDF-Expand(prk, "key" ‖ PK_A ‖ PK_B, 32)
tag A→B  = HKDF-Expand(prk, "tag" ‖ PK_A ‖ PK_B, 3)
nonce    = counter as u64 little-endian ‖ 00 00 00 00
Users compare 80 bits out of band. Every client renders it identically: a1b2-c3d4-e5f6-7890-abcd

Wire format

Twenty-two bytes of overhead, and a budget table to prove it.

The first byte is a version, because peers will run mismatched builds for years. Everything after it is varints, a three-byte routing tag and counter-derived nonces — because at a 48-byte MTU every header byte is text you didn't send.

Packet layout 21 bytes minimum

Associated data: the header, authenticated but not encrypted Nonce: from the counter, never sent in full

0x01 MSG Message fragment: seq, fragment count, index, data.
0x02 ACK Cumulative seq plus a bitmap, with the largest packet counter seen and how long ago.
0x05 FRAG_ACK Which fragments of a partially reassembled message have arrived. Advisory.
0x00 PADDING Ignored on receipt.
0x03 REPAIR Reserved — FEC repair symbol.
0x04 SYNC Reserved — reconciliation summary.

Header-overhead budget generated by test

MTU Text in one packet Fragment payload Efficiency
48 B12 B18 B37%
51 B · LoRa15 B21 B41%
64 B28 B34 B53%
128 B92 B98 B76%
255 B218 B224 B87%
512 B · EDGE475 B481 B93%
1200 B1163 B1169 B97%

At a 48-byte MTU the full 16-byte Poly1305 tag costs a third of the packet. It isn't truncated anyway: saving eight bytes would mean composing ChaCha20 and Poly1305 by hand instead of using an audited AEAD, and v1 declines that trade.

Simulator

Thousands of simulated hours per minute of wall time.

You cannot debug a protocol like this on a real radio. So the simulator is held to the same standard as the engine: virtual time, a seeded RNG, and any failing run reproducible exactly from its seed. Impairments include heavy-tailed delay, Gilbert–Elliott bursty loss, bit flips, duplication, reordering, duty-cycle limits and scheduled outages.

clean

Lab conditions. Measures the engine's own overhead.

MTU
1200 B
Delay
5 ms
Loss
none
Outages
none

geo_satellite

GEO VSAT: ~550 ms round trip, 64 kbit/s, rain fade.

MTU
1200 B
Delay
275 ms ±40
Loss
0.5–50%
Outages
2 min / 2 h

edge_2g

2G EDGE: slow, spiky, Pareto-tailed latency.

MTU
512 B
Delay
300 ms → 8 s
Loss
1–40%
Outages
30 s / 20 min

disaster_cellular

Overloaded, damaged cellular: corruption, duplication, reordering.

MTU
256 B
Delay
400 ms → 30 s
Loss
10–85%
Outages
3 min / 5 min

lora_like

EU868 at SF9: a 51-byte packet costs ~0.37 s of airtime.

MTU
51 B
Delay
0–50 ms
Loss
5–60%
Duty
1% / hour

Timers tuned in the simulator, not guessed disaster_cellular · 10 seeds × 1000 messages

Variant p50 p95 p99 Wire / payload
Fixed 4 s RTO, no backoff 5.3 s6.5 m10.6 m23.9×
RFC 6298 as written, 120 s cap 39.1 s13.2 m19.1 m3.65×
2·rttvar, 30 s cap 33.1 s13.1 m24.7 m3.87×
2·rttvar, 30 s cap, 2 grace probes 8.4 s7.5 m11.3 m4.75×
1·rttvar, 10 s cap, 3 grace 5.5 s6.5 m11.3 m7.04×

The chosen point holds p95 and p99 within about 15% of the naive fixed-timer build while spending a fifth of the bytes — and it is the knee of the curve: pushing further buys little latency for a lot of bandwidth. All three knobs are plain Config fields for hosts that weigh latency and airtime differently.

Desktop app

An interface that never pretends.

A Tauri 2 app — Svelte 5 front end, Rust and SQLite behind it — built on one rule: tell the truth about what has and hasn't happened, and be calm about it. The user decides whether it's a problem. Private keys stay in the backend and in the OS keychain; the front end sees public keys and fingerprints only.

○ Queued Saved on this device; nothing has gone out yet. It gets the quietest treatment of the four — a message sitting queued for six hours is a normal Tuesday, not a failure.
✓ Sent At least one transmission left this device. The retransmission count lives in the hover detail and the accessible name — never on screen, because a number climbing on its own draws the eye to something you can't act on.
✓✓ Delivered The recipient's device acknowledged it. Hover gives both times and the gap between them: 14:05 → 15:17, 1h 12m.
⊘ Failed Only if the engine ever gives up — which today it does not. The interface refuses to invent this state from a timeout: a message queued for a week is still queued, and saying otherwise would be the exact lie this design exists to avoid.

Distinguishable without colour

Each state differs in shape, not just tone, and the set still reads correctly in greyscale. Colour is a redundant second channel; only failed gets a hue, and it has its own glyph too.

The composer never blocks

Sending is a local durable write, so it always succeeds instantly and it never shows an error about a link. No spinners, no "reconnecting…", no red badge for a network doing exactly what it was designed for.

Late messages land in place

The thread is ordered by the library's causal order, not arrival time. A reply that arrives hours after the message it answers inserts into the right position with a subtle "arrived later" marker — and without yanking your scroll position.

Calm link health

Which links are up, recent loss, and time since the last packet from each contact. Informative, never alarming: being offline for a day is normal here, and the chrome says so without raising its voice.

Fingerprints you can read aloud

Your own key as a QR code, a copyable string and five groups of four hex characters. Comparison is made easy and explained in one sentence, because out-of-band verification is the whole trust model.

Smooth at 50,000 messages

A virtualized thread over backend pagination, on SQLite with versioned migrations. Writes the library treats as durable are durable before the engine is told they succeeded.

Status

Built in phases, each with a spec written first.

Every phase ends with a report: what was built, test results, simulator metrics, open questions, and anything that deviated from the plan and why. Significant choices and the alternatives rejected are kept in a decision log.

00 Skeleton Workspace, crate boundaries, doc stubs, CI across host, embedded and iOS targets. Done
01 Framing and simulator Wire format spec, encode/decode, fragmentation at any MTU, the engine API, every impairment in the simulator, a fuzz target on handle_packet. Done
02 Reliability Selective acks, adaptive RTO, a storage-backed outbox, dedupe, causal ordering, bounded queues — 100% eventual delivery at a 64-byte MTU with 50% bursty loss and outages. Done
03 Cryptography Identities, contacts, per-packet AEAD, routing tags, replay windows, and a security model that names its non-goals. Done
04 Forward error correction Cross-message parity with an adaptive repair rate, measured against Phase 3's p95 under bursty loss. Next
05 Sync Summary-based reconciliation, so a peer offline for a simulated week converges with bounded bytes exchanged. Planned
06 Real network The UDP adapter, a two-peer CLI demo, and tc netem profiles mirroring the simulator's. Planned
07 Portability A C ABI via cbindgen, an iOS XCFramework, and an example on real microcontroller hardware. Planned

A bird made of signal.

Horizontal lines trace a gull in flight, each one a packet, fading from the wingtips toward the body like a signal tailing off. One wing line breaks in two — a nod to loss, and to recovery.