diff --git a/bitchat/Services/BLE/BLEOriginTTLPolicy.swift b/bitchat/Services/BLE/BLEOriginTTLPolicy.swift index d32b171e..a1d8baf2 100644 --- a/bitchat/Services/BLE/BLEOriginTTLPolicy.swift +++ b/bitchat/Services/BLE/BLEOriginTTLPolicy.swift @@ -34,4 +34,33 @@ enum BLEOriginTTLPolicy { } 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 58b79ec6..814b21ca 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -227,6 +227,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 @@ -982,7 +988,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) { @@ -1619,7 +1625,7 @@ final class BLEService: NSObject { timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, signature: nil, - ttl: self.messageTTL, + ttl: BLEOriginTTLPolicy.originTTL(), version: 2 ) @@ -2978,7 +2984,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. @@ -3034,6 +3040,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, @@ -3041,7 +3057,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) @@ -7616,7 +7632,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 a4f793c2..560fca51 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -13,9 +13,23 @@ enum TransportConfig { /// 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. + /// 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 @@ -23,10 +37,19 @@ enum TransportConfig { /// 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. + /// 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. diff --git a/bitchatTests/Services/RadioMetadataTests.swift b/bitchatTests/Services/RadioMetadataTests.swift index 52eaee43..327c11f5 100644 --- a/bitchatTests/Services/RadioMetadataTests.swift +++ b/bitchatTests/Services/RadioMetadataTests.swift @@ -49,6 +49,119 @@ struct RadioMetadataTests { #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() { diff --git a/docs/privacy-assessment.md b/docs/privacy-assessment.md index 38bfe217..aae96461 100644 --- a/docs/privacy-assessment.md +++ b/docs/privacy-assessment.md @@ -34,7 +34,11 @@ The app does not advertise the device's assigned name. iOS manages BLE address r 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. +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.