mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:05:20 +00:00
[codex] Extract BLE service policy helpers (#1321)
* Extract BLE service policy helpers * Stabilize image media transfer test --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLEIngressPacketGuard {
|
||||||
|
enum Rejection: Error, Equatable {
|
||||||
|
case selfLoopback(packetType: UInt8)
|
||||||
|
case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID)
|
||||||
|
case invalidRSR(peerID: PeerID)
|
||||||
|
case timestampSkew(peerID: PeerID, skewMs: UInt64, maxSkewMs: UInt64)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func evaluate(
|
||||||
|
packet: BitchatPacket,
|
||||||
|
claimedSenderID: PeerID,
|
||||||
|
boundPeerID: PeerID?,
|
||||||
|
localPeerID: PeerID,
|
||||||
|
directAnnounceTTL: UInt8,
|
||||||
|
nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
maxTimestampSkewMs: UInt64 = 120_000,
|
||||||
|
isValidSyncResponse: (PeerID) -> Bool
|
||||||
|
) -> Result<BLEIngressPacketContext, Rejection> {
|
||||||
|
let contextResult = BLEIngressLinkRegistry.packetContext(
|
||||||
|
for: packet,
|
||||||
|
claimedSenderID: claimedSenderID,
|
||||||
|
boundPeerID: boundPeerID,
|
||||||
|
localPeerID: localPeerID,
|
||||||
|
directAnnounceTTL: directAnnounceTTL
|
||||||
|
)
|
||||||
|
|
||||||
|
let context: BLEIngressPacketContext
|
||||||
|
switch contextResult {
|
||||||
|
case .success(let acceptedContext):
|
||||||
|
context = acceptedContext
|
||||||
|
case .failure(.selfLoopback(let packetType)):
|
||||||
|
return .failure(.selfLoopback(packetType: packetType))
|
||||||
|
case .failure(.directSenderMismatch(let boundPeerID, let claimedSenderID)):
|
||||||
|
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch validatePayload(
|
||||||
|
packet,
|
||||||
|
from: context.validationPeerID,
|
||||||
|
nowMs: nowMs,
|
||||||
|
maxTimestampSkewMs: maxTimestampSkewMs,
|
||||||
|
isValidSyncResponse: isValidSyncResponse
|
||||||
|
) {
|
||||||
|
case .success:
|
||||||
|
return .success(context)
|
||||||
|
case .failure(let rejection):
|
||||||
|
return .failure(rejection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func validatePayload(
|
||||||
|
_ packet: BitchatPacket,
|
||||||
|
from peerID: PeerID,
|
||||||
|
nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
maxTimestampSkewMs: UInt64 = 120_000,
|
||||||
|
isValidSyncResponse: (PeerID) -> Bool
|
||||||
|
) -> Result<Void, Rejection> {
|
||||||
|
if packet.isRSR {
|
||||||
|
guard isValidSyncResponse(peerID) else {
|
||||||
|
return .failure(.invalidRSR(peerID: peerID))
|
||||||
|
}
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
|
||||||
|
let packetTime = packet.timestamp
|
||||||
|
let skew = packetTime > nowMs ? packetTime - nowMs : nowMs - packetTime
|
||||||
|
guard skew <= maxTimestampSkewMs else {
|
||||||
|
return .failure(.timestampSkew(peerID: peerID, skewMs: skew, maxSkewMs: maxTimestampSkewMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class BLELogRateLimiter {
|
||||||
|
private let defaultMinimumInterval: TimeInterval
|
||||||
|
private let queue = DispatchQueue(label: "chat.bitchat.ble.log-rate-limiter")
|
||||||
|
private var lastLogTimeByKey: [String: Date] = [:]
|
||||||
|
|
||||||
|
init(defaultMinimumInterval: TimeInterval) {
|
||||||
|
self.defaultMinimumInterval = defaultMinimumInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldLog(
|
||||||
|
key: String,
|
||||||
|
now: Date = Date(),
|
||||||
|
minimumInterval: TimeInterval? = nil
|
||||||
|
) -> Bool {
|
||||||
|
queue.sync {
|
||||||
|
let interval = minimumInterval ?? defaultMinimumInterval
|
||||||
|
if let lastLogTime = lastLogTimeByKey[key],
|
||||||
|
now.timeIntervalSince(lastLogTime) < interval {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lastLogTimeByKey[key] = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAll() {
|
||||||
|
queue.sync {
|
||||||
|
lastLogTimeByKey.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEOutboundFragmentPlan {
|
||||||
|
let fragmentPackets: [BitchatPacket]
|
||||||
|
let fragmentVersion: UInt8
|
||||||
|
let chunkSize: Int
|
||||||
|
let spacingMs: Int
|
||||||
|
|
||||||
|
var totalFragments: Int {
|
||||||
|
fragmentPackets.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var shouldPauseScanning: Bool {
|
||||||
|
totalFragments > 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEOutboundFragmentPlanner {
|
||||||
|
private static let minimumChunkSize = 64
|
||||||
|
private static let fragmentIDLength = 8
|
||||||
|
|
||||||
|
static func makePlan(
|
||||||
|
for request: BLEOutboundFragmentTransferRequest,
|
||||||
|
defaultChunkSize: Int,
|
||||||
|
bleMaxMTU: Int,
|
||||||
|
fragmentID: Data = randomFragmentID()
|
||||||
|
) -> BLEOutboundFragmentPlan? {
|
||||||
|
guard fragmentID.count == fragmentIDLength,
|
||||||
|
let fullData = request.packet.toBinaryData(padding: request.pad) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let sizing = sizingPolicy(
|
||||||
|
for: request.packet,
|
||||||
|
requestedMaxChunk: request.maxChunk,
|
||||||
|
defaultChunkSize: defaultChunkSize,
|
||||||
|
bleMaxMTU: bleMaxMTU
|
||||||
|
)
|
||||||
|
|
||||||
|
let chunks = stride(from: 0, to: fullData.count, by: sizing.chunkSize).map { offset in
|
||||||
|
Data(fullData[offset..<min(offset + sizing.chunkSize, fullData.count)])
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !chunks.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let fragmentRecipient: Data? = {
|
||||||
|
if let directedPeer = request.directedPeer {
|
||||||
|
return Data(hexString: directedPeer.id)
|
||||||
|
}
|
||||||
|
return request.packet.recipientID
|
||||||
|
}()
|
||||||
|
|
||||||
|
let fragmentPackets = chunks.enumerated().map { index, chunk in
|
||||||
|
makeFragmentPacket(
|
||||||
|
original: request.packet,
|
||||||
|
fragmentID: fragmentID,
|
||||||
|
index: index,
|
||||||
|
total: chunks.count,
|
||||||
|
fragmentData: chunk,
|
||||||
|
fragmentRecipient: fragmentRecipient,
|
||||||
|
fragmentVersion: sizing.fragmentVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return BLEOutboundFragmentPlan(
|
||||||
|
fragmentPackets: fragmentPackets,
|
||||||
|
fragmentVersion: sizing.fragmentVersion,
|
||||||
|
chunkSize: sizing.chunkSize,
|
||||||
|
spacingMs: spacingMs(for: request)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sizingPolicy(
|
||||||
|
for packet: BitchatPacket,
|
||||||
|
requestedMaxChunk: Int?,
|
||||||
|
defaultChunkSize: Int,
|
||||||
|
bleMaxMTU: Int
|
||||||
|
) -> (fragmentVersion: UInt8, chunkSize: Int) {
|
||||||
|
var fragmentVersion: UInt8 = 1
|
||||||
|
var calculatedChunk = defaultChunkSize
|
||||||
|
|
||||||
|
if let route = packet.route, !route.isEmpty {
|
||||||
|
fragmentVersion = 2
|
||||||
|
let routeSize = 1 + (route.count * 8)
|
||||||
|
let overhead = 16 + 8 + 8 + routeSize + 13 + 16
|
||||||
|
calculatedChunk = max(minimumChunkSize, bleMaxMTU - overhead)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
fragmentVersion: fragmentVersion,
|
||||||
|
chunkSize: max(minimumChunkSize, requestedMaxChunk ?? calculatedChunk)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeFragmentPacket(
|
||||||
|
original packet: BitchatPacket,
|
||||||
|
fragmentID: Data,
|
||||||
|
index: Int,
|
||||||
|
total: Int,
|
||||||
|
fragmentData: Data,
|
||||||
|
fragmentRecipient: Data?,
|
||||||
|
fragmentVersion: UInt8
|
||||||
|
) -> BitchatPacket {
|
||||||
|
var payload = Data()
|
||||||
|
payload.append(fragmentID)
|
||||||
|
payload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) })
|
||||||
|
payload.append(contentsOf: withUnsafeBytes(of: UInt16(total).bigEndian) { Data($0) })
|
||||||
|
payload.append(packet.type)
|
||||||
|
payload.append(fragmentData)
|
||||||
|
|
||||||
|
return BitchatPacket(
|
||||||
|
type: MessageType.fragment.rawValue,
|
||||||
|
senderID: packet.senderID,
|
||||||
|
recipientID: fragmentRecipient,
|
||||||
|
timestamp: packet.timestamp,
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: packet.ttl,
|
||||||
|
version: fragmentVersion,
|
||||||
|
route: packet.route,
|
||||||
|
isRSR: packet.isRSR
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func spacingMs(for request: BLEOutboundFragmentTransferRequest) -> Int {
|
||||||
|
if request.directedPeer != nil || request.packet.recipientID != nil {
|
||||||
|
return TransportConfig.bleFragmentSpacingDirectedMs
|
||||||
|
}
|
||||||
|
|
||||||
|
return TransportConfig.bleFragmentSpacingMs
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func randomFragmentID() -> Data {
|
||||||
|
Data((0..<fragmentIDLength).map { _ in UInt8.random(in: 0...255) })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEPeerInfo: Equatable {
|
||||||
|
let peerID: PeerID
|
||||||
|
var nickname: String
|
||||||
|
var isConnected: Bool
|
||||||
|
var noisePublicKey: Data?
|
||||||
|
var signingPublicKey: Data?
|
||||||
|
var isVerifiedNickname: Bool
|
||||||
|
var lastSeen: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEPeerAnnounceUpdate: Equatable {
|
||||||
|
let isNewPeer: Bool
|
||||||
|
let wasDisconnected: Bool
|
||||||
|
let previousNickname: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEPeerLinkPresence: Equatable {
|
||||||
|
var hasPeripheral: Bool
|
||||||
|
var hasCentral: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLERemovedPeer: Equatable {
|
||||||
|
let peerID: PeerID
|
||||||
|
let nickname: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEPeerConnectivityChanges: Equatable {
|
||||||
|
var disconnectedPeerIDs: [PeerID] = []
|
||||||
|
var removedPeers: [BLERemovedPeer] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEPeerRegistry {
|
||||||
|
private var peers: [PeerID: BLEPeerInfo] = [:]
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
peers.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
peers.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var peerIDs: [PeerID] {
|
||||||
|
Array(peers.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectedCount: Int {
|
||||||
|
peers.values.filter(\.isConnected).count
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectedPeerIDs: [PeerID] {
|
||||||
|
peers.values.compactMap { $0.isConnected ? $0.peerID : nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectedRoutingData: [Data] {
|
||||||
|
peers.values.filter(\.isConnected).compactMap { $0.peerID.routingData }
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshotByID: [PeerID: BLEPeerInfo] {
|
||||||
|
peers
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
peers.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func info(for peerID: PeerID) -> BLEPeerInfo? {
|
||||||
|
peers[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func upsert(_ info: BLEPeerInfo) {
|
||||||
|
peers[info.peerID] = info
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func remove(_ peerID: PeerID) -> BLEPeerInfo? {
|
||||||
|
peers.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isConnected(_ peerID: PeerID) -> Bool {
|
||||||
|
peers[peerID.toShort()]?.isConnected ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isReachable(_ peerID: PeerID, now: Date) -> Bool {
|
||||||
|
let shortID = peerID.toShort()
|
||||||
|
let meshAttached = connectedCount > 0
|
||||||
|
guard let info = peers[shortID] else { return false }
|
||||||
|
if info.isConnected { return true }
|
||||||
|
guard meshAttached else { return false }
|
||||||
|
|
||||||
|
let retention: TimeInterval = info.isVerifiedNickname
|
||||||
|
? TransportConfig.bleReachabilityRetentionVerifiedSeconds
|
||||||
|
: TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
||||||
|
return now.timeIntervalSince(info.lastSeen) <= retention
|
||||||
|
}
|
||||||
|
|
||||||
|
func nickname(for peerID: PeerID, connectedOnly: Bool) -> String? {
|
||||||
|
guard let peer = peers[peerID] else { return nil }
|
||||||
|
if connectedOnly && !peer.isConnected { return nil }
|
||||||
|
return peer.nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprint(for peerID: PeerID) -> String? {
|
||||||
|
peers[peerID]?.noisePublicKey?.sha256Fingerprint()
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
||||||
|
let connected = peers.filter { $0.value.isConnected }
|
||||||
|
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
||||||
|
return PeerDisplayNameResolver.resolve(tuples, selfNickname: selfNickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func transportSnapshots(selfNickname: String) -> [TransportPeerSnapshot] {
|
||||||
|
let snapshot = Array(peers.values)
|
||||||
|
let resolvedNames = PeerDisplayNameResolver.resolve(
|
||||||
|
snapshot.map { ($0.peerID, $0.nickname, $0.isConnected) },
|
||||||
|
selfNickname: selfNickname
|
||||||
|
)
|
||||||
|
return snapshot.map { info in
|
||||||
|
TransportPeerSnapshot(
|
||||||
|
peerID: info.peerID,
|
||||||
|
nickname: resolvedNames[info.peerID] ?? info.nickname,
|
||||||
|
isConnected: info.isConnected,
|
||||||
|
noisePublicKey: info.noisePublicKey,
|
||||||
|
lastSeen: info.lastSeen
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collisionResolvedNickname(for peerID: PeerID, selfNickname: String) -> String? {
|
||||||
|
guard let info = peers[peerID], info.isVerifiedNickname else { return nil }
|
||||||
|
let hasCollision = peers.values.contains {
|
||||||
|
$0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID
|
||||||
|
} || selfNickname == info.nickname
|
||||||
|
return hasCollision ? info.nickname + "#" + String(peerID.id.prefix(4)) : info.nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func markDisconnected(_ peerID: PeerID) {
|
||||||
|
guard var info = peers[peerID] else { return }
|
||||||
|
info.isConnected = false
|
||||||
|
peers[peerID] = info
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func updateLastSeen(_ peerID: PeerID, at date: Date) {
|
||||||
|
guard var peer = peers[peerID] else { return }
|
||||||
|
peer.lastSeen = date
|
||||||
|
peers[peerID] = peer
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func upsertVerifiedAnnounce(
|
||||||
|
peerID: PeerID,
|
||||||
|
nickname: String,
|
||||||
|
noisePublicKey: Data,
|
||||||
|
signingPublicKey: Data?,
|
||||||
|
isConnected: Bool,
|
||||||
|
now: Date
|
||||||
|
) -> BLEPeerAnnounceUpdate {
|
||||||
|
let existing = peers[peerID]
|
||||||
|
let update = BLEPeerAnnounceUpdate(
|
||||||
|
isNewPeer: existing == nil,
|
||||||
|
wasDisconnected: existing?.isConnected == false,
|
||||||
|
previousNickname: existing?.nickname
|
||||||
|
)
|
||||||
|
|
||||||
|
peers[peerID] = BLEPeerInfo(
|
||||||
|
peerID: existing?.peerID ?? peerID,
|
||||||
|
nickname: nickname,
|
||||||
|
isConnected: isConnected,
|
||||||
|
noisePublicKey: noisePublicKey,
|
||||||
|
signingPublicKey: signingPublicKey,
|
||||||
|
isVerifiedNickname: true,
|
||||||
|
lastSeen: now
|
||||||
|
)
|
||||||
|
|
||||||
|
return update
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func reconcileConnectivity(
|
||||||
|
now: Date,
|
||||||
|
linkStates: [PeerID: BLEPeerLinkPresence]
|
||||||
|
) -> BLEPeerConnectivityChanges {
|
||||||
|
var changes = BLEPeerConnectivityChanges()
|
||||||
|
|
||||||
|
for (peerID, peer) in Array(peers) {
|
||||||
|
let age = now.timeIntervalSince(peer.lastSeen)
|
||||||
|
let retention: TimeInterval = peer.isVerifiedNickname
|
||||||
|
? TransportConfig.bleReachabilityRetentionVerifiedSeconds
|
||||||
|
: TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
||||||
|
|
||||||
|
if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds {
|
||||||
|
let state = linkStates[peerID] ?? BLEPeerLinkPresence(hasPeripheral: false, hasCentral: false)
|
||||||
|
if !state.hasPeripheral && !state.hasCentral {
|
||||||
|
var updated = peer
|
||||||
|
updated.isConnected = false
|
||||||
|
peers[peerID] = updated
|
||||||
|
changes.disconnectedPeerIDs.append(peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !peer.isConnected && age > retention {
|
||||||
|
peers.removeValue(forKey: peerID)
|
||||||
|
changes.removedPeers.append(BLERemovedPeer(peerID: peerID, nickname: peer.nickname))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLEPeerSenderDisplayName {
|
||||||
|
static func resolveKnownPeer(
|
||||||
|
peerID: PeerID,
|
||||||
|
localPeerID: PeerID,
|
||||||
|
localNickname: String,
|
||||||
|
peers: [PeerID: BLEPeerInfo],
|
||||||
|
allowConnectedUnverified: Bool
|
||||||
|
) -> String? {
|
||||||
|
if peerID == localPeerID {
|
||||||
|
return localNickname
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let info = peers[peerID] else { return nil }
|
||||||
|
|
||||||
|
if info.isVerifiedNickname {
|
||||||
|
return collisionResolvedName(
|
||||||
|
displayName: info.nickname,
|
||||||
|
collisionNickname: info.nickname,
|
||||||
|
peerID: peerID,
|
||||||
|
localNickname: localNickname,
|
||||||
|
peers: peers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if allowConnectedUnverified, info.isConnected {
|
||||||
|
let displayName = info.nickname.isEmpty ? anonymousNickname(for: peerID) : info.nickname
|
||||||
|
return collisionResolvedName(
|
||||||
|
displayName: displayName,
|
||||||
|
collisionNickname: info.nickname,
|
||||||
|
peerID: peerID,
|
||||||
|
localNickname: localNickname,
|
||||||
|
peers: peers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func anonymousNickname(for peerID: PeerID) -> String {
|
||||||
|
"anon" + String(peerID.id.prefix(4))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func collisionResolvedName(
|
||||||
|
displayName: String,
|
||||||
|
collisionNickname: String,
|
||||||
|
peerID: PeerID,
|
||||||
|
localNickname: String,
|
||||||
|
peers: [PeerID: BLEPeerInfo]
|
||||||
|
) -> String {
|
||||||
|
let hasCollision = peers.values.contains {
|
||||||
|
$0.isConnected && $0.nickname == collisionNickname && $0.peerID != peerID
|
||||||
|
} || localNickname == collisionNickname
|
||||||
|
|
||||||
|
guard hasCollision else { return displayName }
|
||||||
|
return displayName + "#" + String(peerID.id.prefix(4))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEReceivedPacketContext: Equatable {
|
||||||
|
let senderID: PeerID
|
||||||
|
let messageID: String
|
||||||
|
let messageType: MessageType?
|
||||||
|
let shouldDeduplicate: Bool
|
||||||
|
let logsHandlingDetails: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEReceivePipeline {
|
||||||
|
static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext {
|
||||||
|
let senderID = PeerID(hexData: packet.senderID)
|
||||||
|
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)"
|
||||||
|
let messageType = MessageType(rawValue: packet.type)
|
||||||
|
let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID
|
||||||
|
let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay
|
||||||
|
|
||||||
|
return BLEReceivedPacketContext(
|
||||||
|
senderID: senderID,
|
||||||
|
messageID: messageID,
|
||||||
|
messageType: messageType,
|
||||||
|
shouldDeduplicate: shouldDeduplicate,
|
||||||
|
logsHandlingDetails: messageType != .announce
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func shouldCancelScheduledRelayForDuplicate(connectedPeerCount: Int) -> Bool {
|
||||||
|
connectedPeerCount > 2
|
||||||
|
}
|
||||||
|
|
||||||
|
static func relayDecision(
|
||||||
|
for packet: BitchatPacket,
|
||||||
|
senderID: PeerID,
|
||||||
|
localPeerID: PeerID,
|
||||||
|
degree: Int,
|
||||||
|
highDegreeThreshold: Int
|
||||||
|
) -> RelayDecision {
|
||||||
|
RelayController.decide(
|
||||||
|
ttl: packet.ttl,
|
||||||
|
senderIsSelf: senderID == localPeerID,
|
||||||
|
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
|
||||||
|
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
|
||||||
|
isDirectedEncrypted: packet.type == MessageType.noiseEncrypted.rawValue && packet.recipientID != nil,
|
||||||
|
isFragment: packet.type == MessageType.fragment.rawValue,
|
||||||
|
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||||
|
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||||
|
isAnnounce: packet.type == MessageType.announce.rawValue,
|
||||||
|
degree: degree,
|
||||||
|
highDegreeThreshold: highDegreeThreshold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLERecentTrafficTracker: Equatable {
|
||||||
|
private var packetTimestamps: [Date] = []
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
packetTimestamps.count
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
packetTimestamps.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func recordPacket(at now: Date) {
|
||||||
|
packetTimestamps.append(now)
|
||||||
|
prune(at: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasTraffic(within seconds: TimeInterval, now: Date) -> Bool {
|
||||||
|
let cutoff = now.addingTimeInterval(-seconds)
|
||||||
|
return packetTimestamps.contains { $0 >= cutoff }
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func prune(at now: Date) {
|
||||||
|
let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds)
|
||||||
|
if packetTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount {
|
||||||
|
packetTimestamps.removeFirst(packetTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount)
|
||||||
|
}
|
||||||
|
packetTimestamps.removeAll { $0 < cutoff }
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLESubscriptionAnnounceDecision: Equatable {
|
||||||
|
case allowed
|
||||||
|
case rateLimited(backoffSeconds: TimeInterval, attemptCount: Int, suppressAnnounce: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLESubscriptionAnnounceLimiter {
|
||||||
|
private struct State {
|
||||||
|
var lastAnnounceTime: Date
|
||||||
|
var attemptCount: Int
|
||||||
|
var currentBackoffSeconds: TimeInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
private var states: [String: State] = [:]
|
||||||
|
|
||||||
|
var trackedCentralCount: Int {
|
||||||
|
states.count
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
states.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func decision(for centralID: String, now: Date) -> BLESubscriptionAnnounceDecision {
|
||||||
|
pruneStaleEntries(now: now)
|
||||||
|
|
||||||
|
guard let existing = states[centralID] else {
|
||||||
|
recordAllowedAttempt(for: centralID, now: now)
|
||||||
|
return .allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeSinceLastAnnounce = now.timeIntervalSince(existing.lastAnnounceTime)
|
||||||
|
guard timeSinceLastAnnounce < existing.currentBackoffSeconds else {
|
||||||
|
recordAllowedAttempt(for: centralID, now: now)
|
||||||
|
return .allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
let newAttemptCount = existing.attemptCount + 1
|
||||||
|
let newBackoff = min(
|
||||||
|
existing.currentBackoffSeconds * TransportConfig.bleSubscriptionRateLimitBackoffFactor,
|
||||||
|
TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds
|
||||||
|
)
|
||||||
|
states[centralID] = State(
|
||||||
|
lastAnnounceTime: now,
|
||||||
|
attemptCount: newAttemptCount,
|
||||||
|
currentBackoffSeconds: newBackoff
|
||||||
|
)
|
||||||
|
|
||||||
|
return .rateLimited(
|
||||||
|
backoffSeconds: existing.currentBackoffSeconds,
|
||||||
|
attemptCount: existing.attemptCount,
|
||||||
|
suppressAnnounce: newAttemptCount >= TransportConfig.bleSubscriptionRateLimitMaxAttempts
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func recordAllowedAttempt(for centralID: String, now: Date) {
|
||||||
|
states[centralID] = State(
|
||||||
|
lastAnnounceTime: now,
|
||||||
|
attemptCount: 1,
|
||||||
|
currentBackoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func pruneStaleEntries(now: Date) {
|
||||||
|
let windowSeconds = TransportConfig.bleSubscriptionRateLimitWindowSeconds
|
||||||
|
states = states.filter { _, state in
|
||||||
|
now.timeIntervalSince(state.lastAnnounceTime) < windowSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -910,7 +910,7 @@ struct ChatViewModelMediaTransferTests {
|
|||||||
viewModel.selectedPrivateChatPeer = peerID
|
viewModel.selectedPrivateChatPeer = peerID
|
||||||
viewModel.sendImage(from: sourceURL)
|
viewModel.sendImage(from: sourceURL)
|
||||||
|
|
||||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 1.0)
|
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 5.0)
|
||||||
#expect(didSend)
|
#expect(didSend)
|
||||||
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
|
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
|
||||||
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
|
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite("BLE ingress packet guard tests")
|
||||||
|
struct BLEIngressPacketGuardTests {
|
||||||
|
@Test("valid packets return the received and validation peer context")
|
||||||
|
func validPacketsReturnContext() throws {
|
||||||
|
let local = PeerID(str: "0011223344556677")
|
||||||
|
let bound = PeerID(str: "1122334455667788")
|
||||||
|
let sender = PeerID(str: "8899aabbccddeeff")
|
||||||
|
let packet = makePacket(sender: sender, timestamp: 1_000)
|
||||||
|
|
||||||
|
let context = try #require(success(BLEIngressPacketGuard.evaluate(
|
||||||
|
packet: packet,
|
||||||
|
claimedSenderID: sender,
|
||||||
|
boundPeerID: bound,
|
||||||
|
localPeerID: local,
|
||||||
|
directAnnounceTTL: 7,
|
||||||
|
nowMs: 1_000,
|
||||||
|
isValidSyncResponse: { _ in false }
|
||||||
|
)))
|
||||||
|
|
||||||
|
#expect(context.receivedFromPeerID == bound)
|
||||||
|
#expect(context.validationPeerID == sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("self loopback and direct announce spoofing are rejected before timestamp checks")
|
||||||
|
func linkBindingRejectionsWinBeforeTimestampChecks() {
|
||||||
|
let local = PeerID(str: "0011223344556677")
|
||||||
|
let bound = PeerID(str: "1122334455667788")
|
||||||
|
let claimed = PeerID(str: "8899aabbccddeeff")
|
||||||
|
let selfPacket = makePacket(sender: local, timestamp: 0)
|
||||||
|
let spoofedAnnounce = makePacket(type: .announce, sender: claimed, timestamp: 0, ttl: 7)
|
||||||
|
|
||||||
|
let selfResult = BLEIngressPacketGuard.evaluate(
|
||||||
|
packet: selfPacket,
|
||||||
|
claimedSenderID: local,
|
||||||
|
boundPeerID: bound,
|
||||||
|
localPeerID: local,
|
||||||
|
directAnnounceTTL: 7,
|
||||||
|
nowMs: 1_000_000,
|
||||||
|
isValidSyncResponse: { _ in false }
|
||||||
|
)
|
||||||
|
let spoofResult = BLEIngressPacketGuard.evaluate(
|
||||||
|
packet: spoofedAnnounce,
|
||||||
|
claimedSenderID: claimed,
|
||||||
|
boundPeerID: bound,
|
||||||
|
localPeerID: local,
|
||||||
|
directAnnounceTTL: 7,
|
||||||
|
nowMs: 1_000_000,
|
||||||
|
isValidSyncResponse: { _ in false }
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(selfResult == .failure(.selfLoopback(packetType: MessageType.message.rawValue)))
|
||||||
|
#expect(spoofResult == .failure(.directSenderMismatch(boundPeerID: bound, claimedSenderID: claimed)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("timestamp skew outside the window is rejected")
|
||||||
|
func timestampSkewIsRejected() {
|
||||||
|
let local = PeerID(str: "0011223344556677")
|
||||||
|
let sender = PeerID(str: "1122334455667788")
|
||||||
|
let packet = makePacket(sender: sender, timestamp: 1_000)
|
||||||
|
|
||||||
|
let result = BLEIngressPacketGuard.evaluate(
|
||||||
|
packet: packet,
|
||||||
|
claimedSenderID: sender,
|
||||||
|
boundPeerID: nil,
|
||||||
|
localPeerID: local,
|
||||||
|
directAnnounceTTL: 7,
|
||||||
|
nowMs: 200_000,
|
||||||
|
maxTimestampSkewMs: 120_000,
|
||||||
|
isValidSyncResponse: { _ in false }
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(result == .failure(.timestampSkew(
|
||||||
|
peerID: sender,
|
||||||
|
skewMs: 199_000,
|
||||||
|
maxSkewMs: 120_000
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("valid RSR packets use bound peer validation and bypass timestamp skew")
|
||||||
|
func validRSRUsesBoundPeerAndBypassesTimestamp() throws {
|
||||||
|
let local = PeerID(str: "0011223344556677")
|
||||||
|
let bound = PeerID(str: "1122334455667788")
|
||||||
|
let sender = PeerID(str: "8899aabbccddeeff")
|
||||||
|
var packet = makePacket(sender: sender, timestamp: 1)
|
||||||
|
packet.isRSR = true
|
||||||
|
|
||||||
|
let context = try #require(success(BLEIngressPacketGuard.evaluate(
|
||||||
|
packet: packet,
|
||||||
|
claimedSenderID: sender,
|
||||||
|
boundPeerID: bound,
|
||||||
|
localPeerID: local,
|
||||||
|
directAnnounceTTL: 7,
|
||||||
|
nowMs: 1_000_000,
|
||||||
|
isValidSyncResponse: { $0 == bound }
|
||||||
|
)))
|
||||||
|
|
||||||
|
#expect(context.receivedFromPeerID == bound)
|
||||||
|
#expect(context.validationPeerID == bound)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("invalid RSR packets are rejected")
|
||||||
|
func invalidRSRIsRejected() {
|
||||||
|
let local = PeerID(str: "0011223344556677")
|
||||||
|
let bound = PeerID(str: "1122334455667788")
|
||||||
|
let sender = PeerID(str: "8899aabbccddeeff")
|
||||||
|
var packet = makePacket(sender: sender, timestamp: 1)
|
||||||
|
packet.isRSR = true
|
||||||
|
|
||||||
|
let result = BLEIngressPacketGuard.evaluate(
|
||||||
|
packet: packet,
|
||||||
|
claimedSenderID: sender,
|
||||||
|
boundPeerID: bound,
|
||||||
|
localPeerID: local,
|
||||||
|
directAnnounceTTL: 7,
|
||||||
|
nowMs: 1_000_000,
|
||||||
|
isValidSyncResponse: { _ in false }
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(result == .failure(.invalidRSR(peerID: bound)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makePacket(
|
||||||
|
type: MessageType = .message,
|
||||||
|
sender: PeerID,
|
||||||
|
timestamp: UInt64,
|
||||||
|
ttl: UInt8 = 3
|
||||||
|
) -> BitchatPacket {
|
||||||
|
BitchatPacket(
|
||||||
|
type: type.rawValue,
|
||||||
|
senderID: Data(hexString: sender.id) ?? Data(),
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: timestamp,
|
||||||
|
payload: Data([0x01, 0x02, 0x03]),
|
||||||
|
signature: nil,
|
||||||
|
ttl: ttl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func success(_ result: Result<BLEIngressPacketContext, BLEIngressPacketGuard.Rejection>) -> BLEIngressPacketContext? {
|
||||||
|
guard case .success(let context) = result else { return nil }
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite("BLE log rate limiter tests")
|
||||||
|
struct BLELogRateLimiterTests {
|
||||||
|
@Test("repeated log keys are suppressed until the default interval elapses")
|
||||||
|
func suppressesRepeatedKeysUntilIntervalElapses() {
|
||||||
|
let limiter = BLELogRateLimiter(defaultMinimumInterval: 5)
|
||||||
|
let now = Date(timeIntervalSince1970: 100)
|
||||||
|
|
||||||
|
#expect(limiter.shouldLog(key: "ready:peer-a", now: now))
|
||||||
|
#expect(!limiter.shouldLog(key: "ready:peer-a", now: now.addingTimeInterval(4.9)))
|
||||||
|
#expect(limiter.shouldLog(key: "ready:peer-a", now: now.addingTimeInterval(5)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("different log keys are tracked independently")
|
||||||
|
func tracksKeysIndependently() {
|
||||||
|
let limiter = BLELogRateLimiter(defaultMinimumInterval: 5)
|
||||||
|
let now = Date(timeIntervalSince1970: 100)
|
||||||
|
|
||||||
|
#expect(limiter.shouldLog(key: "ready:peer-a", now: now))
|
||||||
|
#expect(limiter.shouldLog(key: "ready:peer-b", now: now))
|
||||||
|
#expect(!limiter.shouldLog(key: "ready:peer-a", now: now.addingTimeInterval(1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("call sites can override the default interval")
|
||||||
|
func supportsPerCallIntervals() {
|
||||||
|
let limiter = BLELogRateLimiter(defaultMinimumInterval: 5)
|
||||||
|
let now = Date(timeIntervalSince1970: 100)
|
||||||
|
|
||||||
|
#expect(limiter.shouldLog(key: "self-loopback", now: now, minimumInterval: 30))
|
||||||
|
#expect(!limiter.shouldLog(key: "self-loopback", now: now.addingTimeInterval(29), minimumInterval: 30))
|
||||||
|
#expect(limiter.shouldLog(key: "self-loopback", now: now.addingTimeInterval(30), minimumInterval: 30))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite("BLE outbound fragment planner tests")
|
||||||
|
struct BLEOutboundFragmentPlannerTests {
|
||||||
|
@Test("planner splits packets and preserves reassembled payload")
|
||||||
|
func plannerSplitsAndReassemblesPacket() throws {
|
||||||
|
let packet = makePacket(payload: makePayload(count: 384))
|
||||||
|
let request = BLEOutboundFragmentTransferRequest(
|
||||||
|
packet: packet,
|
||||||
|
pad: false,
|
||||||
|
maxChunk: 128,
|
||||||
|
directedPeer: nil,
|
||||||
|
transferId: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let plan = try #require(BLEOutboundFragmentPlanner.makePlan(
|
||||||
|
for: request,
|
||||||
|
defaultChunkSize: 256,
|
||||||
|
bleMaxMTU: 512,
|
||||||
|
fragmentID: Data(repeating: 0xA1, count: 8)
|
||||||
|
))
|
||||||
|
let headers = try plan.fragmentPackets.map { try #require(BLEFragmentHeader(packet: $0)) }
|
||||||
|
let reassembled = headers.reduce(into: Data()) { data, header in
|
||||||
|
data.append(header.fragmentData)
|
||||||
|
}
|
||||||
|
let decoded = try #require(BinaryProtocol.decode(reassembled))
|
||||||
|
|
||||||
|
#expect(plan.fragmentVersion == 1)
|
||||||
|
#expect(plan.chunkSize == 128)
|
||||||
|
#expect(plan.spacingMs == TransportConfig.bleFragmentSpacingMs)
|
||||||
|
#expect(headers.map(\.index) == Array(0..<headers.count))
|
||||||
|
#expect(decoded.type == packet.type)
|
||||||
|
#expect(decoded.payload == packet.payload)
|
||||||
|
#expect(plan.fragmentPackets.allSatisfy { $0.recipientID == nil })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("directed fragments target the directed peer and use directed pacing")
|
||||||
|
func directedFragmentsUseDirectedRecipientAndSpacing() throws {
|
||||||
|
let directedPeer = PeerID(str: "8877665544332211")
|
||||||
|
let packet = makePacket(payload: makePayload(count: 256))
|
||||||
|
let request = BLEOutboundFragmentTransferRequest(
|
||||||
|
packet: packet,
|
||||||
|
pad: false,
|
||||||
|
maxChunk: 96,
|
||||||
|
directedPeer: directedPeer,
|
||||||
|
transferId: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let plan = try #require(BLEOutboundFragmentPlanner.makePlan(
|
||||||
|
for: request,
|
||||||
|
defaultChunkSize: 256,
|
||||||
|
bleMaxMTU: 512,
|
||||||
|
fragmentID: Data(repeating: 0xB2, count: 8)
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(plan.spacingMs == TransportConfig.bleFragmentSpacingDirectedMs)
|
||||||
|
#expect(plan.fragmentPackets.allSatisfy { $0.recipientID == Data(hexString: directedPeer.id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("route-aware fragments use version two and route-sized chunking")
|
||||||
|
func routeAwareFragmentsUseVersionTwoAndRouteSizedChunking() throws {
|
||||||
|
let route = [
|
||||||
|
Data([0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17]),
|
||||||
|
Data([0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27])
|
||||||
|
]
|
||||||
|
let packet = makePacket(payload: makePayload(count: 192), route: route, isRSR: true)
|
||||||
|
let request = BLEOutboundFragmentTransferRequest(
|
||||||
|
packet: packet,
|
||||||
|
pad: false,
|
||||||
|
maxChunk: nil,
|
||||||
|
directedPeer: nil,
|
||||||
|
transferId: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let plan = try #require(BLEOutboundFragmentPlanner.makePlan(
|
||||||
|
for: request,
|
||||||
|
defaultChunkSize: 256,
|
||||||
|
bleMaxMTU: 128,
|
||||||
|
fragmentID: Data(repeating: 0xC3, count: 8)
|
||||||
|
))
|
||||||
|
let firstHeader = try #require(BLEFragmentHeader(packet: plan.fragmentPackets[0]))
|
||||||
|
|
||||||
|
#expect(plan.fragmentVersion == 2)
|
||||||
|
#expect(plan.chunkSize == 64)
|
||||||
|
#expect(firstHeader.fragmentData.count <= 64)
|
||||||
|
#expect(plan.fragmentPackets.allSatisfy { $0.route == route && $0.isRSR })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("invalid fragment IDs do not produce a plan")
|
||||||
|
func invalidFragmentIDReturnsNil() {
|
||||||
|
let packet = makePacket(payload: makePayload(count: 128))
|
||||||
|
let request = BLEOutboundFragmentTransferRequest(
|
||||||
|
packet: packet,
|
||||||
|
pad: false,
|
||||||
|
maxChunk: nil,
|
||||||
|
directedPeer: nil,
|
||||||
|
transferId: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(BLEOutboundFragmentPlanner.makePlan(
|
||||||
|
for: request,
|
||||||
|
defaultChunkSize: 256,
|
||||||
|
bleMaxMTU: 512,
|
||||||
|
fragmentID: Data(repeating: 0x00, count: 7)
|
||||||
|
) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makePacket(
|
||||||
|
payload: Data,
|
||||||
|
route: [Data]? = nil,
|
||||||
|
isRSR: Bool = false
|
||||||
|
) -> BitchatPacket {
|
||||||
|
BitchatPacket(
|
||||||
|
type: MessageType.message.rawValue,
|
||||||
|
senderID: Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]),
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: 0x0102030405,
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 3,
|
||||||
|
route: route,
|
||||||
|
isRSR: isRSR
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makePayload(count: Int, seed: UInt64 = 0xABCD_1234) -> Data {
|
||||||
|
var state = seed
|
||||||
|
return Data((0..<count).map { _ in
|
||||||
|
state = state &* 6364136223846793005 &+ 1442695040888963407
|
||||||
|
return UInt8(truncatingIfNeeded: state >> 32)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite("BLE peer registry tests")
|
||||||
|
struct BLEPeerRegistryTests {
|
||||||
|
@Test("upserted announces track new, reconnect, and rename transitions")
|
||||||
|
func upsertVerifiedAnnounceTracksTransitions() {
|
||||||
|
var registry = BLEPeerRegistry()
|
||||||
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
|
let firstSeen = Date(timeIntervalSince1970: 100)
|
||||||
|
|
||||||
|
let first = registry.upsertVerifiedAnnounce(
|
||||||
|
peerID: peerID,
|
||||||
|
nickname: "alice",
|
||||||
|
noisePublicKey: Data([1, 2, 3]),
|
||||||
|
signingPublicKey: Data([4, 5, 6]),
|
||||||
|
isConnected: true,
|
||||||
|
now: firstSeen
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(first.isNewPeer)
|
||||||
|
#expect(!first.wasDisconnected)
|
||||||
|
#expect(first.previousNickname == nil)
|
||||||
|
#expect(registry.connectedPeerIDs == [peerID])
|
||||||
|
#expect(registry.nickname(for: peerID, connectedOnly: true) == "alice")
|
||||||
|
|
||||||
|
registry.markDisconnected(peerID)
|
||||||
|
let reconnect = registry.upsertVerifiedAnnounce(
|
||||||
|
peerID: peerID,
|
||||||
|
nickname: "alice-renamed",
|
||||||
|
noisePublicKey: Data([1, 2, 3]),
|
||||||
|
signingPublicKey: Data([4, 5, 6]),
|
||||||
|
isConnected: true,
|
||||||
|
now: firstSeen.addingTimeInterval(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(!reconnect.isNewPeer)
|
||||||
|
#expect(reconnect.wasDisconnected)
|
||||||
|
#expect(reconnect.previousNickname == "alice")
|
||||||
|
#expect(registry.info(for: peerID)?.nickname == "alice-renamed")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("reachability keeps recent verified offline peers only when mesh is attached")
|
||||||
|
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
|
||||||
|
let offlinePeer = PeerID(str: "1122334455667788")
|
||||||
|
let connectedPeer = PeerID(str: "8877665544332211")
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
var isolatedRegistry = BLEPeerRegistry()
|
||||||
|
isolatedRegistry.upsert(BLEPeerInfo(
|
||||||
|
peerID: offlinePeer,
|
||||||
|
nickname: "offline",
|
||||||
|
isConnected: false,
|
||||||
|
noisePublicKey: nil,
|
||||||
|
signingPublicKey: nil,
|
||||||
|
isVerifiedNickname: true,
|
||||||
|
lastSeen: now
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(!isolatedRegistry.isReachable(offlinePeer, now: now))
|
||||||
|
|
||||||
|
var attachedRegistry = isolatedRegistry
|
||||||
|
attachedRegistry.upsert(BLEPeerInfo(
|
||||||
|
peerID: connectedPeer,
|
||||||
|
nickname: "connected",
|
||||||
|
isConnected: true,
|
||||||
|
noisePublicKey: nil,
|
||||||
|
signingPublicKey: nil,
|
||||||
|
isVerifiedNickname: true,
|
||||||
|
lastSeen: now
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(attachedRegistry.isReachable(offlinePeer, now: now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("connectivity reconciliation disconnects inactive peers and prunes expired offline peers")
|
||||||
|
func reconcileConnectivityUpdatesAndPrunesPeerState() {
|
||||||
|
var registry = BLEPeerRegistry()
|
||||||
|
let inactiveConnectedPeer = PeerID(str: "1122334455667788")
|
||||||
|
let expiredOfflinePeer = PeerID(str: "8877665544332211")
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
registry.upsert(BLEPeerInfo(
|
||||||
|
peerID: inactiveConnectedPeer,
|
||||||
|
nickname: "inactive",
|
||||||
|
isConnected: true,
|
||||||
|
noisePublicKey: nil,
|
||||||
|
signingPublicKey: nil,
|
||||||
|
isVerifiedNickname: true,
|
||||||
|
lastSeen: now.addingTimeInterval(-TransportConfig.blePeerInactivityTimeoutSeconds - 1)
|
||||||
|
))
|
||||||
|
registry.upsert(BLEPeerInfo(
|
||||||
|
peerID: expiredOfflinePeer,
|
||||||
|
nickname: "expired",
|
||||||
|
isConnected: false,
|
||||||
|
noisePublicKey: nil,
|
||||||
|
signingPublicKey: nil,
|
||||||
|
isVerifiedNickname: true,
|
||||||
|
lastSeen: .distantPast
|
||||||
|
))
|
||||||
|
|
||||||
|
let changes = registry.reconcileConnectivity(now: now, linkStates: [:])
|
||||||
|
|
||||||
|
#expect(changes.disconnectedPeerIDs == [inactiveConnectedPeer])
|
||||||
|
#expect(changes.removedPeers.map(\.peerID) == [expiredOfflinePeer])
|
||||||
|
#expect(registry.info(for: inactiveConnectedPeer)?.isConnected == false)
|
||||||
|
#expect(registry.info(for: expiredOfflinePeer) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite("BLE peer sender display name tests")
|
||||||
|
struct BLEPeerSenderDisplayNameTests {
|
||||||
|
@Test("local peer resolves to local nickname")
|
||||||
|
func localPeerUsesLocalNickname() {
|
||||||
|
let local = PeerID(str: "1122334455667788")
|
||||||
|
|
||||||
|
#expect(BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||||
|
peerID: local,
|
||||||
|
localPeerID: local,
|
||||||
|
localNickname: "me",
|
||||||
|
peers: [:],
|
||||||
|
allowConnectedUnverified: false
|
||||||
|
) == "me")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("verified nickname collisions add peer suffix")
|
||||||
|
func verifiedCollisionAddsSuffix() {
|
||||||
|
let local = PeerID(str: "1122334455667788")
|
||||||
|
let peer = PeerID(str: "8877665544332211")
|
||||||
|
let peers = [
|
||||||
|
peer: makeInfo(peerID: peer, nickname: "sam", isConnected: false, isVerifiedNickname: true)
|
||||||
|
]
|
||||||
|
|
||||||
|
#expect(BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||||
|
peerID: peer,
|
||||||
|
localPeerID: local,
|
||||||
|
localNickname: "sam",
|
||||||
|
peers: peers,
|
||||||
|
allowConnectedUnverified: false
|
||||||
|
) == "sam#8877")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("connected unverified fallback is opt in")
|
||||||
|
func connectedUnverifiedFallbackIsOptIn() {
|
||||||
|
let local = PeerID(str: "1122334455667788")
|
||||||
|
let peer = PeerID(str: "8877665544332211")
|
||||||
|
let peers = [
|
||||||
|
peer: makeInfo(peerID: peer, nickname: "", isConnected: true, isVerifiedNickname: false)
|
||||||
|
]
|
||||||
|
|
||||||
|
#expect(BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||||
|
peerID: peer,
|
||||||
|
localPeerID: local,
|
||||||
|
localNickname: "me",
|
||||||
|
peers: peers,
|
||||||
|
allowConnectedUnverified: false
|
||||||
|
) == nil)
|
||||||
|
#expect(BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||||
|
peerID: peer,
|
||||||
|
localPeerID: local,
|
||||||
|
localNickname: "me",
|
||||||
|
peers: peers,
|
||||||
|
allowConnectedUnverified: true
|
||||||
|
) == "anon8877")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeInfo(
|
||||||
|
peerID: PeerID,
|
||||||
|
nickname: String,
|
||||||
|
isConnected: Bool,
|
||||||
|
isVerifiedNickname: Bool
|
||||||
|
) -> BLEPeerInfo {
|
||||||
|
BLEPeerInfo(
|
||||||
|
peerID: peerID,
|
||||||
|
nickname: nickname,
|
||||||
|
isConnected: isConnected,
|
||||||
|
noisePublicKey: nil,
|
||||||
|
signingPublicKey: nil,
|
||||||
|
isVerifiedNickname: isVerifiedNickname,
|
||||||
|
lastSeen: Date(timeIntervalSince1970: 0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite("BLE receive pipeline tests")
|
||||||
|
struct BLEReceivePipelineTests {
|
||||||
|
@Test("context includes sender, type-scoped message ID, and logging policy")
|
||||||
|
func contextBuildsTypeScopedMessageID() {
|
||||||
|
let sender = PeerID(str: "1122334455667788")
|
||||||
|
let local = PeerID(str: "8877665544332211")
|
||||||
|
let packet = makePacket(type: .message, sender: sender, timestamp: 1234)
|
||||||
|
|
||||||
|
let context = BLEReceivePipeline.context(for: packet, localPeerID: local)
|
||||||
|
|
||||||
|
#expect(context.senderID == sender)
|
||||||
|
#expect(context.messageID == "\(sender)-1234-\(MessageType.message.rawValue)")
|
||||||
|
#expect(context.messageType == .message)
|
||||||
|
#expect(context.shouldDeduplicate)
|
||||||
|
#expect(context.logsHandlingDetails)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("fragments and self sync replays bypass global deduplication")
|
||||||
|
func contextBypassesDeduplicationForFragmentsAndSelfSyncReplay() {
|
||||||
|
let local = PeerID(str: "1122334455667788")
|
||||||
|
let remote = PeerID(str: "8877665544332211")
|
||||||
|
|
||||||
|
let fragment = BLEReceivePipeline.context(
|
||||||
|
for: makePacket(type: .fragment, sender: remote),
|
||||||
|
localPeerID: local
|
||||||
|
)
|
||||||
|
let selfReplay = BLEReceivePipeline.context(
|
||||||
|
for: makePacket(type: .message, sender: local, ttl: 0),
|
||||||
|
localPeerID: local
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(!fragment.shouldDeduplicate)
|
||||||
|
#expect(!selfReplay.shouldDeduplicate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("dense duplicate traffic cancels pending relays but sparse traffic does not")
|
||||||
|
func duplicateRelayCancellationUsesGraphDensity() {
|
||||||
|
#expect(!BLEReceivePipeline.shouldCancelScheduledRelayForDuplicate(connectedPeerCount: 2))
|
||||||
|
#expect(BLEReceivePipeline.shouldCancelScheduledRelayForDuplicate(connectedPeerCount: 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("relay decision maps packet context and suppresses local recipient traffic")
|
||||||
|
func relayDecisionSuppressesLocalRecipientTraffic() {
|
||||||
|
let sender = PeerID(str: "1122334455667788")
|
||||||
|
let local = PeerID(str: "8877665544332211")
|
||||||
|
let packet = makePacket(
|
||||||
|
type: .noiseEncrypted,
|
||||||
|
sender: sender,
|
||||||
|
recipient: local,
|
||||||
|
ttl: 7
|
||||||
|
)
|
||||||
|
|
||||||
|
let decision = BLEReceivePipeline.relayDecision(
|
||||||
|
for: packet,
|
||||||
|
senderID: sender,
|
||||||
|
localPeerID: local,
|
||||||
|
degree: 3,
|
||||||
|
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(!decision.shouldRelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("recent traffic tracker prunes by count and time window")
|
||||||
|
func recentTrafficTrackerPrunesByCountAndWindow() {
|
||||||
|
var tracker = BLERecentTrafficTracker()
|
||||||
|
let now = Date(timeIntervalSince1970: 1_000)
|
||||||
|
|
||||||
|
tracker.recordPacket(at: now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds - 1))
|
||||||
|
#expect(!tracker.hasTraffic(within: TransportConfig.bleRecentPacketWindowSeconds, now: now))
|
||||||
|
|
||||||
|
for index in 0...TransportConfig.bleRecentPacketWindowMaxCount {
|
||||||
|
tracker.recordPacket(at: now.addingTimeInterval(Double(index) * 0.001))
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(tracker.count == TransportConfig.bleRecentPacketWindowMaxCount)
|
||||||
|
#expect(tracker.hasTraffic(within: 1.0, now: now.addingTimeInterval(0.1)))
|
||||||
|
|
||||||
|
tracker.removeAll()
|
||||||
|
#expect(tracker.count == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makePacket(
|
||||||
|
type: MessageType,
|
||||||
|
sender: PeerID,
|
||||||
|
recipient: PeerID? = nil,
|
||||||
|
timestamp: UInt64 = 1,
|
||||||
|
ttl: UInt8 = 7
|
||||||
|
) -> BitchatPacket {
|
||||||
|
BitchatPacket(
|
||||||
|
type: type.rawValue,
|
||||||
|
senderID: Data(hexString: sender.id) ?? Data(),
|
||||||
|
recipientID: recipient.flatMap { Data(hexString: $0.id) },
|
||||||
|
timestamp: timestamp,
|
||||||
|
payload: Data([0x01, 0x02]),
|
||||||
|
signature: nil,
|
||||||
|
ttl: ttl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite("BLE subscription announce limiter tests")
|
||||||
|
struct BLESubscriptionAnnounceLimiterTests {
|
||||||
|
@Test("first subscription is allowed and repeated subscriptions are rate limited")
|
||||||
|
func repeatedSubscriptionsAreRateLimited() {
|
||||||
|
var limiter = BLESubscriptionAnnounceLimiter()
|
||||||
|
let centralID = "central-a"
|
||||||
|
let now = Date(timeIntervalSince1970: 100)
|
||||||
|
|
||||||
|
#expect(limiter.decision(for: centralID, now: now) == .allowed)
|
||||||
|
#expect(limiter.trackedCentralCount == 1)
|
||||||
|
|
||||||
|
let second = limiter.decision(for: centralID, now: now.addingTimeInterval(0.1))
|
||||||
|
#expect(second == .rateLimited(
|
||||||
|
backoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds,
|
||||||
|
attemptCount: 1,
|
||||||
|
suppressAnnounce: false
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("rapid subscription attempts eventually suppress announces")
|
||||||
|
func rapidAttemptsSuppressAnnouncesAtThreshold() {
|
||||||
|
var limiter = BLESubscriptionAnnounceLimiter()
|
||||||
|
let centralID = "central-a"
|
||||||
|
let now = Date(timeIntervalSince1970: 100)
|
||||||
|
|
||||||
|
#expect(limiter.decision(for: centralID, now: now) == .allowed)
|
||||||
|
|
||||||
|
var decision = BLESubscriptionAnnounceDecision.allowed
|
||||||
|
for attempt in 2...TransportConfig.bleSubscriptionRateLimitMaxAttempts {
|
||||||
|
decision = limiter.decision(
|
||||||
|
for: centralID,
|
||||||
|
now: now.addingTimeInterval(Double(attempt) * 0.01)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if case let .rateLimited(_, _, suppressAnnounce) = decision {
|
||||||
|
#expect(suppressAnnounce)
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected rate-limited decision at suppression threshold")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("stale limiter entries are pruned on the next decision")
|
||||||
|
func staleEntriesArePruned() {
|
||||||
|
var limiter = BLESubscriptionAnnounceLimiter()
|
||||||
|
let staleCentralID = "central-a"
|
||||||
|
let freshCentralID = "central-b"
|
||||||
|
let now = Date(timeIntervalSince1970: 100)
|
||||||
|
|
||||||
|
#expect(limiter.decision(for: staleCentralID, now: now) == .allowed)
|
||||||
|
#expect(limiter.trackedCentralCount == 1)
|
||||||
|
|
||||||
|
let afterWindow = now.addingTimeInterval(TransportConfig.bleSubscriptionRateLimitWindowSeconds + 1)
|
||||||
|
#expect(limiter.decision(for: freshCentralID, now: afterWindow) == .allowed)
|
||||||
|
#expect(limiter.trackedCentralCount == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user