mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 05:45:19 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdfd4345cf | ||
|
|
32e1c99441 | ||
|
|
14e7b428d9 | ||
|
|
c671e3df66 | ||
|
|
399cdf95ab | ||
|
|
132120a88e |
@@ -63,7 +63,13 @@ jobs:
|
||||
echo "#"
|
||||
echo "# Verify a checkout of this ref with:"
|
||||
echo "# shasum -a 256 -c files.sha256"
|
||||
echo "# The git tree hash above is the single value that covers all of it:"
|
||||
echo "# Hash checking alone ignores files this manifest does not list, and"
|
||||
echo "# the Xcode project compiles any source file present in the tree. So"
|
||||
echo "# also confirm nothing extra is present:"
|
||||
echo "# git status --porcelain --ignored # git checkout: must print nothing"
|
||||
echo "# or, for a tarball, diff this manifest's path list against find(1)."
|
||||
echo "# Full instructions: docs/VERIFYING-A-BUILD.md"
|
||||
echo "# The git tree hash above is the single value covering all tracked content:"
|
||||
echo "# git rev-parse HEAD^{tree}"
|
||||
echo "#"
|
||||
} > SOURCE-MANIFEST.txt
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Security policy
|
||||
|
||||
bitchat is a security-focused messenger, and reports about its security are taken seriously. This page says how to report, what counts as a vulnerability here, and what to expect.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Use GitHub's private vulnerability reporting:** [Report a vulnerability](https://github.com/permissionlesstech/bitchat/security/advisories/new) (Security tab → "Report a vulnerability").
|
||||
|
||||
Please do not open a public issue for anything that could put people at risk before a fix ships. bitchat is used by people in hostile network environments; a public proof-of-concept can be acted on faster than a patch can reach them.
|
||||
|
||||
A useful report says what an attacker can do, against which build (App Store version or commit hash), and how to reproduce it. A failing test or a packet capture is worth more than speculation about impact.
|
||||
|
||||
## What to expect
|
||||
|
||||
This is a volunteer-maintained project. The aim is to acknowledge reports within a week and to move on confirmed vulnerabilities immediately — historically, confirmed protocol and key-handling issues have been fixed within days. You'll be kept in the loop in the advisory thread, and credited in the fix unless you'd rather not be. There is no bug bounty.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Fixes ship to the latest App Store release and `main`. Older releases are not patched; the fix is to update.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope — the properties the app promises:
|
||||
|
||||
- Confidentiality and integrity of private messages and media (Noise sessions over BLE; over Nostr, bitchat's own ephemeral private-envelope format — a proprietary scheme, *not* NIP-17/NIP-44/NIP-59, see `WHITEPAPER.md`)
|
||||
- Identity: key handling, verification, impersonation, session binding
|
||||
- The panic wipe actually destroying what it claims to destroy
|
||||
- Metadata exposure beyond what the documentation already discloses (see `PRIVACY_POLICY.md` and `docs/privacy-assessment.md`)
|
||||
- Downgrade paths: anything that silently moves traffic from an encrypted path to a plaintext one
|
||||
- Tor routing: anything that makes traffic bypass Tor while the Tor preference is on
|
||||
- Supply-chain integrity of the source and its vendored binaries (see `docs/VERIFYING-A-BUILD.md`)
|
||||
|
||||
Out of scope — documented design properties, not vulnerabilities:
|
||||
|
||||
- Public visibility of mesh announces and geohash channels: broadcast content, nicknames, and public keys are public by design
|
||||
- Bluetooth proximity being observable: anyone in radio range can tell a BLE device is present
|
||||
- Mesh flooding/relay behavior inherent to a broadcast mesh (rate limits exist; the topology is what it is)
|
||||
- Behavior of third-party Nostr relays
|
||||
- Denial of service requiring physical proximity, and battery-drain attacks in general
|
||||
|
||||
If you're unsure whether something is in scope, report it privately anyway — a false alarm costs a few minutes; a real issue reported publicly can cost much more.
|
||||
|
||||
## Verifying what you're running
|
||||
|
||||
If your concern is that the app or source you have has been tampered with, that has its own document: `docs/VERIFYING-A-BUILD.md`.
|
||||
@@ -848,6 +848,9 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
messageQueueLock.unlock()
|
||||
// A relay queued while Tor bootstraps would otherwise reconnect when
|
||||
// the queue drains, overriding the explicit removal.
|
||||
pendingTorConnectionURLs.subtract(urls)
|
||||
relays.removeAll { urls.contains($0.url) }
|
||||
updateConnectionStatus()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -64,6 +64,10 @@ extension ChatViewModel {
|
||||
@objc func handleTorBootstrapDidStall() {
|
||||
Task { @MainActor in
|
||||
guard TorManager.shared.torEnforced else { return }
|
||||
// torEnforced is a compile-time constant in release builds; the
|
||||
// runtime preference is what says whether anyone is waiting on
|
||||
// Tor. Turning Tor off mid-bootstrap must not read as blocking.
|
||||
guard NetworkActivationService.persistedTorPreference() else { return }
|
||||
guard !self.torStallAnnounced else { return }
|
||||
self.torStallAnnounced = true
|
||||
self.addGeohashOnlySystemMessage(
|
||||
|
||||
@@ -73,7 +73,14 @@ struct ContentComposerView: View {
|
||||
.textInputAutocapitalization(.sentences)
|
||||
#endif
|
||||
.submitLabel(.send)
|
||||
.onSubmit(onSendMessage)
|
||||
.onSubmit {
|
||||
onSendMessage()
|
||||
// Only the return-key path: it steals focus on iOS, so
|
||||
// every message would cost a tap to reopen the keyboard.
|
||||
// The send button must not reopen a deliberately
|
||||
// dismissed keyboard, so it stays out of this.
|
||||
isTextFieldFocused.wrappedValue = true
|
||||
}
|
||||
.padding(.vertical, theme.usesGlassChrome ? 8 : 4)
|
||||
.padding(.horizontal, 6)
|
||||
.themedInputBackground()
|
||||
|
||||
@@ -1374,3 +1374,37 @@ private func makeImageData() throws -> Data {
|
||||
return data
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Tor Extension Tests
|
||||
|
||||
struct ChatViewModelTorExtensionTests {
|
||||
|
||||
/// Turning Tor off mid-bootstrap must not read as "the network is
|
||||
/// blocking tor": `torEnforced` is a compile-time constant, so the stall
|
||||
/// handler has to consult the runtime preference before announcing.
|
||||
@Test @MainActor
|
||||
func bootstrapStall_withTorPreferenceOff_announcesNothing() async {
|
||||
let key = NetworkActivationService.torPreferenceKey
|
||||
let previous = UserDefaults.standard.object(forKey: key)
|
||||
defer {
|
||||
if let previous {
|
||||
UserDefaults.standard.set(previous, forKey: key)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
UserDefaults.standard.set(false, forKey: key)
|
||||
viewModel.handleTorBootstrapDidStall()
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
#expect(viewModel.torStallAnnounced == false)
|
||||
|
||||
// The same stall with the preference on (the persisted default) is
|
||||
// exactly what must still be announced.
|
||||
UserDefaults.standard.set(true, forKey: key)
|
||||
viewModel.handleTorBootstrapDidStall()
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
#expect(viewModel.torStallAnnounced == true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,46 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.cancelCallCount >= 1 })
|
||||
}
|
||||
|
||||
/// A relay removed while its connection is queued behind Tor bootstrap
|
||||
/// must stay removed: draining the pending set used to resurrect it,
|
||||
/// because `dropRelays` never touched `pendingTorConnectionURLs` and a
|
||||
/// custom relay is in neither the default set nor the allow-list filter.
|
||||
func test_relayRemovedWhileWaitingForTor_staysRemovedWhenTorBecomesReady() async {
|
||||
let customURL = "wss://custom-removed.example"
|
||||
let center = NotificationCenter()
|
||||
let customRelays = MutableRelayList(urls: [customURL])
|
||||
let context = makeContext(
|
||||
permission: .authorized,
|
||||
userTorEnabled: true,
|
||||
torEnforced: true,
|
||||
torIsReady: false,
|
||||
notificationCenter: center,
|
||||
customRelays: customRelays
|
||||
)
|
||||
|
||||
// Defaults plus the custom relay all queue while Tor bootstraps.
|
||||
context.manager.connect()
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
|
||||
|
||||
// The relay is removed by hand before Tor is ready.
|
||||
customRelays.urls = []
|
||||
center.post(name: NostrRelaySettings.didChangeNotification, object: nil)
|
||||
// The settings sink hops through the main queue; let it land.
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
context.torWaiter.resolve(true)
|
||||
|
||||
let defaultsConnected = await waitUntil {
|
||||
context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount
|
||||
}
|
||||
XCTAssertTrue(defaultsConnected)
|
||||
XCTAssertFalse(
|
||||
context.sessionFactory.requestedURLs.contains(customURL),
|
||||
"a relay removed while Tor was bootstrapping must not reconnect when the pending queue drains"
|
||||
)
|
||||
}
|
||||
|
||||
func test_connect_waitsForTorReadinessBeforeCreatingSessions() async {
|
||||
let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -18,26 +18,43 @@ In order of how much verification is possible:
|
||||
|
||||
Every tagged release has a `SOURCE-MANIFEST.txt` produced by `.github/workflows/source-manifest.yml`. It records the tag, the commit, the git tree hash, and a SHA-256 for every tracked file.
|
||||
|
||||
Check a copy of the source against it:
|
||||
Keep the downloaded manifest *outside* the source tree (say, `/tmp`) — a stray copy inside the checkout would itself trip the completeness checks below. Then check a copy of the source against it:
|
||||
|
||||
```sh
|
||||
# From the root of the source you obtained
|
||||
grep -v '^#' SOURCE-MANIFEST.txt > /tmp/files.sha256
|
||||
# From the root of the source you obtained, with the manifest at /tmp
|
||||
grep -v '^#' /tmp/SOURCE-MANIFEST.txt > /tmp/files.sha256
|
||||
shasum -a 256 -c /tmp/files.sha256
|
||||
```
|
||||
|
||||
Any `FAILED` line means that file differs from the released source. Investigate before building.
|
||||
|
||||
That check alone is not enough. `shasum -c` verifies the files the manifest lists and says nothing about files it does not list — and the Xcode project compiles every source file present in the tree automatically, so a hostile mirror can pass the hash check by leaving every listed file intact and *adding* one. Confirm nothing extra is present:
|
||||
|
||||
```sh
|
||||
# The manifest's path list must match the tree exactly — no missing files, no extras
|
||||
grep -v '^#' /tmp/SOURCE-MANIFEST.txt | sed 's/^[0-9a-f]* //' | LC_ALL=C sort > /tmp/manifest-paths
|
||||
find . -type f ! -path './.git/*' | sed 's|^\./||' | LC_ALL=C sort > /tmp/actual-paths
|
||||
diff /tmp/manifest-paths /tmp/actual-paths # must print nothing
|
||||
```
|
||||
|
||||
In a git checkout the same assurance is one command — it also catches extra files, because they show as untracked. `--ignored` matters: `.gitignore` covers paths like `build/`, plain `git status` would not report a planted file there, and Xcode compiles it all the same:
|
||||
|
||||
```sh
|
||||
git status --porcelain --ignored # must print nothing before you build
|
||||
```
|
||||
|
||||
The single value that covers the whole tree is the git tree hash in the manifest header:
|
||||
|
||||
```sh
|
||||
git rev-parse HEAD^{tree} # must equal the "tree:" line in the manifest
|
||||
```
|
||||
|
||||
Note the tree hash covers tracked content only; it does not see untracked files sitting in the working directory, which is why the emptiness checks above come first.
|
||||
|
||||
The manifest itself carries a provenance attestation tying it to the workflow run that produced it, so a manifest handed to you along with a mirror is checkable too:
|
||||
|
||||
```sh
|
||||
gh attestation verify SOURCE-MANIFEST.txt --repo permissionlesstech/bitchat
|
||||
gh attestation verify /tmp/SOURCE-MANIFEST.txt --repo permissionlesstech/bitchat
|
||||
```
|
||||
|
||||
That last step is what makes this resistant to a hostile mirror. Without it, whoever gives you the source can give you a matching manifest.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -84,6 +84,10 @@ public final class TorManager: ObservableObject {
|
||||
private var shutdownsInFlight = 0
|
||||
private var startPendingAfterShutdown = false
|
||||
private var bootstrapMonitorStarted = false
|
||||
// Fences the detached poll loop: shutdown, dormancy, and restart each bump
|
||||
// this, so a loop from a previous attempt cannot run out its deadline and
|
||||
// report a stall over state that a newer lifecycle event already owns.
|
||||
private var bootstrapGeneration = 0
|
||||
private var pathMonitor: NWPathMonitor?
|
||||
private var isAppForeground: Bool = true
|
||||
private var lastRestartAt: Date? = nil
|
||||
@@ -268,24 +272,25 @@ public final class TorManager: ObservableObject {
|
||||
private func startBootstrapMonitor() {
|
||||
guard !bootstrapMonitorStarted else { return }
|
||||
bootstrapMonitorStarted = true
|
||||
bootstrapGeneration += 1
|
||||
let generation = bootstrapGeneration
|
||||
Task.detached(priority: .utility) { [weak self] in
|
||||
await self?.bootstrapPollLoop()
|
||||
await self?.bootstrapPollLoop(generation: generation)
|
||||
}
|
||||
}
|
||||
|
||||
private func bootstrapPollLoop() async {
|
||||
private func bootstrapPollLoop(generation: Int) async {
|
||||
let deadline = Date().addingTimeInterval(75)
|
||||
var didComplete = false
|
||||
while Date() < deadline {
|
||||
guard generation == bootstrapGeneration else { return }
|
||||
let progress = Int(arti_bootstrap_progress())
|
||||
let summary = getBootstrapSummary()
|
||||
|
||||
await MainActor.run {
|
||||
self.bootstrapProgress = progress
|
||||
self.bootstrapSummary = summary
|
||||
if progress >= 100 { self.isStarting = false }
|
||||
self.recomputeReady()
|
||||
}
|
||||
self.bootstrapProgress = progress
|
||||
self.bootstrapSummary = summary
|
||||
if progress >= 100 { self.isStarting = false }
|
||||
self.recomputeReady()
|
||||
|
||||
if progress >= 100 {
|
||||
didComplete = true
|
||||
@@ -296,17 +301,17 @@ public final class TorManager: ObservableObject {
|
||||
|
||||
// Running out the deadline is a reportable outcome, not silence. The
|
||||
// loop previously just ended, leaving `isStarting` true forever, so a
|
||||
// blocked network was indistinguishable from a slow one.
|
||||
// blocked network was indistinguishable from a slow one. A deliberate
|
||||
// shutdown mid-bootstrap is not a stall, hence the generation check.
|
||||
if !didComplete {
|
||||
await MainActor.run {
|
||||
self.isStarting = false
|
||||
self.bootstrapDidStall = true
|
||||
SecureLogger.warning(
|
||||
"TorManager: bootstrap did not complete within its deadline (progress=\(self.bootstrapProgress)); network may be blocking Tor",
|
||||
category: .session
|
||||
)
|
||||
NotificationCenter.default.post(name: .TorBootstrapDidStall, object: nil)
|
||||
}
|
||||
guard generation == bootstrapGeneration else { return }
|
||||
self.isStarting = false
|
||||
self.bootstrapDidStall = true
|
||||
SecureLogger.warning(
|
||||
"TorManager: bootstrap did not complete within its deadline (progress=\(self.bootstrapProgress)); network may be blocking Tor",
|
||||
category: .session
|
||||
)
|
||||
NotificationCenter.default.post(name: .TorBootstrapDidStall, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,6 +357,7 @@ public final class TorManager: ObservableObject {
|
||||
// Clear isStarting so foreground recovery can proceed if bootstrap was interrupted.
|
||||
SecureLogger.debug("TorManager: goDormantOnBackground() called", category: .session)
|
||||
Task { @MainActor in
|
||||
self.bootstrapGeneration += 1
|
||||
self.isReady = false
|
||||
self.socksReady = false
|
||||
self.isStarting = false
|
||||
@@ -361,6 +367,7 @@ public final class TorManager: ObservableObject {
|
||||
public func shutdownCompletely() {
|
||||
SecureLogger.debug("TorManager: shutdownCompletely() called", category: .session)
|
||||
startPendingAfterShutdown = false
|
||||
bootstrapGeneration += 1
|
||||
shutdownsInFlight += 1
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -398,6 +405,7 @@ public final class TorManager: ObservableObject {
|
||||
SecureLogger.debug("TorManager: restartArti() starting", category: .session)
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .TorWillRestart, object: nil)
|
||||
self.bootstrapGeneration += 1
|
||||
self.isReady = false
|
||||
self.socksReady = false
|
||||
self.bootstrapProgress = 0
|
||||
|
||||
Reference in New Issue
Block a user