Wire Protocol
5. Message Specification
Section titled “5. Message Specification”5.1 Envelope Format
Section titled “5.1 Envelope Format”#[derive(Serialize, Deserialize)]pub struct Envelope { /// Protocol version (currently 1) pub version: u8,
/// Message type (see MessageType enum) pub msg_type: u8,
/// Sender's device public key pub sender_id: [u8; 32],
/// Sync group identifier pub group_id: [u8; 32],
/// Monotonic sequence number (assigned by relay) pub cursor: u64,
/// Wall clock timestamp (informational only) pub timestamp: u64,
/// Unique nonce for this message pub nonce: [u8; 24],
/// E2E encrypted payload pub payload: Vec<u8>,}5.2 Message Types
Section titled “5.2 Message Types”| Type | Value | Direction | Purpose |
|---|---|---|---|
HELLO | 0x01 | Client → Relay | Initial connection, declare group |
WELCOME | 0x02 | Relay → Client | Connection accepted, relay info |
PUSH | 0x10 | Client → Relay | Upload encrypted blob |
PUSH_ACK | 0x11 | Relay → Client | Blob received, cursor assigned |
PULL | 0x20 | Client → Relay | Request blobs after cursor |
PULL_RESPONSE | 0x21 | Relay → Client | Deliver requested blobs |
PRESENCE | 0x30 | Client → Relay | Heartbeat, online status |
NOTIFY | 0x31 | Relay → Client | New blob available |
DELETE | 0x40 | Client → Relay | Remove blob (after all ACK) |
REVOKE_DEVICE | 0x50 | Client → Relay | Remove device from sync group |
DEVICE_REVOKED | 0x51 | Relay → Client | Notify of device revocation |
REGISTER_PUSH | 0x60 | Client → Relay | Register push notification token |
UNREGISTER_PUSH | 0x61 | Client → Relay | Unregister push token |
CONTENT_REF | 0x70 | Client → Relay | Large content reference (iroh-blobs) |
CONTENT_ACK | 0x71 | Client → Relay | Acknowledge content transfer complete |
ERROR | 0xFF | Either | Error with code and message |
5.3 Message Structures
Section titled “5.3 Message Structures”pub struct Hello { pub version: u8, pub device_name: String, // Human readable pub group_id: [u8; 32], pub last_cursor: u64, // 0 if first sync}WELCOME
Section titled “WELCOME”pub struct Welcome { pub version: u8, pub relay_id: String, pub max_cursor: u64, // Highest cursor for group pub pending_count: u32, // Blobs waiting for this device}pub struct Push { pub blob_id: [u8; 16], // Client-generated UUID pub payload: Vec<u8>, // E2E encrypted pub ttl: u32, // Seconds until auto-delete (0 = default)}PUSH_ACK
Section titled “PUSH_ACK”pub struct PushAck { pub blob_id: [u8; 16], pub cursor: u64, // Assigned cursor pub timestamp: u64, // Server timestamp}pub struct Pull { pub after_cursor: u64, // Return blobs with cursor > this pub limit: u32, // Max blobs (default 100)}PULL_RESPONSE
Section titled “PULL_RESPONSE”pub struct PullResponse { pub blobs: Vec<BlobEntry>, pub has_more: bool, pub max_cursor: u64,}
pub struct BlobEntry { pub blob_id: [u8; 16], pub cursor: u64, pub sender_id: [u8; 32], pub payload: Vec<u8>, pub timestamp: u64,}NOTIFY
Section titled “NOTIFY”pub struct Notify { pub blob_id: [u8; 16], pub cursor: u64, pub sender_id: [u8; 32], pub timestamp: u64, pub size: u32,}pub struct Error { pub code: u32, pub message: String,}CONTENT_REF (Large Content Transfer)
Section titled “CONTENT_REF (Large Content Transfer)”/// Reference to large content stored via iroh-blobs./// The actual encrypted content is transferred P2P via iroh-blobs,/// only this small metadata blob goes through the sync relay.pub struct ContentRef { /// BLAKE3 hash of CIPHERTEXT (iroh-blobs content address) /// This is the hash of encrypted bytes, not plaintext pub content_hash: [u8; 32],
/// XChaCha20-Poly1305 nonce used for encryption (24 bytes) pub encryption_nonce: [u8; 24],
/// Original plaintext size in bytes pub content_size: u64,
/// Ciphertext size in bytes (content_size + 16 byte auth tag) pub encrypted_size: u64,}Note:
ContentRefuses encrypt-then-hash: content key is derived via HKDF fromGroupSecret + blob_id, makingblob_ida derivation input rather than a struct field. Debug output redactscontent_hashandencryption_nonce(F-016).
CONTENT_ACK
Section titled “CONTENT_ACK”/// Acknowledge successful content transferpub struct ContentAck { /// BLAKE3 hash of the acknowledged content pub content_hash: [u8; 32],}Note: Content transfers bypass the sync relay entirely. The relay only sees the small ContentRef metadata blob. Actual encrypted content is transferred device-to-device via iroh-blobs (QUIC/P2P). See Large Content Transfer Protocol.
5.4 Security Audit Remediation Features (2026-02-05)
Section titled “5.4 Security Audit Remediation Features (2026-02-05)”The following hardening measures were applied during security audit v1 + v2 remediation:
| ID | Feature | Implementation |
|---|---|---|
| F-001 | Random Argon2id salt | 16-byte salt via getrandom (not fixed/empty) |
| F-004 | File permissions | Config files written with mode 0600 (Unix) |
| F-005 | Echo suppression | rpassword hides passphrase input on terminal |
| F-006 | HELLO timeout | Configurable timeout on handshake (default 10s) |
| F-007 | Session limits | max_concurrent_sessions caps relay connections |
| F-012 | Device name truncation | UTF-8 aware truncation in Hello message |
| F-013 | Pull limit clamping | Server clamps client-requested pull limit |
| F-015/F-016 | Debug redaction | GroupSecret, GroupKey, ContentRef redact sensitive fields in Debug output |
| F-017 | Content size limit | MAX_CONTENT_SIZE = 100 MiB |
| F-019 | Cursor gap cap | Maximum cursor gap = 10,000 blobs per pull |
| SR-001 | Global rate limiter | Aggregate rate limit across all clients (governor crate) |
| CL-001 | OWASP Argon2id floor | Lowest tier raised to 19 MiB / 2 iter |
| ST-001 | Message size limit | MAX_MESSAGE_SIZE (1 MB) enforced at transport layer before deserialization |
| XC-001 | Zeroize key material | GroupSecret, GroupKey, ContentTransfer zeroize on drop |
5.5 Cursor vs Timestamp
Section titled “5.5 Cursor vs Timestamp”Why cursors instead of timestamps?
| Problem with Timestamps | Solution with Cursors |
|---|---|
| Device clocks drift | Relay assigns monotonic cursor |
| No guaranteed ordering | Cursor guarantees: “after 500” = all > 500 |
| Gap detection impossible | No gaps: cursor 501 follows 500 |
| Mobile clocks unreliable | Cursor independent of device clock |
Timestamps are kept for logging/debugging only, never for ordering.