mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 04:25: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 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
|
||||
@@ -869,7 +875,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)
|
||||
@@ -980,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) {
|
||||
@@ -1617,7 +1625,7 @@ final class BLEService: NSObject {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: self.messageTTL,
|
||||
ttl: BLEOriginTTLPolicy.originTTL(),
|
||||
version: 2
|
||||
)
|
||||
|
||||
@@ -2892,7 +2900,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
|
||||
)
|
||||
@@ -2971,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.
|
||||
@@ -3027,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,
|
||||
@@ -3034,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)
|
||||
@@ -7609,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)
|
||||
|
||||
@@ -6,6 +6,73 @@ 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 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 bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
||||
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
|
||||
- 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 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.
|
||||
|
||||
## Private Messaging and Courier Delivery
|
||||
|
||||
Reference in New Issue
Block a user