Skip to content

Content Transfer

⚠️ Critical for Mobile: Push notifications are not optional for production mobile apps. Without them, users must manually open the app to receive synced data.

Push notification flow: Device A pushes to relay, relay notifies or sends push to offline Device B, Device B wakes and pulls

The sync-client MUST expose push token hooks from Day 1, even if backend integration comes later:

// sync-client API
impl SyncClient {
pub async fn register_push_token(
&self,
token: String,
platform: PushPlatform,
) -> Result<()>;
pub async fn unregister_push_token(&self) -> Result<()>;
}
pub enum PushPlatform {
Apns, // Apple Push Notification Service
Fcm, // Firebase Cloud Messaging
Web, // Web Push (future)
}
// JavaScript/TypeScript bindings (for JS-based frameworks)
export interface SyncClient {
// ... existing methods ...
// Push notification integration (Day 1)
registerPushToken(token: string, platform: 'apns' | 'fcm'): Promise<void>;
unregisterPushToken(): Promise<void>;
// Event for handling push-triggered wake
on(event: 'push-wake', handler: () => void): void;
}
pub struct RegisterPush {
/// Platform-specific push token
pub token: String,
/// Platform identifier
pub platform: PushPlatform,
/// App bundle ID (for APNS)
pub app_id: Option<String>,
}
pub struct UnregisterPush {
/// Reason for unregistering
pub reason: UnregisterReason,
}
pub enum UnregisterReason {
UserDisabled,
TokenExpired,
AppUninstalled,
}

On PUSH (when recipient offline):

async fn handle_push_for_offline_device(
device: &Device,
blob: &Blob,
push_service: &PushService,
) {
if let Some(push_token) = device.push_token {
// Send silent push notification
let notification = PushNotification {
token: push_token,
platform: device.push_platform,
payload: PushPayload::SilentSync {
group_id: blob.group_id,
cursor: blob.cursor,
},
// Silent push - no user-visible notification
content_available: true,
alert: None,
};
push_service.send(notification).await?;
}
}

iOS (APNS):

// In your iOS app delegate
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.hexString
// Pass to your sync client (adapt to your framework's native bridge)
SyncClient.shared.registerPushToken(token: token, platform: .apns)
}
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
// Silent push received - trigger sync
SyncClient.shared.handlePushWake()
fetchCompletionHandler(.newData)
}

Android (FCM):

// In your Firebase messaging service
class SyncFirebaseService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
// Pass to your sync client (adapt to your framework's native bridge)
SyncClient.getInstance().registerPushToken(token, PushPlatform.FCM)
}
override fun onMessageReceived(message: RemoteMessage) {
if (message.data["type"] == "sync") {
// Trigger sync in background
SyncClient.getInstance().handlePushWake()
}
}
}
PhaseScopeDependency
Day 1Client API hooks (register/unregister)None
Day 1Message types (REGISTER_PUSH, etc.)sync-types
Phase 5Relay stores push tokenssync-relay
Phase 5Relay sends to APNS/FCMPush service integration
FutureWeb Push supportService workers

Key Point: The client-side hooks MUST exist from Day 1 so apps can register tokens. The relay-side push sending can come later, but the API contract must be stable.


Amendment (2026-02-02): Added per iroh-deep-dive-report.md recommendations.

The sync protocol is optimized for small encrypted blobs (app state, JSON entries, ~100 KB sweet spot). When apps need to sync photos (2-10 MB), voice memos (1-5 MB), document scans (0.5-3 MB), or PDFs, pushing megabytes through a relay designed for kilobytes is inefficient.

Affected use cases by app type:

App TypeContentSize RangeFrequency
Journal appsPhotos, voice memos1-15 MBHigh (daily)
Health appsMeal photos, progress pics, scans1-10 MBHigh
Finance appsReceipt scans, invoice PDFs0.5-5 MBMedium
Note appsArticle images, PDF attachments0.5-20 MBMedium
Contact appsContact photos50-500 KBLow (small enough for sync relay)
Password managersNoneNone

17.2 Architecture: Encrypt-Then-Hash with iroh-blobs

Section titled “17.2 Architecture: Encrypt-Then-Hash with iroh-blobs”

Large content bypasses the sync relay entirely. The relay handles only small metadata; actual content transfers device-to-device via iroh-blobs.

Flow:

Content transfer flow: Sender encrypts and creates ContentRef, Relay handles only metadata, Receiver fetches blob via iroh-blobs and decrypts

Key properties:

  • iroh-blobs sees only ciphertext — BLAKE3 hash is of encrypted bytes
  • Resumable — iroh-blobs resumes from last verified chunk if interrupted
  • Verified — every 16 KiB chunk integrity-checked during streaming
  • No relay load — large files travel P2P, relay handles only metadata

See Wire Protocol — Section 5.3 for the full struct definition. Key fields:

FieldPurpose
content_hashBLAKE3 hash of ciphertext (iroh-blobs address)
encryption_nonceXChaCha20-Poly1305 nonce for decryption
content_sizeOriginal size (for UI progress)
mime_typeContent type for app handling
thumbnail_hashOptional preview (encrypted, much smaller)

See Security Model — Section 4.1 for the HKDF derivation. Same key for all devices in the group.

DeviceGC Strategy
SenderKeeps blob tagged until all devices ACK
ReceiverDrops tag after successful download + local storage
Orphan protectionSender keeps tag for grace period (30 days) if ContentRef deleted before all fetches

Thumbnail-first strategy:

  1. Show “Photo attached” immediately (from ContentRef)
  2. Show thumbnail if thumbnail_hash present (tiny iroh-blobs fetch)
  3. Download full resolution on demand or WiFi

Background transfer:

  • Queue large downloads for WiFi / charging
  • iroh-blobs handles resumption natively
  • iOS/Android background fetch can trigger thumbnail downloads

Storage quota:

  • Each app manages its own content storage budget
  • Old content can be evicted and re-fetched (ContentRef persists in sync log)
ScenarioSolution
Same LANmDNS direct discovery → LAN-speed transfer
Different networks, both onlineiroh relay forwards encrypted blobs
Asynchronous (rarely online together)Pending downloads queue, resume on next app foreground
Future enhancementRelay-hosted blob cache with TTL (deployment optimization)
// Enable mDNS for same-LAN direct transfer (recommended)
let discovery = ConcurrentDiscovery::new()
.add(MdnsDiscovery::new()) // LAN discovery
.add(DnsDiscovery::new(dns_url)) // Our DNS server
.add(DhtDiscovery::new()); // Fallback (optional)
let endpoint = Endpoint::builder()
.discovery(discovery)
.relay_mode(RelayMode::Custom(our_relay_map)) // Our infrastructure only
.build()
.await?;

For production deployments, run your own iroh infrastructure:

ComponentPurposeDeployment
iroh-relayEncrypted datagram relay when P2P failsDocker, any VPS
iroh-dns-serverEndpoint discovery by IDDocker, your domain
mDNSLAN discoveryZero infrastructure
DHTDecentralized fallbackZero infrastructure

Configuration:

// Disable n0 default relays, use only our infrastructure
let relay_map = RelayMap::from_url("https://relay.yourdomain.com".parse()?);

This ensures zero runtime dependency on any third party.