bitchat Documentation

Bluetooth mesh chat, IRC vibes

RAW Doc

File: README.md

bitchat

A decentralized peer-to-peer messaging app with dual transport architecture: local Bluetooth mesh networks for offline communication and internet-based Nostr protocol for global reach. No accounts, no phone numbers, no central servers. It's the side-groupchat.

bitchat.free

๐Ÿ“ฒ App Store

๐Ÿ“ฒ Play Store

Getting a copy you can trust

Install from the App Store, or build from source you have verified. A compiled build from anywhere else cannot be verified โ€” see Verifying bitchat for how to check source against the per-release hash manifest, and for what to do if that is the only build you can get.

This matters more than it usually would: this repository has been the target of takedown demands, and when a repository or releases page disappears, mirrors appear that nobody can check.

License

This project is released into the public domain. See the LICENSE file for details.

Features

  • Dual Transport Architecture: Bluetooth mesh for offline + Nostr protocol for internet-based messaging
  • Location-Based Channels: Geographic chat rooms using geohash coordinates over global Nostr relays
  • Intelligent Message Routing: Automatically chooses best transport (Bluetooth โ†’ Nostr fallback)
  • Decentralized Mesh Network: Automatic peer discovery and multi-hop message relay over Bluetooth LE
  • Privacy First: No accounts, no phone numbers, no servers. Note that the mesh does use a persistent per-device identifier derived from your identity key โ€” see the whitepaper on identity and metadata for what a nearby radio can observe
  • Private Message End-to-End Encryption: Noise Protocol for mesh, BitChat private envelopes for Nostr fallback
  • IRC-Style Commands: Familiar /slap, /msg, /who style interface
  • Universal App: Native support for iOS and macOS
  • Emergency Wipe: Triple-tap to instantly clear all data
  • Performance Optimizations: LZ4 message compression, adaptive battery modes, and optimized networking

