mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 10:05:20 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdfd4345cf | ||
|
|
32e1c99441 | ||
|
|
399cdf95ab |
@@ -0,0 +1,66 @@
|
|||||||
|
//
|
||||||
|
// BLEOriginTTLPolicy.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
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<UInt8> = TransportConfig.broadcastOriginTTLRange,
|
||||||
|
randomTTL: (ClosedRange<UInt8>) -> 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<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 bleMaxMTU = 512
|
||||||
private let maxMessageLength = InputValidator.Limits.maxMessageLength
|
private let maxMessageLength = InputValidator.Limits.maxMessageLength
|
||||||
private let messageTTL: UInt8 = TransportConfig.messageTTLDefault
|
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
|
// Flood/battery controls
|
||||||
private let maxInFlightAssemblies = TransportConfig.bleMaxInFlightAssemblies // cap concurrent fragment assemblies
|
private let maxInFlightAssemblies = TransportConfig.bleMaxInFlightAssemblies // cap concurrent fragment assemblies
|
||||||
private let highDegreeThreshold = TransportConfig.bleHighDegreeThreshold // for adaptive TTL/probabilistic relays
|
private let highDegreeThreshold = TransportConfig.bleHighDegreeThreshold // for adaptive TTL/probabilistic relays
|
||||||
@@ -869,7 +875,9 @@ final class BLEService: NSObject {
|
|||||||
timestamp: sendTimestampMs,
|
timestamp: sendTimestampMs,
|
||||||
payload: Data(content.utf8),
|
payload: Data(content.utf8),
|
||||||
signature: nil,
|
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 {
|
guard let signedPacket = noiseService.signPacket(basePacket) else {
|
||||||
SecureLogger.error("❌ Failed to sign public message", category: .security)
|
SecureLogger.error("❌ Failed to sign public message", category: .security)
|
||||||
@@ -980,7 +988,7 @@ final class BLEService: NSObject {
|
|||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: Data(),
|
payload: Data(),
|
||||||
signature: nil,
|
signature: nil,
|
||||||
ttl: messageTTL
|
ttl: BLEOriginTTLPolicy.originTTL()
|
||||||
)
|
)
|
||||||
|
|
||||||
if let signed = noiseService.signPacket(leavePacket) {
|
if let signed = noiseService.signPacket(leavePacket) {
|
||||||
@@ -1617,7 +1625,7 @@ final class BLEService: NSObject {
|
|||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
ttl: self.messageTTL,
|
ttl: BLEOriginTTLPolicy.originTTL(),
|
||||||
version: 2
|
version: 2
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2892,7 +2900,12 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
let (connectedPeerIDs, advertisedCapabilities, advertisedBridgeCell): ([Data], PeerCapabilities, String?) = collectionsQueue.sync {
|
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),
|
PeerCapabilities.localSupported.union(runtimeCapabilities),
|
||||||
runtimeCapabilities.contains(.bridge) ? localBridgeGeohash : nil
|
runtimeCapabilities.contains(.bridge) ? localBridgeGeohash : nil
|
||||||
)
|
)
|
||||||
@@ -2971,7 +2984,7 @@ final class BLEService: NSObject {
|
|||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: envelope,
|
payload: envelope,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
ttl: self.messageTTL
|
ttl: BLEOriginTTLPolicy.originTTL()
|
||||||
)
|
)
|
||||||
// Pre-mark our own broadcast as processed to avoid handling a
|
// Pre-mark our own broadcast as processed to avoid handling a
|
||||||
// relayed self copy.
|
// relayed self copy.
|
||||||
@@ -3027,6 +3040,16 @@ final class BLEService: NSObject {
|
|||||||
guard !burstContent.isEmpty else { return }
|
guard !burstContent.isEmpty else { return }
|
||||||
messageQueue.async { [weak self] in
|
messageQueue.async { [weak self] in
|
||||||
guard let self else { return }
|
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(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.voiceFrame.rawValue,
|
type: MessageType.voiceFrame.rawValue,
|
||||||
senderID: self.myPeerIDData,
|
senderID: self.myPeerIDData,
|
||||||
@@ -3034,7 +3057,7 @@ final class BLEService: NSObject {
|
|||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: burstContent,
|
payload: burstContent,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
ttl: self.messageTTL
|
ttl: burstTTL
|
||||||
)
|
)
|
||||||
guard let signedPacket = self.noiseService.signPacket(packet) else {
|
guard let signedPacket = self.noiseService.signPacket(packet) else {
|
||||||
SecureLogger.error("❌ Failed to sign voice frame", category: .security)
|
SecureLogger.error("❌ Failed to sign voice frame", category: .security)
|
||||||
@@ -7609,7 +7632,7 @@ extension BLEService {
|
|||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
ttl: self.messageTTL
|
ttl: BLEOriginTTLPolicy.originTTL()
|
||||||
)
|
)
|
||||||
guard let signedPacket = self.noiseService.signPacket(basePacket) else {
|
guard let signedPacket = self.noiseService.signPacket(basePacket) else {
|
||||||
SecureLogger.error("❌ Failed to sign board packet", category: .security)
|
SecureLogger.error("❌ Failed to sign board packet", category: .security)
|
||||||
|
|||||||
@@ -6,6 +6,73 @@ enum TransportConfig {
|
|||||||
// BLE / Protocol
|
// BLE / Protocol
|
||||||
static let bleDefaultFragmentSize: Int = 469 // ~512 MTU minus protocol overhead
|
static let bleDefaultFragmentSize: Int = 469 // ~512 MTU minus protocol overhead
|
||||||
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
|
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<UInt8> = 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 bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
||||||
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
||||||
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
|
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
|
||||||
|
|||||||
@@ -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<UInt8>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,13 +26,22 @@ Signed announces can expose:
|
|||||||
|
|
||||||
- Nickname, persistent Noise public key, and Ed25519 signing public key
|
- Nickname, persistent Noise public key, and Ed25519 signing public key
|
||||||
- Capability flags
|
- Capability flags
|
||||||
- A bounded set of short direct-neighbor identifiers
|
|
||||||
- A coarse rendezvous geohash when the bridge capability is enabled
|
- 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.
|
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.
|
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.
|
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
|
## Private Messaging and Courier Delivery
|
||||||
|
|||||||
Reference in New Issue
Block a user