Skip to content

Pairing & Devices

Zero accounts. QR or short code pairing.

1. User taps "Enable Sync"
2. Generate:
- group_id: random 32 bytes
- group_secret: random 32 bytes
- device_keypair (if not exists)
3. Create invite payload (v3):
{
relay_nodes: ["primary-node-id", "secondary-node-id"], // NodeIds of sync-relays
group_id: base64(group_id),
group_secret: base64(group_secret),
created_by: base64(device_pubkey),
expires: unix_timestamp + 600 // 10 minutes
}
4. Display QR code or short code
1. User taps "Join Sync"
2. Scan QR or enter short code
3. Decode invite payload
4. Store in keychain:
- group_id
- group_secret (→ derive Group Key via Argon2id)
- relay NodeId (if present — Tier 1 uses iroh public network, no relay NodeId needed)
5. Connect to relay
6. Sync begins
URL: your-app://sync?invite=BASE64_PAYLOAD
Payload (before base64):
{
"v": 1,
"r": "sync-relay-node-id-or-discovery-url",
"g": "base64(group_id)",
"s": "base64(group_secret)",
"c": "base64(creator_pubkey)",
"e": 1705000000
}
Note: For Tier 1 (iroh public network), the `r` field is omitted — peers discover each other via the public iroh relay network. For Tiers 2-6, `r` contains the sync-relay's NodeId or an HTTPS discovery URL (e.g., `https://sync.example.com/.well-known/iroh`) that resolves to a NodeId.

Note: Replace your-app:// with your application’s custom URL scheme.

16-character alphanumeric: XXXX-XXXX-XXXX-XXXX

Split:

  • First 8 chars: lookup_key (sent to relay)
  • Last 8 chars: decrypt_key (never sent)

Flow:

  1. Creator encrypts payload with decrypt_key
  2. Creator POSTs {lookup_key, encrypted_payload} to relay
  3. Joiner GETs /invite/{lookup_key} from relay
  4. Relay returns encrypted_payload and deletes it
  5. Joiner decrypts with decrypt_key

Relay never sees decrypt_key or plaintext invite.

PropertyMechanism
Time-limited10 minute expiry
Single-useDeleted on first claim
EncryptedRelay can’t read (for short codes)
RevocableCreator can cancel

Scenario: A user loses their phone. The phone never comes back online to ACK pending blobs. Under normal operation, those blobs remain on the relay until the 7-day TTL expires.

Problems:

  1. Storage waste on relay
  2. Blobs stuck in “pending delivery” state
  3. No way to remove the lost device from the sync group

Device revocation flow: User removes device → REVOKE_DEVICE message → Relay clears pending data → Optional key rotation

pub struct RevokeDevice {
/// Device to revoke (public key)
pub device_id: [u8; 32],
/// Reason (for audit log)
pub reason: RevokeReason,
/// Also clear pending blobs for this device
pub clear_pending: bool,
}
pub enum RevokeReason {
Lost, // Device lost/stolen
Decommissioned, // User retired device
Compromised, // Security concern
}

DEVICE_REVOKED (notification to other devices)

Section titled “DEVICE_REVOKED (notification to other devices)”
pub struct DeviceRevoked {
/// Revoked device
pub device_id: [u8; 32],
/// Who revoked it
pub revoked_by: [u8; 32],
/// When
pub timestamp: u64,
/// Reason
pub reason: RevokeReason,
}

For blobs that are stuck due to offline devices (not just revoked), provide a force delete option:

pub struct Delete {
pub blob_id: [u8; 16],
/// Normal delete: wait for all ACKs
/// Force delete: delete immediately, clear pending ACKs
pub force: bool,
}

Force Delete Rules:

  • Only the blob creator can force delete
  • Force delete is audited (logged)
  • Other devices receive DELETE_NOTIFICATION so they can remove local copies

On REVOKE_DEVICE:

-- 1. Mark device as revoked
INSERT INTO revoked_devices (device_id, group_id, revoked_at, reason)
VALUES (?, ?, ?, ?);
-- 2. Clear pending deliveries for revoked device
DELETE FROM deliveries
WHERE device_id = ? AND delivered_at IS NULL;
-- 3. Delete blobs that only had this device pending
DELETE FROM blobs
WHERE blob_id IN (
SELECT b.blob_id FROM blobs b
LEFT JOIN deliveries d ON b.blob_id = d.blob_id
WHERE d.blob_id IS NULL -- No remaining pending deliveries
);

On Connection (HELLO):

// Reject connections from revoked devices
if is_device_revoked(&hello.device_id, &hello.group_id) {
return Err(Error::DeviceRevoked);
}
impl SyncClient {
/// List all devices in the sync group
pub async fn list_devices(&self) -> Result<Vec<DeviceInfo>>;
/// Revoke a device (remove from sync group)
pub async fn revoke_device(
&self,
device_id: DeviceId,
reason: RevokeReason
) -> Result<()>;
/// Force delete a blob (skip waiting for ACKs)
pub async fn force_delete(&self, blob_id: BlobId) -> Result<()>;
}
pub struct DeviceInfo {
pub device_id: DeviceId,
pub device_name: String,
pub last_seen: u64,
pub pending_blobs: u32,
pub is_online: bool,
}
ConcernMitigation
Unauthorized revocationOnly devices in group can revoke
Revocation race conditionAtomic operation, idempotent
Revoked device reconnectsChecked on every HELLO
Key rotation after compromiseOptional but recommended

Optional Key Rotation:

If a device is revoked due to compromise, the group key should be rotated:

  1. Generate new group_secret
  2. Distribute to remaining devices via existing E2E channel
  3. Old group key becomes invalid
  4. Compromised device cannot decrypt new blobs