Conventions
- Integers in the packet header, the sealed message and voice frames are big-endian.
- A mesh packet is self-framing: the same bytes travel over Bluetooth notifications, Bluetooth writes and TCP, chunked to each link's size and reassembled by the receiver.
Mesh packet
A 41-byte header followed by the payload.
| Offset | Size | Field | Type | Meaning |
|---|---|---|---|---|
| 0 | 2 | magic | bytes | A1 7E. Lets a receiver resynchronise after a lost chunk |
| 2 | 1 | version | u8 | 1 |
| 3 | 1 | kind | u8 | 1 direct, 2 receipt, 3 hello, 4 voice |
| 4 | 1 | ttl | u8 | Hops of life left. Clamped to 8 when the packet is created |
| 5 | 16 | id | bytes | Random message identifier. The only key for duplicate suppression |
| 21 | 8 | recipient | bytes | Recipient fingerprint. FF FF FF FF FF FF FF FF means everyone |
| 29 | 8 | timestamp | u64 | Sender's clock at creation, seconds since 1970 |
| 37 | 4 | length | u32 | Payload length, at most 8,192 |
| 41 | length | payload | bytes | Depends on the kind |
Decoding fails on fewer than 41 bytes, a wrong magic, a version other than 1, an unknown kind, a length over 8,192, or a payload shorter than its declared length. The length is checked before any payload is copied.
Kinds
| Kind | Value | Payload | Hop limit at origin | Queued |
|---|---|---|---|---|
| direct | 1 | Sealed message | 8, 6 or 5 by density | Yes, in the sender's outbox |
| receipt | 2 | Sealed message carrying the receipt | 8, 6 or 5 by density | No |
| hello | 3 | Contact card as JSON, not encrypted | 2 | No |
| voice | 4 | Sealed voice frame | 1 | No |
Contact card
The hello payload, and the form in which contacts are stored, is a JSON object:
{
"signingKey": "<32-byte Ed25519 public key, base64>",
"agreementKey": "<32-byte X25519 public key, base64>",
"displayName": "Ama"
}A card is valid when both keys are 32 bytes.
Fingerprint and number
fingerprint = SHA-256(signingKey || agreementKey)[0..8]
number = Crockford base 32 of fingerprint[0..5], grouped 4-4The alphabet is 0123456789ABCDEFGHJKMNPQRSTVWXYZ. When parsing, input is uppercased, O becomes 0, I and L become 1, and hyphens and spaces are ignored. ABCD-1234, ABCD-0000 and WXYZ-1234 are reserved and never issued.
Sealed message
The payload of a direct or receipt packet.
payload = encapsulatedKey (32) || HPKE ciphertext (inner || 16-byte tag)| Parameter | Value |
|---|---|
| Suite | HPKE, DHKEM X25519, HKDF-SHA256, ChaCha20-Poly1305 |
| Mode | Base (one-shot, sender unauthenticated at the HPKE layer) |
| Info | airlink-mesh-hpke-v1 |
| Associated data | The packet's 16-byte message identifier |
Inner plaintext
| Offset | Size | Field |
|---|---|---|
| 0 | 32 | Sender's Ed25519 public key |
| 32 | 32 | Sender's X25519 public key |
| 64 | 64 | Ed25519 signature over messageID || plaintext |
| 128 | Padded plaintext |
The padded plaintext is a 4-byte length followed by the plaintext and zeros up to the next bucket of 256, 512, 1,024, 2,048, 4,096 or 8,192 bytes. The signature covers the unpadded plaintext.
A sealed message therefore adds 176 bytes to its padded plaintext: 32 for the encapsulated key, 128 for the sender's keys and signature, and 16 for the tag. Because the whole payload must fit 8,192 bytes, the largest bucket that can be delivered is 4,096.
Content types
The first byte of the plaintext says what the message carries.
| Value | Content | Body |
|---|---|---|
| 1 | Text | UTF-8 text |
| 2 | Ranging token | Ultra-wideband discovery token for precision finding |
| 3 | Sound request | Reserved. Defined in the core; the apps neither send it nor act on it |
| 4 | Receipt | The 16-byte identifier of the message being acknowledged |
| 5 | Call invite | <service> <nonce> <key>: the direct audio service name or -, the call nonce, and the caller's ephemeral X25519 public key in base64 |
| 6 | Call accept | <nonce> <key>: the nonce and the callee's ephemeral X25519 public key in base64 |
| 7 | Call decline | The nonce |
| 8 | Call end | The nonce |
| 9 | Lab probe | Measurement traffic, shown nowhere |
Call signalling bodies are UTF-8 text separated by single spaces. A nonce is eight characters. A direct audio service name has the form call- followed by eight characters.
Voice frame
The payload of a voice packet over the mesh, and the whole datagram over the direct UDP path.
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | Sequence number, u32 |
| 4 | n | ChaCha20-Poly1305 ciphertext of one Opus packet |
| 4 + n | 16 | Authentication tag |
| Parameter | Value |
|---|---|
| Key | 32 bytes, one per direction, from HKDF-SHA256 over the X25519 shared secret |
| HKDF salt | airlink-call-v1 |
| HKDF info | c2a for caller to answerer, a2c for answerer to caller |
| Nonce | 12 bytes: eight zero bytes, then the sequence number |
| Associated data | The same 12 nonce bytes |
| Audio | Opus, 24 kHz, mono, 480 samples (20 ms) per packet, 24 kbit/s |
On the mesh a 20 ms frame is 41 bytes of packet header, 4 of sequence number, about 60 of Opus at 24 kbit/s and 16 of tag: 121 bytes, or 48.4 kbit/s at 50 frames a second.
Identity file
Stored sealed at rest (see Security and privacy). The serialised form:
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | Version, 1 |
| 1 | 32 | Ed25519 private key |
| 33 | 32 | X25519 private key |
| 65 | Display name, UTF-8 |
Bluetooth identifiers
| Item | UUID |
|---|---|
| Service | A17E0001-5D3A-4B7C-9E2F-A11C0DE0A17E |
| Inbox characteristic, write and write without response | A17E0002-5D3A-4B7C-9E2F-A11C0DE0A17E |
| Outbox characteristic, notify | A17E0003-5D3A-4B7C-9E2F-A11C0DE0A17E |
The advertisement carries only the service UUID. A sender writes to the peer's inbox without response when it reached the peer as a central, and notifies through its own outbox when the peer subscribed to it. Chunks are sized to fit both limits.
Bonjour service types
| Type | Transport | Use |
|---|---|---|
_airlink._tcp | TCP, peer-to-peer allowed | The mesh over Wi-Fi. The service name is a random identifier chosen per launch |
_airlinkcall._udp | UDP, peer-to-peer allowed | The direct audio path for one call |
_airlink-lab._tcp | TCP | Lab mode's measurement collector, only when lab mode is on |