IFM Packet Specification
Overview
The IFM packet is the fundamental unit of communication. Everything—text, voice, video, files, control messages—is encoded as a packet. The protocol is packet-based, not stream-based, which keeps the core simple and flexible.
Packet Structure (Wire Format)
Field-by-Field Specification
| Offset | Field | Type | Size | Description |
|---|---|---|---|---|
| 0 | version | u8 | 1 | Protocol version (1) |
| 1–32 | frequency | [u8; 32] | 32 | BLAKE3 hash of canonical frequency |
| 33–64 | packet_id | [u8; 32] | 32 | BLAKE3 hash of packet (excl. signature) |
| 65–96 | sender | [u8; 32] | 32 | Sender's Ed25519 public key |
| 97–104 | timestamp | u64 | 8 | Unix ms since epoch (little-endian) |
| 105 | ttl | u8 | 1 | Hops remaining (initial: 16) |
| 106–113 | sequence | u64 | 8 | Per-sender monotonic counter |
| 114 | payload_type | u8 | 1 | See Payload Types table |
| 115–118 | payload_length | u32 | 4 | Payload bytes (little-endian) |
| 119–N | payload | Vec<u8> | Variable | Encrypted/compressed data |
| N+1–N+64 | signature | [u8; 64] | 64 | Ed25519 signature over bytes 0..N |
Total header overhead: 119 bytes + payload + 64 bytes signature
Payload Types
| Value | Name | Description | Typical Size |
|---|---|---|---|
0x00 | TEXT | UTF-8 text message | < 1 KB |
0x01 | VOICE | Opus frame (20ms) | 200–800 bytes |
0x02 | VIDEO | Video frame (AV1/VP9/H.264) | 1–50 KB |
0x03 | IMAGE | JPEG/PNG/WebP image | 10–500 KB |
0x04 | FILE | File chunk (see File Transfer) | 64 KB |
0x05 | JSON | Structured data | < 10 KB |
0x06 | PING | Keepalive/latency probe | ~32 bytes |
0x07 | PRESENCE | Join/leave/status update | < 500 bytes |
0x08 | CONTROL | Protocol control (HELLO, etc.) | < 1 KB |
0x09 | PLUGIN | Plugin-defined payload | Variable |
Packet ID Generation
The packet_id is a BLAKE3 hash of the entire packet excluding the signature:
fn compute_packet_id(packet: &PacketWithoutSignature) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
hasher.update(&[packet.version]);
hasher.update(&packet.frequency);
hasher.update(&packet.sender);
hasher.update(&packet.timestamp.to_le_bytes());
// NOTE: TTL is deliberately NOT part of the id — relays decrement it
// without re-signing, so the id must stay stable across hops for
// mesh-wide duplicate detection.
hasher.update(&packet.sequence.to_le_bytes());
hasher.update(&[packet.payload_type]);
hasher.update(&packet.payload_length.to_le_bytes());
hasher.update(&packet.payload);
*hasher.finalize().as_bytes()
}Properties
- Deterministic: Same packet → same ID
- Collision-resistant: 256-bit output
- Tamper-evident: Any change → different ID
- Used for: Deduplication cache, Store & Forward indexing
Signature
Every packet is signed with the sender's Ed25519 private key:
fn sign_packet(private_key: &[u8; 32], packet_bytes: &[u8]) -> [u8; 64] {
// packet_bytes = all fields EXCEPT signature
ed25519::sign(private_key, packet_bytes)
}Verification (performed by EVERY relay and recipient)
fn verify_packet(public_key: &[u8; 32], packet_bytes: &[u8], signature: &[u8; 64]) -> bool {
ed25519::verify(public_key, packet_bytes, signature)
}Rules:
- Relays MUST verify before forwarding
- Recipients MUST verify before processing
- Invalid signature → drop packet, do NOT relay
- The signature covers every field EXCEPT
id,signature, andttl— TTL is hop metadata: relays decrement it when forwarding without re-signing, so a signed TTL would make every relayed copy fail verification. Excluding it also keeps the packet id stable across hops, so duplicate detection works mesh-wide.
TTL (Time To Live)
| Parameter | Value |
|---|---|
| Initial TTL | 16 hops |
| Decrement | 1 per relay |
| Expiry | Drop when TTL = 0 |
TTL Flow
Important: Relays forward the packet with TTL decremented and do NOT re-sign. Because TTL is excluded from the signed view, the signature stays valid and the packet id is identical at every hop — so relayed copies verify and deduplicate correctly across the whole mesh.
Sequence Numbers
- Per-sender: Each sender maintains a monotonically increasing
u64counter - Purpose: Replay protection, ordering, nonce for encryption
- Wraparound: At
u64::MAX(practically never), reset with new identity - Verification: Recipients track highest seen sequence per sender; reject ≤ seen
Timestamp
- Format: Unix milliseconds since epoch (
u64, little-endian) - Source: Sender's local clock
- Usage:
- Freshness check (reject packets older than 5 min by default)
- Latency measurement (PING/PONG)
- Sequence tiebreaker
- Clock skew: Tolerated ±30 seconds
Payload Encoding
TEXT (0x00)
Raw UTF-8 bytesVOICE (0x01)
- Frame size: ~200–800 bytes depending on bitrate
- No additional header — Opus is self-delimiting
VIDEO (0x02)
| Codec | Value |
|---|---|
| AV1 | 0x01 |
| VP9 | 0x02 |
| H.264 | 0x03 |
| H.265 | 0x04 |
| Flag | Bit | Meaning |
|---|---|---|
| Keyframe | 0 | I-frame (random access point) |
| Config | 1 | Contains codec config (extradata) |
IMAGE (0x03)
| Format | Value |
|---|---|
| JPEG | 0x01 |
| PNG | 0x02 |
| WebP | 0x03 |
| AVIF | 0x04 |
FILE (0x04) — See File Transfer Section
JSON (0x05)
Raw UTF-8 JSON bytes (no BOM)PING (0x06)
{
"timestamp": 1699999999999,
"nonce": "random16bytes"
}PRESENCE (0x07)
{
"action": "join | leave | update",
"node_id": "12D3KooW...",
"display_name": "optional",
"status": "available | busy | away",
"frequencies": ["91.700", "chat.general"]
}CONTROL (0x08) — See Protocol Spec
PLUGIN (0x09)
File Transfer
Large files are split into chunks, each sent as a separate FILE packet.
File Manifest (First Packet)
{
"type": "manifest",
"file_id": "BLAKE3 hash of entire file",
"filename": "video.mp4",
"size": 104857600,
"chunk_size": 65536,
"total_chunks": 1600,
"mime_type": "video/mp4",
"encryption": "none | chacha20poly1305"
}File Chunk (Subsequent Packets)
Every chunk carries the file_id so reassembly is keyed per transfer — two senders broadcasting files on the same frequency concurrently can never attribute a chunk to the wrong file, and chunks that arrive before the manifest are held until it lands.
Reassembly
- Receive manifest → allocate buffer / temp file (per
file_id) - Receive chunks in any order — each carries its
file_id - Verify each chunk hash
- Write chunk at offset = index × chunk_size
- When all chunks received → verify full file hash
- Deliver to application
Compression
- Algorithm: zstd (level 3 default)
- Applied: Only to payloads > 1 KB
- Indicator: Compressed flag in packet header (future: use payload_type high bit)
- Dictionary: None (standard zstd)
fn maybe_compress(payload: &[u8], payload_type: u8) -> Vec<u8> {
if payload.len() > 1024 && is_compressible_type(payload_type) {
zstd::encode(payload, 3)
} else {
payload.to_vec()
}
}Compressible types: TEXT, JSON, FILE chunks, PLUGIN Non-compressible: VOICE (Opus), VIDEO, IMAGE (already compressed)
Encryption (Protected/Hidden Frequencies)
Applied at payload level before signing:
fn encrypt_payload(payload: &[u8], key: &[u8; 32], sequence: u64, aad: &[u8]) -> Vec<u8> {
let nonce = build_nonce(nonce_prefix, sequence);
chacha20_poly1305_encrypt(key, &nonce, payload, aad)
}- Algorithm: ChaCha20-Poly1305 (AEAD)
- Key: Derived from shared secret + canonical frequency (HKDF)
- Nonce: 12-byte prefix + 4-byte sequence (big-endian)
- AAD: Canonical frequency string
- Result: Ciphertext + 16-byte auth tag appended
Important: Signature covers the encrypted payload. Verification happens after decryption (recipient) or on ciphertext (relay — relay cannot decrypt protected frequencies, only verifies signature on ciphertext).
Maximum Limits
| Limit | Value | Enforcement |
|---|---|---|
| Max packet size | 1,048,576 bytes (1 MB) | Drop larger |
| Max payload size | 1,048,400 bytes | Header + sig overhead |
| Max frequency name | 256 bytes | Parse error |
| Dedup cache size | 100,000 entries | LRU eviction |
| Sequence window | 2^32 per sender | Track highest seen |
Packet Processing Pipeline
Sender
1. Application calls broadcast(data)
2. SDK determines payload_type, encodes payload
3. Core assigns sequence, timestamp, TTL=16
4. If protected freq: encrypt payload
5. If compressible: compress payload
6. Compute packet_id = BLAKE3(header + payload)
7. Sign packet with Ed25519 private key
8. Add to local dedup cache
9. Publish to GossipSub topicRelay
Recipient
Mermaid: Packet Lifecycle
Example: Minimal TEXT Packet (Hex Dump)
# Header (119 bytes)
01 # version = 1
a1 b2 c3 d4 ... (32 bytes) # frequency hash
e5 f6 78 90 ... (32 bytes) # packet_id
11 22 33 44 ... (32 bytes) # sender public key
00 00 00 00 00 65 4a 8b # timestamp = 1699999999999 (le)
10 # ttl = 16
01 00 00 00 00 00 00 00 # sequence = 1
00 # payload_type = TEXT (0)
05 00 00 00 # payload_length = 5
# Payload (5 bytes)
48 65 6c 6c 6f # "Hello"
# Signature (64 bytes)
9a 8b 7c 6d 5e 4f 3a 2b ... (64 bytes) # Ed25519 signatureVersioning & Extensibility
- Version 1: Current specification
- New payload types: Add to registry, unknown types → forward opaque
- New fields: Use PLUGIN payload or extend via version negotiation
- Breaking changes: Increment version, nodes reject unknown versions