[Technical Architecture](https://deepwiki.com/permissionlesstech/bitchat)

BitChat uses a hybrid messaging architecture with two complementary transport layers:

Bluetooth Mesh Network (Offline)

  • Local Communication: Direct peer-to-peer within Bluetooth range
  • Multi-hop Relay: Messages route through nearby devices (max 7 hops)
  • No Internet Required: Works completely offline in disaster scenarios
  • Noise Protocol Encryption: End-to-end encryption, with forward secrecy for live sessions (store-and-forward mail is sealed without it โ€” see the whitepaper)
  • Binary Protocol: Compact packet format optimized for Bluetooth LE constraints
  • Automatic Discovery: Peer discovery and connection management
  • Adaptive Power: Battery-optimized duty cycling

Nostr Protocol (Internet)

  • Global Reach: Connect with users worldwide via internet relays
  • Location Channels: Geographic chat rooms using geohash coordinates
  • 440+ Relay Network: Distributed across the globe for reliability
  • BitChat Private Envelopes: App-specific encrypted private messages over Nostr relays
  • Ephemeral Keys: Fresh cryptographic identity per geohash area

BitChat's private-envelope format is proprietary and is not NIP-17,
NIP-44, or NIP-59 compatible. It uses Nostr as a relay transport but only
interoperates with BitChat clients: private payloads travel inside kind-1059
events whose v2:-prefixed content is a BitChat-specific XChaCha20-Poly1305
construction, not NIP-44 encryption.

Channel Types

`mesh #bluetooth`

  • Transport: Bluetooth Low Energy mesh network
  • Scope: Local devices within multi-hop range
  • Internet: Not required
  • Use Case: Offline communication, protests, disasters, remote areas

Location Channels (`block #dr5rsj7`, `neighborhood #dr5rs`, `country #dr`)

  • Transport: Nostr protocol over internet
  • Scope: Geographic areas defined by geohash precision
    • block (7 chars): City block level
    • neighborhood (6 chars): District/neighborhood
    • city (5 chars): City level
    • province (4 chars): State/province
    • region (2 chars): Country/large region
  • Internet: Required (connects to Nostr relays)
  • Use Case: Location-based community chat, local events, regional discussions

Direct Message Routing

Private messages use intelligent transport selection:

  1. Bluetooth First (preferred when available)

    • Direct connection with established Noise session
    • Fastest and most private option
  2. Nostr Fallback (when Bluetooth unavailable)

    • Uses recipient's Nostr public key
    • BitChat's app-specific private-envelope encryption
    • Routes through global relay network
  3. Smart Queuing (when neither available)

    • Messages queued until transport becomes available
    • Automatic delivery when connection established

For detailed protocol documentation, see the Technical Whitepaper.

Setup

Option 1: Using Xcode

bash
open bitchat.xcodeproj

For a signed device build, create your ignored local configuration and replace
the example team ID with your Apple Developer Team ID:

bash
cp Configs/Local.xcconfig.example Configs/Local.xcconfig

Local.xcconfig.example derives unique app and App Group identifiers from that
team ID. The entitlement files already reference $(APP_GROUP_ID), so tracked
project or entitlement files do not need to be edited.

Useful command-line checks from the repository root:

bash
# macOS Debug build without signing
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" \
  -configuration Debug CODE_SIGNING_ALLOWED=NO build

# Full SwiftPM test suite
swift test

# iOS simulator tests
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" \
  -sdk iphonesimulator \
  -destination 'platform=iOS Simulator,name=iPhone 17' test

If iPhone 17 is unavailable, choose an installed simulator from:

bash
xcodebuild -showdestinations -project bitchat.xcodeproj -scheme "bitchat (iOS)"

Option 2: Using `just`

bash
brew install just
just check
just run

just build and just run use the current bitchat (macOS) scheme and keep
Xcode output in the ignored .DerivedData/ directory. They never patch source,
project, configuration, or entitlement files.

just clean removes only .DerivedData/ and .build/. It does not invoke Git
or restore tracked files, so uncommitted work is preserved. just test runs the
SwiftPM suite and just test-ios runs the iPhone 17 simulator suite.

Localization

  • App localizations live in bitchat/Localizable.xcstrings.
  • Share extension strings are separate in bitchatShareExtension/Localization/Localizable.xcstrings.
  • Prefer keys that describe intent (app_info.features.offline.title) and reuse existing ones where possible.
  • Run xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build to compile-check any localization updates.

File: bitchat/ViewModels/Extensions/README.md

ChatViewModel Extensions

This directory contains extensions to ChatViewModel to modularize its functionality.

  • ChatViewModel+Tor.swift: Handles Tor lifecycle events and notifications.
  • ChatViewModel+PrivateChat.swift: Manages private chat logic, media transfers (images, voice notes), and file handling.
  • ChatViewModel+Nostr.swift: Contains all logic related to Nostr integration, Geohash channels, and Nostr identity management.

The main ChatViewModel.swift retains core state, initialization, and coordination logic.


File: bitchatTests/README.md

Test Harness Guide

This test suite uses an in-memory networking harness to make end-to-end and integration tests deterministic, fast, and race-free without touching production code.

In-Memory Bus

  • File: bitchatTests/Mocks/MockBLEService.swift
  • Registry/Adjacency: Global registry maps peerID to a MockBLEService instance; adjacency records simulated links between peers.
  • Setup: Call MockBLEService.resetTestBus() in setUp() to clear state between tests.
  • Topology: Use simulateConnectedPeer(_:) and simulateDisconnectedPeer(_:) to add/remove links. connectFullMesh() helpers in tests build larger topologies.
  • Handlers: Tests can observe data via messageDeliveryHandler (decoded BitchatMessage) and packetDeliveryHandler (raw BitchatPacket).
  • Deโ€‘duplication: A thread-safe seenMessageIDs prevents duplicate deliveries during flooding/relays.

Broadcast Flooding

  • Flag: MockBLEService.autoFloodEnabled
  • Intent: When true, public broadcasts propagate across the entire connected component (ignores TTL for reach) while still deโ€‘duping to prevent loops.
  • Usage: Enabled in Integration tests (setUp) to simulate large-network broadcast; disabled in E2E tests to keep routing explicit and verify TTL behavior (see PublicChatE2ETests.testZeroTTLNotRelayed).

Rehandshake Flow (Noise)

  • Why: The legacy NACK recovery path was removed; recovery now relies on Noise session rehandshake after decrypt failure or desync.
  • Manager: NoiseSessionManager manages per-peer sessions.
  • Pattern: On decrypt failure, proactively clear the local session and re-initiate a handshake. The peer accepts and replaces their session.
  • Test: IntegrationTests.testRehandshakeAfterDecryptionFailure
    • Corrupts ciphertext to induce a decrypt error.
    • Calls removeSession(for:) on the initiatorโ€™s manager before initiateHandshake(with:) to avoid alreadyEstablished.
    • Verifies encrypt/decrypt succeeds post-rehandshake.

Tips

  • Determinism: Add small async delays only where handler installation/topology changes could race the first send.
  • Scoping: Keep autoFloodEnabled toggled only within Integration tests; always reset in tearDown() to avoid cross-test contamination.
  • Direct vs Relay: Private messages target a specific peer when adjacent; otherwise they are surfaced to neighbors for relay and, if known, also delivered to the target.

Quick Start

  • Create nodes and connect them:
    • let svc = MockBLEService(); svc.myPeerID = "PEER1"
    • svc.simulateConnectedPeer("PEER2")
  • Observe messages:
    • svc.messageDeliveryHandler = { msg in /* asserts */ }
  • Enable broadcast flooding for Integration suites only:
    • MockBLEService.autoFloodEnabled = true

File: docs/GeohashPresenceSpec.md

Geohash Presence Specification

Overview

The Geohash Presence feature provides a mechanism to track online participants in geohash-based location channels. It uses a dedicated ephemeral Nostr event kind to broadcast "heartbeats," ensuring accurate and privacy-preserving online counts.

Nostr Protocol

Event Kind

A new ephemeral event kind is defined for presence heartbeats:

  • Kind: 20001 (GEOHASH_PRESENCE)
  • Type: Ephemeral (not stored by relays long-term)

Event Structure

The presence event mimics the structure of a geohash chat message (Kind 20000) but without content or nickname metadata, to minimize overhead and focus purely on "liveness".

json
{
  "kind": 20001,
  "created_at": <timestamp>,
  "tags": [
    ["g", "<geohash>"]
  ],
  "content": "",
  "pubkey": "<geohash_derived_pubkey>",
  "id": "<event_id>",
  "sig": "<signature>"
}
  • content: Must be empty string.
  • tags: Must include ["g", "<geohash>"]. Should NOT include ["n", "<nickname>"].
  • pubkey: The ephemeral identity derived specifically for this geohash (same as used for chat messages).

Client Behavior

1. Broadcasting Presence

Clients MUST broadcast a Kind 20001 presence event globally when the app is open, regardless of which screen the user is viewing.

  • Global Heartbeat:
    • Trigger: Application start / initialization, or whenever location (available geohashes) changes.
    • Frequency: Randomized loop interval between 40s and 80s (average 60s).
    • Scope: Sent to all geohash channels corresponding to the device's current physical location.
    • Privacy Restriction: Presence MUST ONLY be broadcast to low-precision geohash levels to protect user privacy. Specifically:
      • Allowed: REGION (precision 2), PROVINCE (precision 4), CITY (precision 5).
      • Denied: NEIGHBORHOOD (precision 6), BLOCK (precision 7), BUILDING (precision 8+).
    • Decorrelation: Individual broadcasts within a heartbeat loop must be separated by random delays (e.g., 2-5 seconds) to prevent temporal correlation of public keys across different geohash levels. The main loop delay is adjusted to maintain the target average cadence.

2. Subscribing to Presence

Clients must update their Nostr filters to listen for both chat and presence events on geohash channels.

  • Filter:
    • kinds: [20000, 20001]
    • #g: ["<geohash>"]

3. Participant Counting

The "online participants" count shown in the UI aggregates unique public keys from both presence heartbeats and active chat messages.

  • Logic:
    • Maintain a map of pubkey -> last_seen_timestamp for each geohash.
    • Update last_seen_timestamp upon receiving a valid Kind 20001 (Presence) OR Kind 20000 (Chat) event.
    • A participant is considered "online" if their last_seen_timestamp is within the last 5 minutes.

4. UI Presentation

The presentation of the participant count depends on the geohash precision level and data availability.

  • Standard Display: For channels where presence is broadcast (Region, Province, City) OR any channel where at least one participant has been detected, show the exact count: [N people].
  • High-Precision Uncertainty: For high-precision channels (Neighborhood, Block, Building) where:
    • Presence broadcasting is disabled (privacy restriction).
    • AND the detected participant count is 0.
    • Display: [? people]
    • Reasoning: Since clients don't announce themselves in these channels, a count of "0" is misleading (people could be lurking).

5. Implementation Details (Android Reference)

  • NostrKind.GEOHASH_PRESENCE: Added constant 20001.
  • NostrProtocol.createGeohashPresenceEvent: Helper to generate the event.
  • GeohashViewModel:
    • startGlobalPresenceHeartbeat(): Coroutine that collectLatest on LocationChannelManager.availableChannels.
    • Implements randomized loop logic (40-80s) and per-broadcast random delays (2-5s).
    • Filters channels by precision <= 5 before broadcasting.
  • GeohashMessageHandler:
    • Refactored onEvent to update participant counts for both Kind 20000 and 20001.
  • LocationChannelsSheet:
    • Implements the [? people] display logic for high-precision, zero-count channels.

Benefits

  • Accuracy: Counts reflect both active listeners (via heartbeats) and active speakers (via messages).
  • Privacy: High-precision location presence is NOT broadcast. Temporal correlation between different levels is obfuscated via random delays.
  • Consistency: "Online" status is maintained globally while the app is open.
  • Transparency: The UI correctly reflects uncertainty (?) when privacy rules prevent accurate passive counting.

File: docs/privacy-assessment.md

bitchat Privacy Assessment

Last reviewed: July 2026

Scope

  • BLE discovery, mesh routing, gossip sync, private delivery, and courier behavior
  • Nostr private fallback, bridge courier drops, mesh bridging, geohash channels, and notices
  • CoreLocation and reverse geocoding
  • Local persistence, panic wipe, logging, and App Store privacy manifests

The user-facing contract is PRIVACY_POLICY.md. This document records implementation-level behavior and residual risks that should be re-audited when storage or transport semantics change.

Current Posture

  • The project operates no account system, analytics pipeline, advertising SDK, or project-owned messaging backend.
  • Mesh transport is peer-to-peer. Optional internet features use third-party Nostr relays and can expose public content, coarse geohashes, timing, relay, and network metadata.
  • Private payloads are end-to-end encrypted, but public mesh, board, bridge, and geohash content is intentionally visible to its participants.
  • Local storage is bounded where practical and included in panic wipe, but it is not wholly ephemeral. The app persists the stores listed below.
  • The app and share extension each bundle a privacy manifest declaring their actual required-reason API use.
  • A locked device discloses less than an unlocked one, but not nothing. Notification previews are hidden by default and the app-switcher snapshot is covered; see "Locked and Seized Devices".

BLE Discovery and Metadata

Signed announces can expose:

  • Nickname, persistent Noise public key, and Ed25519 signing public key
  • Capability flags
  • A bounded set of short direct-neighbor identifiers
  • A coarse rendezvous geohash when the bridge capability is enabled

The app does not advertise the device's assigned name. iOS manages BLE address randomization; bitchat does not attempt to create a stable MAC address.

That randomization does not deliver the unlinkability it might suggest, because the application layer publishes stable identifiers above it. The 8-byte peer ID in every packet header is the first 8 bytes of the Noise static key fingerprint, so it does not rotate; announces carry the static keys themselves; and the fixed service UUID makes any bitchat device detectable as such by a passive scanner. A receiver in radio range can therefore recognise a specific device across sessions and locations, and detect that the app is in use at all. RSSI, timing, traffic volume, and radio fingerprints remain observable as well.

Ingress validates announce structure, sender binding, signatures, payload sizes, and freshness. Current-link Noise authentication is required before destructive courier handoff or strict directed delivery. Floods, queues, fragments, ingress work, and per-peer state are bounded.

Private Messaging and Courier Delivery

  • Direct mesh sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256.
  • Undelivered outgoing private messages remain in a bounded, ChaChaPoly-sealed outbox for at most 24 hours. Its key is stored in the keychain.
  • Physical couriers store opaque Noise-sealed envelopes, not plaintext. Deposits have trust-tier quotas, per-depositor caps, a global cap, and at most a 24-hour lifetime.
  • Spray-and-wait copies are bounded and progress cannot be replenished by replaying a deposit.
  • Delivery status advances only after real transport admission or explicit relay acceptance; late failure cannot downgrade a delivered/read message.
  • Panic wipe deletes the outbox, courier mail, keys, dedup state, and active transport state.

Residual risk: private-message metadata such as timing, radio adjacency, ciphertext size, rotating recipient tags, and relay connections remains observable. A compromised recipient device can disclose plaintext.

Public Gossip, Boards, and Media

  • Recent signed public mesh messages are archived in Application Support for up to 6 hours so gossip sync survives a relaunch and can cross mesh partitions.
  • Signed public board posts and tombstones persist until author-selected expiry, at most seven days. Stores are bounded by global and per-author quotas.
  • Group metadata (name, roster, creator, epoch) persists as protected JSON; group keys live in the keychain until leave/removal/wipe.
  • Voice notes and images are stored in Application Support. Incoming media has a 100 MB oldest-first quota, and all managed media โ€” incoming and outgoing โ€” is additionally bounded by age: a launch-time sweep deletes anything older than seven days. In-flight live captures and files reserved by a delivery or deletion in progress are exempt regardless of age. Panic wipe invalidates detached preparation work, cancels active transfers, closes live capture files, and removes the managed media tree before returning.

Public archives contain content already intended for public mesh/board distribution, but a seized unlocked device can reveal it. Group metadata and media can reveal relationships or content even when the in-memory chat timeline has gone away.

Nostr and Mesh Bridge

  • BitChat's proprietary private-envelope fallback protects plaintext with secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. It is not NIP-17, NIP-44, or NIP-59 compatible and does not provide forward secrecy against later compromise of the recipient's static Nostr private key. Relays still see the recipient public-key tag, event timing and size, and network metadata.

  • Bridge courier drops use a throwaway publisher key, an opaque Noise-sealed envelope, and a day-rotating recipient tag. Only a party already holding the recipient's Noise static key can compute candidate tags.

  • Relay publication is considered successful only after an explicit NIP-01 OK true from at least one target relay. Rejected, disconnected, timed-out, or merely socket-written events stay retryable.

  • When mesh bridge is enabled, public mesh messages not marked โ€œnearby onlyโ€ are signed under a per-cell Nostr identity and published to a neighborhood rendezvous geohash. Presence and public bridge traffic therefore expose a coarse area to relays and participants.

  • A bridge gateway can carry signed bridge/location events and opaque courier drops for nearby mesh-only peers. It cannot validly publish a neighbor's radio-only message because the author must first sign the bridge event.

  • Relays added by hand persist in local preferences (nostr.customRelays, at most 8, normalized on read) and are wiped on panic. An added relay names an operator someone chose to route through, so it is treated as sensitive local state rather than inert configuration. .onion addresses are accepted, which is the point: the four built-in relays are well-known clearnet hostnames and a filter blocking four names would otherwise end internet-delivered private messages until a new build shipped.

  • Turning the Tor preference off routes relay sockets and the relay-directory fetch directly, disclosing the device IP to every relay operator including those carrying private messages. The settings UI states this while the preference is off.

Residual risk: Nostr relay retention and logging are outside project control. Public events may be copied indefinitely. Timing, coarse location, and participation can be correlated even when content is encrypted or per-cell identities are used.

Location

  • When-in-use CoreLocation access computes geohash choices and bridge cells. Permission revocation stops live sampling and releases subscriptions.
  • Exact coordinates are not persisted by bitchat or placed into mesh/Nostr payloads.
  • Selected/bookmarked geohashes, teleport flags, and display names persist in local preferences; a fine geohash can identify a small area.
  • Friendly place names use CLGeocoder.reverseGeocodeLocation. Apple may process the supplied location under its own privacy terms, so this operation is not accurately described as wholly on-device.
  • Automatic presence is limited to lower-precision geohashes; precise posts occur through user-selected channels, notes, notices, or the bridge behavior presented in the UI.

Logging and Telemetry

  • SecureLogger uses OSLog privacy markers and filters likely secrets. Release builds suppress debug verbosity.
  • No project analytics or telemetry endpoint exists.
  • Apple system logs, Nostr relays, network providers, and nearby radios can still observe operational metadata outside the project's logging layer.

Privacy Manifests

bitchat/PrivacyInfo.xcprivacy declares:

  • UserDefaults: CA92.1 for app-only preferences and 1C8F.1 for the shared app group
  • File timestamps/metadata: C617.1 for app-container files and 3B52.1 for user-granted files
  • System boot time: 35F9.1 for elapsed-time deadlines and timers

bitchatShareExtension/PrivacyInfo.xcprivacy declares app-group UserDefaults reason 1C8F.1. Both manifests declare no tracking domains and no data collection by the app developer. They must remain bundled in their respective executable bundles.

Locked and Seized Devices

The realistic compromise for many of the people this app is built for is not interception but a phone taken and, often, unlocked under coercion.

  • Notification content is rendered by the system on the lock screen, so it is readable without unlocking. Message previews are therefore hidden by default: alerts state that a direct message, mention, or location-channel activity arrived, and withhold the message body, the sender's nickname, and the geohash until the app is opened. userInfo still carries the routing peer ID and deep link, neither of which the system displays. The preference is notifications.hideMessagePreviews; turning it off restores full previews.
  • The window is covered on willResignActive, so the snapshot iOS stores for the app switcher shows a placeholder rather than an open conversation. The cover is opaque rather than blurred, and is added synchronously because the capture follows shortly after that notification. Panic wipe separately deletes snapshots already on disk.
  • Clearing a mesh timeline erases the on-disk gossip archive behind it, so cleared public history is deleted rather than hidden. The echo watermark still suppresses pre-clear messages this device hears again from peers.
  • Managed media is bounded by age as well as size, so a received photo does not outlive its conversation indefinitely.

Not addressed, and deliberately out of scope here:

  • No duress mechanism. There is no decoy passphrase, no wipe-on-failed-authentication, and no biometric or passcode lock on the app itself. A coerced unlock discloses everything the device still holds. Adding one is a product decision as much as an engineering one: in some jurisdictions destroying data on demand is itself an offence, so a mode that hides may protect someone better than one that destroys, and the choice should be made deliberately rather than by default.
  • macOS gets no file-protection classes. Every FileProtectionType application is inside #if os(iOS); Data Protection on macOS additionally requires an entitlement. The Mac app also has no app-switcher equivalent.
  • Media is not sealed at the app layer. It relies on the platform default protection class, which is readable once the device has been unlocked since boot. Sealing under a key with the same accessibility would not change that; only a key gated on user authentication would, and that conflicts with receiving media while locked.

Panic Wipe Coverage

The panic action clears identity/session state, preferences, location state, groups, prekeys, outbox mail, courier mail, bridge dedup state, gossip archive, board data, managed media, hand-added relays, and active subscriptions/transports. Managed media deletion completes synchronously, after active media work has been invalidated. Keychain secrets use device-only accessibility, and an install marker detects and clears app keys that survive uninstall before a later reinstall can use them. New persistent stores must add an explicit wipe hook and a regression test.

Release Review Checklist

  • Reconcile every new Application Support, UserDefaults, keychain, cache, or relay write with this assessment and PRIVACY_POLICY.md.
  • Re-scan required-reason APIs and validate both bundled PrivacyInfo.xcprivacy files before archive submission.
  • Verify panic wipe reaches any newly added persistent store.
  • Treat geohash precision, bridge-cell changes, new relay tags, and announce fields as privacy-surface changes.
  • Re-run real-device Bluetooth, background/locked-device recovery, location revocation, and audio-route checks; simulators cannot validate the physical side of those behaviors.
  • Check what a new notification discloses on a locked screen, and that the app-switcher snapshot is covered, whenever notification or scene-lifecycle code changes.

File: docs/REQUEST_SYNC_MANAGER.md

Request Sync Manager & V2 Packet Updates

This document details the implementation of the Request Sync Manager and updates to the V2 packet structure to improve synchronization security and attribution on iOS, mirroring the Android implementation.

Overview

The goal of these changes is to make the request sync functionality "less blind". Previously, sync requests were broadcast, and responses were accepted without strict attribution or timestamp validation (to allow syncing old messages). This opened up potential spoofing vectors and prevented us from enforcing timestamp checks on normal traffic.

The new implementation introduces a RequestSyncManager to track outgoing sync requests and attributes incoming responses (RSR - Request-Sync Response) to specific peers. This allows us to:

  1. Enforce Timestamp Validation: Normal packets now require timestamps to be within 2 minutes of the local clock.
  2. Exempt Solicited Sync Responses: Packets marked as RSR are exempt from timestamp validation only if they correspond to a valid, pending sync request sent to that specific peer.
  3. Prevent Unsolicited Sync Floods: Unsolicited RSR packets are rejected.

Protocol Changes

Binary Protocol Updates

  • New Flag: IS_RSR (0x10) added to the packet header flags.
  • BitchatPacket: Updated to include isRSR: Bool field.
  • Encoding/Decoding: Updated BinaryProtocol to handle the new flag.

Request Sync Payload

The REQUEST_SYNC packet payload (TLV encoded) has been updated to include:

  • sinceTimestamp (Type 0x05): filter-coverage cursor (UInt64 big-endian). The requester's GCS filter only covers packets at or after this timestamp; the responder skips older packets instead of re-sending them every round.
  • fragmentIdFilter (Type 0x06): targeted fragment resync (UTF-8 string). Comma-separated 16-hex-char (8-byte) fragment stream IDs โ€” the ID that prefixes every fragment payload.
    • Requester: when a broadcast reassembly stalls (no new fragment for 5 s), the fragment assembler reports the stream ID and a REQUEST_SYNC with types = fragment and this filter goes to each connected peer (re-requested at most every 10 s per stream). Directed reassemblies are excluded โ€” peers only archive broadcast fragments for sync.
    • Responder: when the filter is present, the fragment diff is restricted to exactly the named streams and the sinceTimestamp cursor is bypassed for them; the GCS filter still excludes pieces the requester already holds. Responses keep RSR marking, TTL 0, per-peer response rate limiting (8/30 s), and REQUEST_SYNC itself remains link-local (TTL 0, never relayed).
    • Bounds: at most 60 IDs per request. Each ID encodes as 16 hex chars plus a comma separator, so the largest value is 60 ร— 17 โˆ’ 1 = 1019 bytes, within the decoder's 1024-byte acceptance cap; oversized filter values are ignored (the rest of the request still decodes).

Architecture

RequestSyncManager

A new component (Sync/RequestSyncManager.swift) responsible for:

  • Tracking: Stores peerID -> timestamp mappings for pending sync requests.
  • Validation: isValidResponse(from: PeerID, isRSR: Bool) checks if an incoming RSR packet matches a pending request within the 30-second window.
  • Cleanup: Periodically removes expired requests.

GossipSyncManager Updates

  • Unicast Sync: Instead of blind broadcasting, the periodic sync task now iterates over connected peers and sends unicast REQUEST_SYNC packets.
  • Registration: Before sending, requests are registered with RequestSyncManager.
  • Response Marking: When responding to a REQUEST_SYNC, generated packets (Announce/Message) are explicitly marked with isRSR = true (and ttl = 0).

BLEService (Security Manager) Updates

  • Timestamp Enforcement: Checks abs(now - packetTimestamp) < 2 minutes for standard packets.
  • Conditional Exemption: If packet.isRSR is true (or packet is a legacy TTL=0 response), it queries RequestSyncManager.
    • Valid: If solicited, timestamp check is skipped (allowing historical data sync).
    • Invalid: If unsolicited or timed out, the packet is rejected.

Usage

These changes are integrated into BLEService and GossipSyncManager. No external API changes are required for clients, but all peers must be updated to support the new IS_RSR flag and protocol logic to participate in the secure sync process.


File: docs/SOURCE_ROUTING.md

Source-Based Routing for BitChat Packets (v2)

This document specifies the Source-Based Routing extension (v2) for the BitChat protocol. This upgrade enables efficient unicast routing across the mesh by allowing senders to specify an explicit path of intermediate relays.

Status: Implemented in Android and iOS: both decode routed packets, forward along routes, and originate routes. iOS origination is policy-gated (see ยง8). Backward compatible (v1 clients never receive routed frames from iOS: routes are only originated when every node on the path has been observed speaking v2).


1. Protocol Versioning & Layering

To support source routing and larger payloads, the packet format has been upgraded to Version 2.

  • Version 1 (Legacy): 2-byte payload length limit. Ignores routing flags.
  • Version 2 (Current): 4-byte payload length limit. Supports Source Routing.

Key Rule: The HAS_ROUTE (0x08) flag is only valid if the packet version >= 2. Relays receiving a v1 packet must ignore this flag even if set.


2. Packet Structure Comparison

The following diagram illustrates the structural differences between a standard v1 packet and a source-routed v2 packet.

V1 Packet (Legacy)

text
+-------------------+---------------------------------------------------------+
| Fixed Header (14) | Variable Sections                                       |
+-------------------+----------+-------------+------------------+-------------+
| Ver: 1 (1B)       | SenderID | RecipientID | Payload          | Signature   |
| Type, TTL, etc.   | (8B)     | (8B)        | (Length in Head) | (64B)       |
| Len: 2 Bytes      |          | (Optional)  |                  | (Optional)  |
+-------------------+----------+-------------+------------------+-------------+

V2 Packet (Source Routed)

text
+-------------------+-----------------------------------------------------------------------------+
| Fixed Header (16) | Variable Sections                                                           |
+-------------------+----------+-------------+-----------------------+------------------+-------------+
| Ver: 2 (1B)       | SenderID | RecipientID | SOURCE ROUTE          | Payload          | Signature   |
| Type, TTL, etc.   | (8B)     | (8B)        | (Variable)            | (Length in Head) | (64B)       |
| Len: 4 Bytes      |          | (Required*) | Only if HAS_ROUTE=1   |                  | (Optional)  |
+-------------------+----------+-------------+-----------------------+------------------+-------------+

(*) Note: A Route can be attached to any packet type that has a RecipientID (flag HAS_RECIPIENT set).

Fixed Header Differences

Field Size (v1) Size (v2) Description
Version 1 byte 1 byte 0x01 vs 0x02
Payload Length 2 bytes 4 bytes UInt32 in v2 to support large files. Excludes route/IDs/sig.
Total Size 14 bytes 16 bytes V2 header is 2 bytes larger.

3. Source Route Specification

The Source Route field is a variable-length list of intermediate hops that the packet must traverse.

  • Location: Immediately follows RecipientID.
  • Structure:
    • Count (1 byte): Number of intermediate hops (N).
    • Hops (N * 8 bytes): Sequence of Peer IDs.

Intermediate Hops Only

The route list MUST contain only the intermediate relays between the sender and the recipient.

  • DO NOT include the SenderID (it is already in the packet).
  • DO NOT include the RecipientID (it is already in the packet).

Example:
Topology: Alice (Sender) -> Bob -> Charlie -> Dave (Recipient)

  • Packet SenderID: Alice
  • Packet RecipientID: Dave
  • Packet Route: [Bob, Charlie] (Count = 2)

4. Topology Discovery (Gossip)

To calculate routes, nodes need a view of the network topology. This is achieved via a Neighbor List extension to the IdentityAnnouncement packet.

The ANNOUNCE packet payload now consists of a sequence of TLVs. The standard identity information is followed by an optional Gossip TLV.

  • Mechanism: Appended to the IdentityAnnouncement payload.
  • New TLV Type: 0x04 (Direct Neighbors).
  • Content: A list of Peer IDs that the announcing node is directly connected to.

TLV Structure (Type 0x04):

text
[Type: 0x04] [Length: 1B] [NeighborID1 (8B)] [NeighborID2 (8B)] ...

The Length field indicates the total size of the neighbor IDs in bytes (N * 8). There is no explicit count field.

Nodes receiving this TLV update their local mesh graph, linking the sender to the listed neighbors.

Edge Verification (Two-Way Handshake)

To prevent spoofing and routing through stale connections, the Mesh Graph service implements a strict two-way handshake verification:

  • Unconfirmed Edge: If Peer A announces Peer B, but Peer B does not announce Peer A, the connection is treated as unconfirmed. Unconfirmed edges are visualized as dotted lines in debug tools but are excluded from route calculations.
  • Confirmed Edge: An edge is only valid for routing when both peers explicitly announce each other in their neighbor lists. This ensures that the connection is bidirectional and currently active from both perspectives.

5. Fragmentation & Source Routing

When a large source-routed packet (e.g., File Transfer) exceeds the MTU and requires fragmentation:

  1. Version Inheritance: All fragments MUST be marked as Version 2.
  2. Route Inheritance: All fragments MUST contain the exact same Route field as the parent packet.

Why? If fragments were sent as v1 packets or without routes, they would fall back to flooding, negating the bandwidth benefits of source routing for large data transfers.


6. Security & Signing

Source routing is fully secured by the existing Ed25519 signature scheme.

  • Scope: The signature covers the entire packet structure (Header + Sender + Recipient + Route + Payload).
  • Verification: The receiver verifies the signature against the SenderID's public key.
  • Integrity: Any tampering with the route list by malicious relays will invalidate the signature, causing the packet to be dropped by the destination.

Signature Input Construction:
Serialize the packet exactly as transmitted, but temporarily set TTL = 0 and remove the Signature bytes.


7. Relay Logic

When a node receives a packet not addressed to itself:

  1. Check Route:
    • Is Version >= 2?
    • Is HAS_ROUTE flag set?
    • Is the route list non-empty?
  2. If YES (Source Routed):
    • Find local Peer ID in the route list at index i.
    • Next Hop: The peer at i + 1.
    • Last Hop: If i is the last index, the Next Hop is the RecipientID.
    • Action: Attempt to unicast (sendToPeer) to the Next Hop.
    • Fallback: If the Next Hop is unreachable, fall back to broadcast/flood to ensure delivery.
  3. If NO (Standard):
    • Flood the packet to all connected neighbors (subject to TTL and probability rules).

8. iOS Origination Policy

iOS attaches a route (upgrading the packet to v2 and re-signing it) only when
all of the following hold at send time (BLESourceRouteOriginationPolicy):

  1. Authored locally. The packet's SenderID is our own peer ID. Relays
    never rewrite someone else's packet โ€” adding a route would force a
    re-sign under the wrong key. Relays only follow existing routes
    (BLERouteForwardingPolicy).
  2. Directed. The packet has a single-peer RecipientID (not the
    broadcast ID). In practice this covers Noise-encrypted private traffic,
    private file transfers, and their fragments (fragments inherit the
    parent's route and version, per ยง5).
  3. TTL headroom. TTL > 1. Link-local packets (e.g. REQUEST_SYNC,
    always TTL 0) never carry routes.
  4. Recipient not directly connected. A direct write already delivers in
    one hop; a route would only add bytes.
  5. Complete v2 path exists. BFS over the confirmed-edge mesh graph
    (MeshTopologyTracker, built from verified announce directNeighbors
    claims, entries expiring after 60 s) finds a path with at most 4
    intermediate hops
    where every intermediate hop and the recipient
    has been observed originating or relaying a v2 packet. Nodes never seen
    speaking v2 are assumed v1-only and are excluded โ€” a v1 client cannot
    decode a v2 frame, so routing through it would silently drop the packet.
  6. No recent route failure. See below.

If any gate fails, behavior is exactly the pre-routing flood/direct-write
path โ€” v1 peers observe no change.

Failure Fallback

A routed unicast rides one path; a broken hop loses the packet where a flood
would heal around it. iOS keeps a small per-recipient health cache
(BLESourceRouteFailureCache): a routed send that sees no inbound packet
authored by the recipient within 10 s counts as a route failure, and directed
sends to that recipient fall back to flooding for the next 60 s before
routing is attempted again. Retransmission of the payload itself stays where
it always was (MessageRouter and higher layers).


File: docs/TOR-INTEGRATION.md

Tor integration

Overview

Internet traffic โ€” Nostr relay sockets and the geo-relay directory fetch โ€” is routed through Tor by default, fail-closed: when Tor is wanted but not ready, requests queue rather than falling back to clearnet.

Tor is provided by Arti, in-process, vendored as a Swift package under localPackages/Arti wrapping a Rust static-library xcframework. There is no tor binary, no torrc, and no control port. A SOCKS5 listener on 127.0.0.1:39050 is the only interface.

Key pieces

  • TorManager โ€” owns the Arti client and its data directory under Application Support, exposes the SOCKS port, and provides awaitReady().
    • torEnforced is compile-time: true unless BITCHAT_DEV_ALLOW_CLEARNET is defined. It is not set anywhere in Configs/ or the project file, so release builds enforce.
    • isStarting, bootstrapProgress, and bootstrapSummary describe an attempt in flight.
    • bootstrapDidStall becomes true when an attempt spends its whole 75-second deadline without completing, and posts .TorBootstrapDidStall. This is the state a network that blocks Tor produces, and it is deliberately distinct from isStarting: without it the UI says "starting torโ€ฆ" indefinitely. It is cleared on each new start or restart.
  • TorURLSession โ€” a shared URLSession with the SOCKS proxy configured when proxying is on, and an unproxied session when it is off. setProxyMode(useTor:) is the switch, driven by NetworkActivationService.
  • NetworkActivationService โ€” decides whether Tor may run at all. Tor starts when the activation policy permits it and the Tor preference is on. persistedTorPreference(in:) is a nonisolated read of that preference for callers off the main actor.

Both network call sites go through TorURLSession: NostrRelayManager (relay websockets) and GeoRelayDirectory (directory CSV refresh). There is no other outbound network in the app or the share extension.

The Tor preference is user-visible

The earlier version of this document said there are no user-visible settings. There is one: a tor routing toggle in settings, persisted under networkActivationService.userTorEnabled, defaulting to on.

Turning it off is a real change in exposure, not a performance tweak. Every fail-closed guard is conditioned on the preference, so with it off:

  • relay websockets connect directly, and every relay operator sees the device IP โ€” including relays carrying private messages;
  • the geo-relay directory fetch also goes direct.

The settings UI states this while the toggle is off.

GeoRelayDirectory keys its Tor wait on the preference, not on live readiness, and this distinction is load-bearing. With Tor off, waiting for a client that has been shut down would spend the full bootstrap timeout on every refresh and freeze the directory on its cached copy. With Tor on but not ready, the wait must still fail so the fetch is skipped rather than silently leaking the IP.

Relays

Private messages target the built-in relay set plus any relays added by hand (NostrRelaySettings, capped at 8, .onion addresses accepted). The built-in set is four well-known clearnet hostnames, so a filter blocking four names would otherwise end internet-delivered private messages until a new build shipped.

Artifact maintenance

  • Binary provenance, rebuild steps, and current hashes: docs/ARTI-BINARY-PROVENANCE.md, enforced by .github/workflows/arti-provenance.yml.
  • The xcframework must include iOS device, iOS simulator, and macOS arm64 slices.
  • Any refresh reviews the Rust source, Cargo.lock, generated header, build script, and new hashes together. A binary-only update is not acceptable.

Known gap: no bridges or pluggable transports

arti-client is built with default-features = false and features ["tokio", "rustls"] only โ€” no pt-client, no bridge-client โ€” and arti-bitchat/src/lib.rs bootstraps from stock configuration with no bridge lines and no configurable directory authorities.

So in a country that blocks Tor by blocking the public relays and directory authorities, bootstrap never completes. The app reports that clearly now instead of appearing to start forever, and the BLE mesh is unaffected, but there is no circumvention path: obfs4, snowflake, and meek are all unavailable.

Closing this means enabling the pluggable-transport features, plumbing bridge configuration through the FFI and a settings surface, and rebuilding the xcframework under the pinned toolchain with a provenance-manifest update. That is the single largest remaining gap in censorship resilience for the internet transport.

Dev bypass (local only)

Define the Swift compiler flag BITCHAT_DEV_ALLOW_CLEARNET to allow direct network access without Tor while developing. Never enable it in release builds.