A hybrid messaging application that seamlessly switches between web and SMS transport. All messages are stored locally on the user's device — the server acts only as a relay, never persisting message content.
This repository hosts two related but separately-paced efforts:
-
airwire (product track) — this document. The immediate, buildable goal: encrypt and decrypt text and images under a pre-shared key and move them over SMS/MMS. Each payload is sent either as a raw encrypted-and-encoded blob, or steganographically disguised as benign content — human-like text via Markov chains, or an information-bearing image. Everything below specifies this track.
-
airwave (research track) — see
docs/research-proposal.md. An exploratory research proposal for an adaptive, channel-agnostic protocol that establishes audio communication over an unknown channel (VoIP, a lossy voice codec, or an open-air speaker+microphone link) — discovering the channel's transmittable features at runtime and adapting its encoding to them. Framing and bibliography only at this stage; no implementation.
See docs/roadmap.md for the phased implementation plan for this track,
starting with a platform-independent Python proof of concept of the crypto/framing/obfuscation
core before any mobile app work begins, and docs/design-decisions.md
for the record of specific technical trade-offs made along the way, with the reasoning behind
each.
Messages are never stored on the server. The server receives, relays, and discards. All message history lives exclusively on the user's device.
Every message carries a delivery status:
| Status | Meaning |
|---|---|
| Pending | Message is still being transmitted to the server |
| Sent | Server has received the message |
| Delivered | Message was delivered to the recipient's device |
The server enforces timeouts with retries on every message:
- If a message is not fully received from the sender within the timeout, it becomes unsent and the sender is notified.
- If a message is not fully delivered to the recipient within the timeout, it becomes undelivered and the sender is notified.
- In both cases the message is discarded from the server.
Exception: notification messages (e.g. presence updates) are fire-and-forget — no retransmission, no timeout.
The server tracks each user's current transport mode and routes messages accordingly.
- Sending: the client calls the server API directly; the server acknowledges.
- Receiving: the server sends a Firebase push notification, the client comes online, downloads the message, and acknowledges.
- Sending: the client splits the message into SMS-sized pieces and sends them; the server acknowledges via SMS.
- Receiving: the server does the same in reverse.
- When a device comes online, it calls the API to declare web mode (unless the user has restricted the app to SMS-only).
- When a device goes offline, it sends an SMS to declare SMS mode (if available, unless the user has restricted the app to web-only).
Every message is a Protobuf structure containing:
| Field | Required | Description |
|---|---|---|
id |
Yes | Rolling 4-byte message ID |
sender |
Yes | Sender identifier (phone number) |
recipient |
Yes | 16-byte receiver ID |
type |
Yes | Message type |
payload |
No | Optional message body |
Standard TLS. The entire size-prefixed Protobuf message is sent over a TLS connection.
Fully asynchronous encryption using X25519 key exchange and XChaCha20-Poly1305 for symmetric encryption.
- Service messages (e.g. the initial handshake) carry the full asymmetric overhead.
- Data message bodies are encrypted symmetrically only, to save space.
A data message body is cut into large hyperslices (configurable, ~1KB by default), and each hyperslice is encrypted as a single AEAD operation — one nonce and tag cover the whole hyperslice, not each individual outgoing message. That's what keeps the per-message overhead low; see design decision #1 for the reasoning and the numbers behind it. The resulting ciphertext is split into small, message-sized chunks, each carrying only a cheap sequence number, preceded by one small header message (itself encrypted) describing how to reassemble and verify the chunks that follow. The receiver acknowledges each hyperslice as a whole (also encrypted); on any failure, the whole hyperslice is retried.
SMS is limited to 160 ASCII characters (160 bytes) per message. The exact overhead per hyperslice
now depends on the configured hyperslice and chunk sizes rather than a single fixed table — see
core/sources/chunking.py for the mechanics, and
design decision #1 for the current
numbers with the shipped defaults: roughly 91% of raw bytes sent are message content rather than
overhead, versus roughly 68% under an earlier, naive per-message encryption scheme.
This constraint means voice notes are realistically transferable over MMS only.
Users can opt in to MMS transport. The process follows the same chunked protocol as SMS, but with larger payloads. To make MMS content appear benign, the app offers two encoding strategies:
- Markov-chain text — the encrypted payload is processed and encoded into human-like natural language text.
- Image steganography — the payload is embedded into a generated image (the image appears random/noisy).
Note: MMS availability varies by country and carrier.
The core application (web mode + SMS mode) is free. MMS support is a premium feature.
TODO!