Compare commits

..
Author SHA1 Message Date
jackandClaude Opus 5 399cdf95ab Stop broadcasting the adjacency graph and the authorship marker
Two radio-layer metadata leaks that need no cross-platform agreement,
because both only change what this device chooses to emit.

**Announces no longer carry the neighbour list.** The TLV held up to ten
8-byte peer IDs, so a *single* passive receiver could reconstruct the local
adjacency graph — who is standing next to whom — with no need for several
receivers or RSSI trilateration. In a crowd that is the most sensitive
thing the radio layer gives away, and unlike the identity keys it is not
required for the protocol to work.

Backward compatible in both directions: an empty list omits the TLV
entirely rather than emitting a zero-length one, the decoder already treats
its absence as "no topology offered", and lists from other peers are still
parsed so a mixed network behaves sensibly.

The cost is source routing. MeshTopologyTracker builds its adjacency map
from these lists, so with everyone silent there are no routes to compute
and directed traffic floods instead — which is already the documented
fallback whenever a route fails. More airtime for directed sends in dense
meshes; no correctness change. Left as a TransportConfig constant rather
than a user setting because it is a protocol trade-off, not a preference,
and flipping it back is one line.

**Public broadcasts no longer always originate at the maximum TTL.**
`ttl == messageTTLDefault` was a reliable "this device wrote it" marker to
any direct listener, which discloses authorship rather than mere presence.
Origin TTL is now drawn from 5...7: in a dense graph relays already 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.

Signature-safe and needs no agreement: TTL is excluded from the signed
bytes (toBinaryDataForSigning zeroes it so relays can decrement), so a peer
on any version just sees a smaller starting TTL and relays it normally. The
floor is not below the dense-graph clamp, since lower would cost reach
without buying ambiguity that clamp does not already provide.

Announces deliberately keep the fixed TTL: three link-binding paths read a
maximum-TTL announce as "direct link", and an announce's sender ID already
identifies the device, so there is nothing to hide and something to break.

