Files
bitchat/bitchatTests/Services/RadioMetadataTests.swift
T
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

106 lines
4.3 KiB
Swift

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)
}
}