mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 12:45:20 +00:00
Apply the origin-TTL draw everywhere, and stop overclaiming it
Review was right on both counts, and the second one matters more than the first. **It was wired into exactly one send path.** Public text drew a TTL; voice, broadcast files, group messages, board posts and leave all still originated at the fixed maximum — so those were still perfectly marked as authored-here, while the privacy assessment said broadcasts were randomized. A policy that exists but is not applied is worse than none, because it reads as solved. All six authored-broadcast paths now draw. Live voice draws **once per talk burst**, not per frame. At ~15 frames a second a per-frame draw hands an observer the range maximum almost immediately, so it would have cost reach and bought nothing. A burst is now one sample, the same as a text message. Deliberately still fixed, each for a reason now written down: announces (link binding reads ttl == max as "direct link", and an announce already names its sender), directed traffic (fewer hops means fewer deliveries — a real trade that deserves its own change), prekey bundles and gateway carriers (the payload already identifies its owner; a carrier is a re-broadcast, not authorship). **The docs claimed more than the mechanism delivers.** Relays strictly decrement — every branch of RelayController emits ttlLimit - 1 — so the top of the range can still only come from an origin. With three values that is one message in three, and 1 - (2/3)^k, so roughly 87% of senders are self-identified within five messages. It meaningfully protects an occasional sender and barely protects a chatty one. Removing the marker outright needs relays to sometimes not decrement, which trades against TTL's job as the loop bound, so it is named as follow-up rather than implied to be done. TransportConfig and the privacy assessment now say this instead of implying the marker is gone. Added a wiring guard that reads BLEService and fails if an authored broadcast origination site uses the fixed maximum without being on an explicit exclusion list with a reason. Verified it fails: injecting the old fixed TTL back into the group-message path was caught with file and line, and it went green again on revert. That is the specific regression this had, so it is the specific regression now covered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<UInt8> = TransportConfig.broadcastOriginTTLRange,
|
||||
randomTTL: (ClosedRange<UInt8>) -> UInt8 = { UInt8.random(in: $0) }
|
||||
) -> UInt8 {
|
||||
if let currentBurstTTL,
|
||||
let lastFrameAt,
|
||||
now.timeIntervalSince(lastFrameAt) < burstGap {
|
||||
return currentBurstTTL
|
||||
}
|
||||
return originTTL(range: range, randomTTL: randomTTL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<UInt8> = 5...7
|
||||
|
||||
/// Whether signed announces advertise this device's direct neighbours.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user