diff --git a/bitchat/Services/BLE/BLEOriginTTLPolicy.swift b/bitchat/Services/BLE/BLEOriginTTLPolicy.swift new file mode 100644 index 00000000..d32b171e --- /dev/null +++ b/bitchat/Services/BLE/BLEOriginTTLPolicy.swift @@ -0,0 +1,37 @@ +// +// BLEOriginTTLPolicy.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// Chooses the TTL a locally originated public broadcast leaves with. +/// +/// Split out from `BLEService` so the choice is testable without a radio, and so +/// the reasoning lives in one place: see +/// `TransportConfig.broadcastOriginTTLRange` for why originating at a fixed +/// maximum identifies the author to any direct listener. +enum BLEOriginTTLPolicy { + /// Uniform draw from the configured range. + /// + /// The randomizer is injectable so tests can pin the value; production uses + /// the system generator. Note that TTL is excluded from the packet signature + /// (`toBinaryDataForSigning` zeroes it so relays can decrement without + /// invalidating), so varying it per message is signature-safe and needs no + /// cross-platform agreement — a peer running any version simply sees a + /// smaller starting TTL and relays it normally. + static func originTTL( + range: ClosedRange = TransportConfig.broadcastOriginTTLRange, + randomTTL: (ClosedRange) -> UInt8 = { UInt8.random(in: $0) } + ) -> UInt8 { + // A degenerate or inverted range must not trap; fall back to the + // documented default rather than crashing a send path. + guard range.lowerBound <= range.upperBound, range.lowerBound >= 1 else { + return TransportConfig.messageTTLDefault + } + return randomTTL(range) + } +} diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 919c3820..58b79ec6 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -869,7 +869,9 @@ final class BLEService: NSObject { timestamp: sendTimestampMs, payload: Data(content.utf8), signature: nil, - ttl: messageTTL + // Not the fixed maximum: that would mark every message this device + // wrote as originating here, to any direct listener. + ttl: BLEOriginTTLPolicy.originTTL() ) guard let signedPacket = noiseService.signPacket(basePacket) else { SecureLogger.error("❌ Failed to sign public message", category: .security) @@ -2892,7 +2894,12 @@ final class BLEService: NSObject { let (connectedPeerIDs, advertisedCapabilities, advertisedBridgeCell): ([Data], PeerCapabilities, String?) = collectionsQueue.sync { ( - peerRegistry.connectedRoutingData, + // Publishing the neighbour list hands the local adjacency graph + // to a single passive receiver; see + // TransportConfig.announceIncludesDirectNeighbors. + TransportConfig.announceIncludesDirectNeighbors + ? peerRegistry.connectedRoutingData + : [], PeerCapabilities.localSupported.union(runtimeCapabilities), runtimeCapabilities.contains(.bridge) ? localBridgeGeohash : nil ) diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 98029094..a4f793c2 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -6,6 +6,50 @@ enum TransportConfig { // BLE / Protocol static let bleDefaultFragmentSize: Int = 469 // ~512 MTU minus protocol overhead static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding + + /// TTL range a public broadcast is originated with. + /// + /// Originating every message at the maximum makes `ttl == messageTTLDefault` + /// a reliable "this device wrote it" marker for any direct listener, which + /// tells a passive observer who *said* a thing rather than merely who is + /// present. Drawing from a range makes the lower values ambiguous between an + /// origin and a relay: in a dense graph relays already clamp broadcasts to + /// 5, so an origin that emits 5 is indistinguishable from relayed traffic, + /// and in a sparse chain a 6 could be an origin or one hop from a 7. + /// + /// The cost is reach: a message originated at 5 crosses two fewer hops than + /// one at 7. The upper bound stays at the default so the common case is + /// unchanged, and the floor is deliberately not lower than the dense-graph + /// relay clamp — going below it would cost reach without buying ambiguity + /// that clamp does not already provide. + /// + /// Announces are deliberately excluded: the link-binding paths treat + /// `ttl == messageTTLDefault` on an announce as "direct link", and an + /// announce's sender ID already identifies the device anyway, so there is + /// nothing to hide and something to break. + static let broadcastOriginTTLRange: ClosedRange = 5...7 + + /// Whether signed announces advertise this device's direct neighbours. + /// + /// The neighbour TLV carries up to ten 8-byte peer IDs, so a *single* + /// passive receiver can reconstruct the local adjacency graph — who is + /// standing next to whom — without needing several receivers or RSSI + /// trilateration. For a crowd, that is the most sensitive thing the radio + /// layer discloses, and unlike the identity keys it is not needed for the + /// protocol to work. + /// + /// Turning it off costs source routing. `MeshTopologyTracker` builds its + /// adjacency map from these lists, and `computeRoute` needs that map, so + /// with every device silent there are no routes to compute and directed + /// traffic falls back to flooding — which is the documented fallback and is + /// already what happens whenever a route fails. Expect more airtime for + /// directed sends in dense meshes, and no correctness change. + /// + /// Kept as a constant rather than a user setting because it is a protocol + /// trade-off, not a preference: flipping it back is a one-line change, and + /// receiving peers' lists is unaffected either way, so a mixed network + /// behaves sensibly during any transition. + static let announceIncludesDirectNeighbors = false static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends diff --git a/bitchatTests/Services/RadioMetadataTests.swift b/bitchatTests/Services/RadioMetadataTests.swift new file mode 100644 index 00000000..52eaee43 --- /dev/null +++ b/bitchatTests/Services/RadioMetadataTests.swift @@ -0,0 +1,105 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +/// Two radio-layer metadata leaks that need no cross-platform agreement to +/// close: the neighbour list in announces, and the fixed origin TTL. +struct RadioMetadataTests { + + // MARK: - Origin TTL + + @Test func originTTLStaysInsideTheConfiguredRange() { + let range = TransportConfig.broadcastOriginTTLRange + for _ in 0..<200 { + let ttl = BLEOriginTTLPolicy.originTTL() + #expect(range.contains(ttl)) + } + } + + /// The point of the change: a message must not always leave at the maximum, + /// because a direct listener reads `ttl == max` as "this device wrote it". + @Test func originTTLDoesNotAlwaysUseTheMaximum() { + var seen = Set() + for _ in 0..<500 { + seen.insert(BLEOriginTTLPolicy.originTTL()) + } + #expect(seen.count > 1) + #expect(seen.contains { $0 < TransportConfig.messageTTLDefault }) + } + + @Test func originTTLUsesTheInjectedRandomizer() { + let ttl = BLEOriginTTLPolicy.originTTL(range: 5...7, randomTTL: { _ in 6 }) + #expect(ttl == 6) + } + + /// A send path must never trap on a bad range. + @Test func degenerateRangesFallBackToTheDefault() { + #expect(BLEOriginTTLPolicy.originTTL(range: 0...0) == TransportConfig.messageTTLDefault) + // Single-value range is legitimate and must be honoured. + #expect(BLEOriginTTLPolicy.originTTL(range: 4...4) == 4) + } + + /// The floor must not drop below the dense-graph relay clamp: going lower + /// costs reach without buying ambiguity the clamp does not already provide. + @Test func rangeSitsBetweenTheDenseClampAndTheDefault() { + let range = TransportConfig.broadcastOriginTTLRange + #expect(range.upperBound == TransportConfig.messageTTLDefault) + #expect(range.lowerBound >= TransportConfig.bleFragmentRelayTtlCapDense) + #expect(range.lowerBound >= 2, "TTL 1 is dropped by RelayController") + } + + // MARK: - Neighbour list + + @Test func neighborAdvertisingIsOffByDefault() { + #expect(!TransportConfig.announceIncludesDirectNeighbors) + } + + /// The mechanism that makes this backward compatible: an empty list omits + /// the TLV entirely rather than emitting a zero-length one, and the decoder + /// treats its absence as "no topology offered". + @Test func emptyNeighborListOmitsTheTLV() throws { + let announcement = AnnouncementPacket( + nickname: "alice", + noisePublicKey: Data(repeating: 0x11, count: 32), + signingPublicKey: Data(repeating: 0x22, count: 32), + directNeighbors: [], + capabilities: [.bridge] + ) + let encoded = try #require(announcement.encode()) + + // TLV type 0x04 is the neighbour list; it must not appear at all. + var offset = encoded.startIndex + var types: [UInt8] = [] + while offset < encoded.endIndex { + guard encoded.distance(from: offset, to: encoded.endIndex) >= 2 else { break } + let type = encoded[offset] + let length = Int(encoded[encoded.index(after: offset)]) + types.append(type) + offset = encoded.index(offset, offsetBy: 2 + length) + } + #expect(!types.contains(0x04)) + + let decoded = try #require(AnnouncementPacket.decode(from: encoded)) + #expect(decoded.directNeighbors == nil) + // Everything else still round-trips, so old peers lose nothing but the + // topology hint. + #expect(decoded.nickname == "alice") + #expect(decoded.capabilities == [.bridge]) + } + + /// Receiving a neighbour list must keep working: peers on older builds still + /// send one, and a mixed mesh has to behave sensibly. + @Test func receivedNeighborListsAreStillParsed() throws { + let neighbors = [Data(repeating: 0xA1, count: 8), Data(repeating: 0xB2, count: 8)] + let announcement = AnnouncementPacket( + nickname: "bob", + noisePublicKey: Data(repeating: 0x11, count: 32), + signingPublicKey: Data(repeating: 0x22, count: 32), + directNeighbors: neighbors + ) + let encoded = try #require(announcement.encode()) + let decoded = try #require(AnnouncementPacket.decode(from: encoded)) + #expect(decoded.directNeighbors == neighbors) + } +} diff --git a/docs/privacy-assessment.md b/docs/privacy-assessment.md index 92598407..38bfe217 100644 --- a/docs/privacy-assessment.md +++ b/docs/privacy-assessment.md @@ -26,13 +26,18 @@ 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 +Announces no longer advertise this device's direct neighbours. That TLV carried up to ten peer IDs, so a single passive receiver could reconstruct the local adjacency graph — who is standing next to whom — with no need for multiple receivers or signal-strength trilateration. It is off by default (`TransportConfig.announceIncludesDirectNeighbors`). Neighbour lists from other peers are still parsed, so a mixed network behaves sensibly. The cost is source routing: its adjacency map comes from these lists, so directed traffic falls back to flooding, which is already the documented fallback whenever a route fails. + 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. +Public broadcasts are originated with a TTL drawn from a range rather than always at the maximum. A fixed maximum made `ttl == default` a reliable "this device wrote it" marker to any direct listener — disclosing authorship, not merely presence. Lower draws are ambiguous between an origin and a relay, at the cost of fewer hops for some messages. Announces keep the fixed TTL, because link binding treats a maximum-TTL announce as a direct link and an announce already identifies its sender. + +Payload length remains observable for most traffic: only Noise frames are padded, and the padding itself is inside the signed bytes, so widening its coverage or fixing its length-marker gap is a coordinated cross-platform change rather than a local one. + 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