diff --git a/bitchat/Services/BLE/BLEOriginTTLPolicy.swift b/bitchat/Services/BLE/BLEOriginTTLPolicy.swift new file mode 100644 index 00000000..a1d8baf2 --- /dev/null +++ b/bitchat/Services/BLE/BLEOriginTTLPolicy.swift @@ -0,0 +1,66 @@ +// +// 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) + } + + /// Gap after which the next voice frame counts as a new talk burst. + static let voiceBurstGap: TimeInterval = 1.0 + + /// TTL for a live-voice frame: one draw per talk burst, not per frame. + /// + /// This distinction is the whole value. Voice leaves at roughly 15 frames a + /// second, so drawing per frame would hand an observer the maximum of the + /// range within a fraction of a second — averaging over a burst would + /// defeat the randomisation completely and cost reach for nothing. One draw + /// per burst makes a burst a single sample, the same as a text message. + /// + /// Returns the TTL to use and the burst TTL to remember. A burst ends when + /// `voiceBurstGap` passes with no frame. + static func voiceBurstTTL( + now: Date, + lastFrameAt: Date?, + currentBurstTTL: UInt8?, + burstGap: TimeInterval = voiceBurstGap, + range: ClosedRange = TransportConfig.broadcastOriginTTLRange, + randomTTL: (ClosedRange) -> UInt8 = { UInt8.random(in: $0) } + ) -> UInt8 { + if let currentBurstTTL, + let lastFrameAt, + now.timeIntervalSince(lastFrameAt) < burstGap { + return currentBurstTTL + } + return originTTL(range: range, randomTTL: randomTTL) + } +} diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 0c4ecfb1..3e42bb9e 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -197,6 +197,12 @@ final class BLEService: NSObject { private let bleMaxMTU = 512 private let maxMessageLength = InputValidator.Limits.maxMessageLength private let messageTTL: UInt8 = TransportConfig.messageTTLDefault + /// Live-voice burst TTL state: one draw per talk burst rather than per + /// frame, so a ~15 fps stream is a single sample to an observer instead of + /// handing over the range maximum immediately. Both are touched only on + /// `messageQueue`. + private var currentVoiceBurstTTL: UInt8? + private var lastVoiceBurstFrameAt: Date? // Flood/battery controls private let maxInFlightAssemblies = TransportConfig.bleMaxInFlightAssemblies // cap concurrent fragment assemblies private let highDegreeThreshold = TransportConfig.bleHighDegreeThreshold // for adaptive TTL/probabilistic relays @@ -874,7 +880,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) @@ -983,7 +991,7 @@ final class BLEService: NSObject { timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: Data(), signature: nil, - ttl: messageTTL + ttl: BLEOriginTTLPolicy.originTTL() ) if let signed = noiseService.signPacket(leavePacket) { @@ -1516,7 +1524,7 @@ final class BLEService: NSObject { timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: nil, - ttl: self.messageTTL, + ttl: BLEOriginTTLPolicy.originTTL(), version: 2 ) @@ -2767,7 +2775,12 @@ final class BLEService: NSObject { let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification let signingPub = noiseService.getSigningPublicKeyData() // For signature verification - let connectedPeerIDs = peerRegistry.connectedRoutingData + // Publishing the neighbour list hands the local adjacency graph + // to a single passive receiver; see + // TransportConfig.announceIncludesDirectNeighbors. + let connectedPeerIDs: [Data] = TransportConfig.announceIncludesDirectNeighbors + ? peerRegistry.connectedRoutingData + : [] let localIdentity = localIdentityState.snapshot() let advertisedCapabilities = localIdentity.advertisedCapabilities let advertisedBridgeCell = localIdentity.advertisedBridgeGeohash @@ -2843,7 +2856,7 @@ final class BLEService: NSObject { timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: envelope, signature: nil, - ttl: self.messageTTL + ttl: BLEOriginTTLPolicy.originTTL() ) // Pre-mark our own broadcast as processed to avoid handling a // relayed self copy. @@ -2899,6 +2912,16 @@ final class BLEService: NSObject { guard !burstContent.isEmpty else { return } messageQueue.async { [weak self] in guard let self else { return } + // One TTL draw per talk burst. Drawing per frame would leak the + // maximum within a fraction of a second at ~15 frames/sec. + let now = Date() + let burstTTL = BLEOriginTTLPolicy.voiceBurstTTL( + now: now, + lastFrameAt: self.lastVoiceBurstFrameAt, + currentBurstTTL: self.currentVoiceBurstTTL + ) + self.currentVoiceBurstTTL = burstTTL + self.lastVoiceBurstFrameAt = now let packet = BitchatPacket( type: MessageType.voiceFrame.rawValue, senderID: self.myPeerIDData, @@ -2906,7 +2929,7 @@ final class BLEService: NSObject { timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: burstContent, signature: nil, - ttl: self.messageTTL + ttl: burstTTL ) guard let signedPacket = self.noiseService.signPacket(packet) else { SecureLogger.error("❌ Failed to sign voice frame", category: .security) @@ -6610,7 +6633,7 @@ extension BLEService { timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: nil, - ttl: self.messageTTL + ttl: BLEOriginTTLPolicy.originTTL() ) guard let signedPacket = self.noiseService.signPacket(basePacket) else { SecureLogger.error("❌ Failed to sign board packet", category: .security) diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 98029094..560fca51 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -6,6 +6,73 @@ 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 clamp broadcasts to 5, so an + /// origin emitting 5 is indistinguishable from relayed traffic, and in a + /// sparse chain a 6 could be an origin or one hop from a 7. + /// + /// **What this does not do, stated plainly.** Every relay branch in + /// `RelayController` emits `ttlLimit - 1`, so TTL is strictly decreasing and + /// the top of whatever range is chosen can only ever come from an origin. + /// With three values that is one message in three, and the probability that + /// a sender has revealed itself after `k` messages is `1 - (2/3)^k` — about + /// 87% by the fifth. So this meaningfully protects an occasional sender and + /// barely protects a chatty one. + /// + /// Removing the marker entirely is not possible from the origin side: it + /// needs relays to sometimes *not* decrement, which trades directly against + /// TTL's job as the loop bound. Widening the range trades against reach. + /// Both belong in a follow-up with the mesh behaviour in scope; what this + /// constant buys is that a single observed packet is no longer conclusive. + /// + /// 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. + /// + /// Applied to public broadcasts that carry content this device authored: + /// public messages, group messages, broadcast files, board posts, live + /// voice (one draw per talk burst, see `BLEOriginTTLPolicy`), and leave. + /// Deliberately excluded: + /// + /// - **Announces.** Link binding treats `ttl == messageTTLDefault` on an + /// announce as "direct link", and an announce's sender ID already + /// identifies the device — nothing to hide, something to break. + /// - **Directed traffic** (DMs, handshakes, courier envelopes, directed + /// files). Fewer hops means fewer deliveries, and the trade needs its own + /// look rather than riding along here. + /// - **Prekey bundles and gateway carriers.** A bundle already contains its + /// owner's key, and carriers are re-broadcasts rather than authorship. + 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..327c11f5 --- /dev/null +++ b/bitchatTests/Services/RadioMetadataTests.swift @@ -0,0 +1,218 @@ +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: - Voice burst TTL + + /// Per-frame drawing would be worse than useless: at ~15 frames a second an + /// observer collects the range maximum almost immediately, so the sender is + /// marked within a fraction of a second while every low draw still costs + /// reach. One draw per burst makes a burst a single sample. + @Test func voiceFramesInOneBurstShareOneTTL() { + let start = Date(timeIntervalSince1970: 1_784_000_000) + let first = BLEOriginTTLPolicy.voiceBurstTTL( + now: start, lastFrameAt: nil, currentBurstTTL: nil, randomTTL: { _ in 6 } + ) + // Subsequent frames inside the burst must reuse it even though the + // randomizer would now return something else. + var last = start + var current = first + for step in 1...20 { + let now = start.addingTimeInterval(Double(step) * 0.066) + current = BLEOriginTTLPolicy.voiceBurstTTL( + now: now, lastFrameAt: last, currentBurstTTL: current, randomTTL: { _ in 7 } + ) + last = now + #expect(current == first) + } + } + + @Test func aNewBurstRedrawsTheTTL() { + let start = Date(timeIntervalSince1970: 1_784_000_000) + let first = BLEOriginTTLPolicy.voiceBurstTTL( + now: start, lastFrameAt: nil, currentBurstTTL: nil, randomTTL: { _ in 5 } + ) + #expect(first == 5) + + // A gap longer than the burst window means a new talk burst. + let later = start.addingTimeInterval(BLEOriginTTLPolicy.voiceBurstGap + 0.5) + let second = BLEOriginTTLPolicy.voiceBurstTTL( + now: later, lastFrameAt: start, currentBurstTTL: first, randomTTL: { _ in 7 } + ) + #expect(second == 7) + } + + @Test func voiceBurstTTLStaysInRange() { + var last: Date? + var current: UInt8? + let start = Date(timeIntervalSince1970: 1_784_000_000) + for step in 0..<200 { + // Gaps long enough to force a fresh draw each time. + let now = start.addingTimeInterval(Double(step) * 5) + let ttl = BLEOriginTTLPolicy.voiceBurstTTL( + now: now, lastFrameAt: last, currentBurstTTL: current + ) + #expect(TransportConfig.broadcastOriginTTLRange.contains(ttl)) + last = now + current = ttl + } + } + + // MARK: - Wiring + + /// The first version of this change defined the policy and used it in + /// exactly one place, leaving voice, files, group messages and board posts + /// originating at a fixed maximum — i.e. still perfectly marked, while the + /// docs claimed otherwise. A policy that exists but is not wired is worse + /// than none, because it reads as solved. + /// + /// This asserts against the source rather than behaviour because the send + /// paths need a live radio; it is a cheap guard against the specific + /// regression of adding a broadcast origination site and forgetting it. + @Test func everyAuthoredBroadcastOriginatesWithADrawnTTL() throws { + let source = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Services + .deletingLastPathComponent() // bitchatTests + .deletingLastPathComponent() // repo root + .appendingPathComponent("bitchat/Services/BLE/BLEService.swift") + let lines = try String(contentsOf: source, encoding: .utf8) + .components(separatedBy: .newlines) + + // Packet constructions that still pin the fixed maximum. + let fixed = lines.enumerated().filter { + $0.element.contains("ttl: messageTTL") || $0.element.contains("ttl: self.messageTTL") + } + + // Each remaining one must be a deliberate exclusion. Announces keep the + // fixed TTL for link binding; the rest are directed, diagnostics, or + // re-broadcasts. Identify by the packet type named just above. + let allowedTypes = [ + "announce", // link binding depends on ttl == max + "ping", "pong", // diagnostics; payload records origin TTL for hops + "noiseEncrypted", "noiseHandshake", "courierEnvelope", // directed + "fileTransfer", // the directed variant; the broadcast one is drawn + "prekeyBundle", // payload already names its owner + "nostrCarrier" // re-broadcast, not authorship + ] + + var unexplained: [String] = [] + for (index, line) in fixed { + let window = lines[max(0, index - 14)...index].joined(separator: "\n") + guard !allowedTypes.contains(where: { window.contains("MessageType.\($0)") }) else { continue } + unexplained.append("BLEService.swift:\(index + 1) — \(line.trimmingCharacters(in: .whitespaces))") + } + + #expect( + unexplained.isEmpty, + """ + These broadcast origination sites still use the fixed maximum TTL, \ + which marks this device as the author to any direct listener. Use \ + BLEOriginTTLPolicy.originTTL(), or add the type to allowedTypes here \ + with the reason. + + \(unexplained.joined(separator: "\n")) + """ + ) + } + + // 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..aae96461 100644 --- a/docs/privacy-assessment.md +++ b/docs/privacy-assessment.md @@ -26,13 +26,22 @@ 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 that carry authored content — public and group messages, broadcast files, board posts, live voice, and leave — 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 rather than mere presence. Live voice draws once per talk burst, not per frame; at roughly 15 frames a second a per-frame draw would surrender the range maximum almost immediately. + +This reduces the marker, it does not remove it. Relays strictly decrement, so the top of the range can still only come from an origin — one message in three, or about an 87% chance of self-identifying within five messages. It protects an occasional sender considerably and a chatty one little. Eliminating it needs relays to sometimes not decrement, which trades against TTL's role as the loop bound, so it is deliberately left as follow-up rather than claimed here. + +Announces keep the fixed TTL: link binding reads a maximum-TTL announce as a direct link, and an announce already identifies its sender. Directed traffic, prekey bundles, and gateway carriers are also excluded — for directed traffic because fewer hops means fewer deliveries, and for the others because the payload already identifies its owner. + +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