**Not done here: padding.** Extending padding beyond Noise frames, and
fixing the gap where a frame needing over 255 bytes of padding is emitted
unpadded, both looked unilateral but are not. `toBinaryDataForSigning`
encodes with padding enabled, so the padding bytes are inside the signed
material for every signed packet — changing the algorithm changes the
signed byte stream and breaks signature verification against any peer that
has not changed it identically. That makes it a coordinated wire change;
recorded in the privacy assessment and in #1487's open questions rather
than attempted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:03:35 +02:00
9 changed files with 222 additions and 86 deletions
@@ -0,0 +1,37 @@
//
// 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)
}
}
+9 -2
View File
@@ -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
)
+44
View File
@@ -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<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 bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
@@ -580,16 +580,8 @@ final class GeoRelayDirectoryTests: XCTestCase {
/// constrained CI runners (2-core, serialized testing) can starve the
/// detached utility-priority fetch task for seconds before it runs, and
/// a successful wait returns as soon as the condition becomes true.
/// Default deliberately far larger than the work being awaited.
///
/// The directory performs its fetch in a `Task.detached(priority: .utility)`,
/// and utility priority competes with every other suite on a CI runner. At
/// ten seconds the retry-scheduling test timed out at exactly 10.06s with
/// the retry never scheduled which reads like a missing retry rather than
/// a starved background task. Returning as soon as the condition holds means
/// a longer deadline only extends the genuine-failure case.
private func waitUntil(
timeout: TimeInterval = 30.0,
timeout: TimeInterval = 10.0,
condition: @escaping @MainActor () async -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
@@ -69,45 +69,25 @@ final class NetworkReachabilityGateTests: XCTestCase {
XCTAssertNil(d.pendingRemaining(at: t0.addingTimeInterval(2.5)))
}
/// Wiring only: a duplicate mid-window still yields exactly one committed
/// `false`, published through the monitor's debounce.
///
/// This deliberately makes no assertion about *when* the flush fires. It
/// used to bound elapsed wall-clock time at 1.4 s to prove the deadline was
/// not restarted, which flaked on loaded CI runners one observed run took
/// 3.75 s, because `Task.sleep` and the `asyncAfter` flush are both real
/// time and neither is bounded above on a busy machine. No wall-clock bound
/// can distinguish "deadline preserved" from "runner is slow", so the timing
/// property is asserted where it is computable instead:
/// `test_debounce_duplicateObservationsPreservePendingDeadline` drives
/// `ReachabilityDebounce` with injected timestamps and checks
/// `pendingRemaining` directly.
///
/// The clock is injected here so the debounce arithmetic is deterministic
/// even though the flush itself is scheduled in real time.
func test_monitor_duplicateUpdatesCommitOnceThroughTheDebounce() async {
let clock = MutableDate(now: Date(timeIntervalSince1970: 1_784_000_000))
let monitor = NWPathReachabilityMonitor(
debounceInterval: 0.2,
now: { clock.now }
)
func test_monitor_duplicateUpdatesDoNotPostponeOfflineCommit() async {
let monitor = NWPathReachabilityMonitor(debounceInterval: 1.0)
var received: [Bool] = []
let cancellable = monitor.reachabilityPublisher.sink { received.append($0) }
defer { cancellable.cancel() }
let start = Date()
monitor.ingest(reachable: false)
// Duplicate unsatisfied update mid-window (e.g. an interface detail
// change while still offline).
clock.now = clock.now.addingTimeInterval(0.1)
try? await Task.sleep(nanoseconds: 500_000_000)
// Duplicate unsatisfied update mid-window (e.g. interface detail change
// while still offline) must not restart the debounce window.
monitor.ingest(reachable: false)
// Past the original deadline, so the scheduled flush commits.
clock.now = clock.now.addingTimeInterval(0.2)
// Generous: this is a liveness check, not a latency bound. A real
// regression never committing still fails, just later.
let committed = await waitUntil(timeout: 10.0) { !received.isEmpty }
let committed = await waitUntil(timeout: 2.0) { !received.isEmpty }
XCTAssertTrue(committed)
XCTAssertEqual(received, [false])
// The flush must fire at the original ~1.0s deadline, not ~1.5s
// (a full interval after the duplicate).
XCTAssertLessThan(Date().timeIntervalSince(start), 1.4)
}
// MARK: - Service gating
@@ -259,13 +239,3 @@ private final class GateMockProxyController: NetworkActivationProxyControlling {
private(set) var proxyModes: [Bool] = []
func setProxyMode(useTor: Bool) { proxyModes.append(useTor) }
}
/// Controllable clock, so debounce arithmetic is deterministic even where the
/// flush itself is scheduled in real time.
private final class MutableDate: @unchecked Sendable {
var now: Date
init(now: Date) {
self.now = now
}
}
@@ -652,12 +652,12 @@ struct NoiseEncryptionServiceTests {
)
let retried = await TestHelpers.waitUntil(
{ recorder.messages.count == 1 },
timeout: TestConstants.longTimeout
timeout: 1
)
#expect(retried)
let retryExpired = await TestHelpers.waitUntil(
{ !service.hasSession(with: peerID) },
timeout: TestConstants.longTimeout
timeout: 1
)
#expect(retryExpired)
#expect(recorder.timeoutCount == 1)
@@ -710,12 +710,7 @@ struct NoiseEncryptionServiceTests {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(
keychain: MockKeychain(),
// Generous for the same reason as the quarantine-restore test
// (#1483): this timeout also arms during the `establishSessions`
// setup handshake below, where bob is the responder. At 0.06 a
// preempted runner could fire it mid-setup, tear down the half-open
// responder, and make message 3 be answered as a fresh initiation.
ordinaryResponderHandshakeTimeout: 1.0,
ordinaryResponderHandshakeTimeout: 0.06,
ordinaryReconnectRollbackCooldown: 0.3
)
let mallory = NoiseEncryptionService(keychain: MockKeychain())
@@ -746,12 +741,12 @@ struct NoiseEncryptionServiceTests {
let restored = await TestHelpers.waitUntil(
{ bob.hasEstablishedSession(with: alicePeerID) },
timeout: TestConstants.longTimeout
timeout: 1
)
#expect(restored)
let callbackArrived = await TestHelpers.waitUntil(
{ recovery.timeoutCount == 1 },
timeout: TestConstants.longTimeout
timeout: 1
)
#expect(callbackArrived)
@@ -785,13 +780,7 @@ struct NoiseEncryptionServiceTests {
let bob = NoiseEncryptionService(
keychain: MockKeychain(),
ordinaryHandshakeTimeout: 0.04,
// Also arms during the `establishSessions` setup handshake below,
// where bob is the responder. Observed failing on a loaded CI
// runner with exactly the signature #1483 documented: the setup's
// `#expect(finalMessage == nil)` saw a 96-byte message 2, because
// the half-open responder had already been torn down and message 3
// was answered as a fresh initiation.
ordinaryResponderHandshakeTimeout: 1.0
ordinaryResponderHandshakeTimeout: 0.04
)
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
@@ -825,12 +814,12 @@ struct NoiseEncryptionServiceTests {
// initiates one bounded convergence retry; drop that message 1 too.
let retryPrepared = await TestHelpers.waitUntil(
{ recovery.messages.count == 1 },
timeout: TestConstants.longTimeout
timeout: 1
)
#expect(retryPrepared)
let retryExpired = await TestHelpers.waitUntil(
{ !bob.hasSession(with: alicePeerID) },
timeout: TestConstants.longTimeout
timeout: 1
)
#expect(retryExpired)
#expect(recovery.timeoutCount == 1)
@@ -1037,7 +1026,7 @@ struct NoiseEncryptionServiceTests {
let requested = await TestHelpers.waitUntil(
{ recovery.messages.count == 1 },
timeout: TestConstants.longTimeout
timeout: 1
)
#expect(requested)
let retryMessage1 = try #require(recovery.messages.first)
@@ -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<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: - 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)
}
}
@@ -59,24 +59,11 @@ struct VoiceNotePlaybackControllerTests {
return url
}
/// Waits for an async settle, then asserts.
///
/// The deadline is deliberately far larger than the work it waits on. Every
/// condition here depends on a `@MainActor` Task that playback schedules
/// (the session acquire and its failure path), and on a CI runner executing
/// many suites in parallel that Task can simply not be scheduled for
/// seconds. At five seconds this timed out on CI and reported *two*
/// failures the wait itself, and the `!isPlaying` that the un-run failure
/// path had not yet reset which reads like a playback bug rather than a
/// starved scheduler.
///
/// A generous deadline costs nothing when the condition holds, since this
/// returns as soon as it does; it only extends the genuine-failure case.
private func waitUntil(
_ condition: () -> Bool,
sourceLocation: SourceLocation = #_sourceLocation
) async {
let deadline = ContinuousClock.now.advanced(by: .seconds(30))
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while !condition(), ContinuousClock.now < deadline {
await Task.yield()
try? await Task.sleep(nanoseconds: 1_000_000)
+6 -1
View File
@@ -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