mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 04:45:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87fdb2b3c0 | ||
|
|
b255355fed | ||
|
|
6e026c2c22 |
@@ -36,6 +36,30 @@ enum NoiseSecurityConstants {
|
|||||||
|
|
||||||
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
|
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
|
||||||
static let xxInitialMessageSize = 32
|
static let xxInitialMessageSize = 32
|
||||||
|
|
||||||
|
// Bounds an ordinary initiator whose message 1 or 2 is lost.
|
||||||
|
static let ordinaryHandshakeTimeout: TimeInterval = 10
|
||||||
|
|
||||||
|
// Bounds the receive-only rollback quarantine created by an unauthenticated
|
||||||
|
// inbound message 1. A lost message 3 must not strand outbound traffic.
|
||||||
|
static let ordinaryResponderHandshakeTimeout: TimeInterval = 20
|
||||||
|
|
||||||
|
// A released client may immediately retry after both crossed initiators
|
||||||
|
// yielded. Give that unilateral retry a brief head start before the
|
||||||
|
// patched side spends its one bounded recovery.
|
||||||
|
static let handshakeCollisionRecoveryDelay: TimeInterval = 0.2
|
||||||
|
|
||||||
|
// Rate-limited recovery remains actionable without spinning.
|
||||||
|
static let handshakeRateLimitRecoveryDelay: TimeInterval = 60
|
||||||
|
|
||||||
|
// Covers only reordering between a winning message 3 and the losing
|
||||||
|
// crossed message 1.
|
||||||
|
static let recentInitiatorCompletionGracePeriod: TimeInterval = 1
|
||||||
|
|
||||||
|
// After unauthenticated responder rollback, reject another attempt long
|
||||||
|
// enough that paced message 1 traffic cannot keep outbound paused. A
|
||||||
|
// legitimate peer converges through the one manager-owned local retry.
|
||||||
|
static let ordinaryReconnectRollbackCooldown: TimeInterval = 60
|
||||||
|
|
||||||
// Session timeout - sessions older than this should be renegotiated
|
// Session timeout - sessions older than this should be renegotiated
|
||||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||||
|
|||||||
@@ -13,3 +13,9 @@ enum NoiseSessionError: Error, Equatable {
|
|||||||
case alreadyEstablished
|
case alreadyEstablished
|
||||||
case peerIdentityMismatch
|
case peerIdentityMismatch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The manager owns the exact attempt's one bounded recovery. Packet handling
|
||||||
|
/// must not launch its historical second, immediate restart for this failure.
|
||||||
|
struct NoiseManagedHandshakeFailure: Error {
|
||||||
|
let underlying: Error
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -70,8 +70,8 @@ final class BLENoisePacketHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true when the handshake message was processed successfully.
|
/// Returns true when the handshake message was processed successfully.
|
||||||
/// Callers use this to distinguish an authenticated replacement completion
|
/// Callers use this to distinguish an authenticated reconnect completion
|
||||||
/// from a rejected candidate while an older session remains established.
|
/// from a rejected ordinary responder while rollback state is restored.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||||
handleHandshakeWithResult(packet, from: peerID).processed
|
handleHandshakeWithResult(packet, from: peerID).processed
|
||||||
@@ -112,8 +112,16 @@ final class BLENoisePacketHandler {
|
|||||||
didEstablishAuthenticatedSession:
|
didEstablishAuthenticatedSession:
|
||||||
result.didEstablishAuthenticatedSession
|
result.didEstablishAuthenticatedSession
|
||||||
)
|
)
|
||||||
|
} catch let managedFailure as NoiseManagedHandshakeFailure {
|
||||||
|
SecureLogger.error(
|
||||||
|
"Failed to process handshake; manager owns recovery: \(managedFailure.underlying)"
|
||||||
|
)
|
||||||
|
return BLENoiseHandshakeHandlingResult(
|
||||||
|
processed: false,
|
||||||
|
didEstablishAuthenticatedSession: false
|
||||||
|
)
|
||||||
} catch NoiseSessionError.peerIdentityMismatch {
|
} catch NoiseSessionError.peerIdentityMismatch {
|
||||||
// The candidate was already discarded by the session manager.
|
// The responder was already discarded by the session manager.
|
||||||
// Do not let a spoofed claimed ID trigger a fresh outbound
|
// Do not let a spoofed claimed ID trigger a fresh outbound
|
||||||
// handshake or recreate state for the attacker-selected ID.
|
// handshake or recreate state for the attacker-selected ID.
|
||||||
SecureLogger.warning(
|
SecureLogger.warning(
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Bounds ordinary Noise revalidation to one attempt per physical-link epoch.
|
||||||
|
/// A live epoch may retry after the cooldown so a lost handshake cannot leave
|
||||||
|
/// the link permanently unauthenticated.
|
||||||
|
struct BLENoiseReconnectPolicy {
|
||||||
|
static let minimumRetryInterval: TimeInterval = 60
|
||||||
|
|
||||||
|
private var lastAttemptAt: [BLEIngressLinkID: Date] = [:]
|
||||||
|
|
||||||
|
mutating func shouldRevalidate(
|
||||||
|
on link: BLEIngressLinkID,
|
||||||
|
hasEstablishedSession: Bool,
|
||||||
|
isNoiseAuthenticatedLink: Bool,
|
||||||
|
hasAuthenticatedPeerLink: Bool,
|
||||||
|
now: Date
|
||||||
|
) -> Bool {
|
||||||
|
guard hasEstablishedSession,
|
||||||
|
!isNoiseAuthenticatedLink,
|
||||||
|
!hasAuthenticatedPeerLink else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if let previous = lastAttemptAt[link],
|
||||||
|
now.timeIntervalSince(previous) < Self.minimumRetryInterval {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lastAttemptAt[link] = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Link identifiers can be stable across CoreBluetooth reconnects, so a
|
||||||
|
/// disconnect explicitly starts a new epoch and permits one fresh attempt.
|
||||||
|
mutating func endLinkEpoch(_ link: BLEIngressLinkID) {
|
||||||
|
lastAttemptAt.removeValue(forKey: link)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
lastAttemptAt.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -241,6 +241,7 @@ final class BLEService: NSObject {
|
|||||||
// that the session was established *on this current ingress link*, not
|
// that the session was established *on this current ingress link*, not
|
||||||
// merely that some session exists for the claimed ID. bleQueue-owned.
|
// merely that some session exists for the claimed ID. bleQueue-owned.
|
||||||
private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:]
|
private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:]
|
||||||
|
private var noiseReconnectPolicy = BLENoiseReconnectPolicy()
|
||||||
|
|
||||||
// Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link
|
// Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link
|
||||||
// store): entries older than the cooldown are pruned on insert.
|
// store): entries older than the cooldown are pruned on insert.
|
||||||
@@ -311,6 +312,9 @@ final class BLEService: NSObject {
|
|||||||
/// May block in tests to hold the serial message queue immediately before
|
/// May block in tests to hold the serial message queue immediately before
|
||||||
/// the deferred private-media admission check.
|
/// the deferred private-media admission check.
|
||||||
var _test_beforePrivateMediaDeferredSend: ((String) -> Void)?
|
var _test_beforePrivateMediaDeferredSend: ((String) -> Void)?
|
||||||
|
/// May block announce handling after verified-link rebind work is queued.
|
||||||
|
/// Tests use this boundary to prove rebind and reconnect are serialized.
|
||||||
|
var _test_afterVerifiedDirectRebindEnqueued: (() -> Void)?
|
||||||
#endif
|
#endif
|
||||||
private var selfBroadcastTracker = BLESelfBroadcastTracker()
|
private var selfBroadcastTracker = BLESelfBroadcastTracker()
|
||||||
private let meshTopology = MeshTopologyTracker()
|
private let meshTopology = MeshTopologyTracker()
|
||||||
@@ -764,6 +768,8 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
bleQueue.sync {
|
bleQueue.sync {
|
||||||
pendingWriteBuffers.removeAll()
|
pendingWriteBuffers.removeAll()
|
||||||
|
noiseAuthenticatedLinkOwners.removeAll()
|
||||||
|
noiseReconnectPolicy.removeAll()
|
||||||
connectionScheduler.reset()
|
connectionScheduler.reset()
|
||||||
}
|
}
|
||||||
disconnectNotifyDebouncer.removeAll()
|
disconnectNotifyDebouncer.removeAll()
|
||||||
@@ -1061,6 +1067,7 @@ final class BLEService: NSObject {
|
|||||||
bleQueue.sync {
|
bleQueue.sync {
|
||||||
linkStateStore.clearAll()
|
linkStateStore.clearAll()
|
||||||
noiseAuthenticatedLinkOwners.removeAll()
|
noiseAuthenticatedLinkOwners.removeAll()
|
||||||
|
noiseReconnectPolicy.removeAll()
|
||||||
connectionScheduler.reset()
|
connectionScheduler.reset()
|
||||||
subscriptionAnnounceLimiter.removeAll()
|
subscriptionAnnounceLimiter.removeAll()
|
||||||
}
|
}
|
||||||
@@ -2617,6 +2624,7 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
for link in departedLinks {
|
for link in departedLinks {
|
||||||
noiseAuthenticatedLinkOwners.removeValue(forKey: link)
|
noiseAuthenticatedLinkOwners.removeValue(forKey: link)
|
||||||
|
noiseReconnectPolicy.endLinkEpoch(link)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = collectionsQueue.sync(flags: .barrier) {
|
_ = collectionsQueue.sync(flags: .barrier) {
|
||||||
@@ -2929,14 +2937,21 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
startScanning()
|
startScanning()
|
||||||
|
|
||||||
case .poweredOff:
|
case .poweredOff:
|
||||||
// Bluetooth was turned off - stop scanning and clean up connection state
|
// CoreBluetooth has already transitioned out of poweredOn. Do
|
||||||
|
// not issue stop/cancel commands now; they are rejected as API
|
||||||
|
// misuse. Retire our link state locally instead.
|
||||||
SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session)
|
SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session)
|
||||||
central.stopScan()
|
|
||||||
// Mark all peripheral connections as disconnected (they are now invalid)
|
|
||||||
let peripheralStates = linkStateStore.peripheralStates
|
let peripheralStates = linkStateStore.peripheralStates
|
||||||
let peerIDs: [PeerID] = peripheralStates.compactMap(\.peerID)
|
let peerIDs: [PeerID] = peripheralStates.compactMap(\.peerID)
|
||||||
for state in peripheralStates {
|
for state in peripheralStates {
|
||||||
central.cancelPeripheralConnection(state.peripheral)
|
let peripheralID = state.peripheral.identifier.uuidString
|
||||||
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
|
pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||||
|
}
|
||||||
|
noiseAuthenticatedLinkOwners.removeValue(
|
||||||
|
forKey: .peripheral(peripheralID)
|
||||||
|
)
|
||||||
|
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||||||
}
|
}
|
||||||
_ = linkStateStore.clearPeripherals()
|
_ = linkStateStore.clearPeripherals()
|
||||||
// Notify UI of disconnections
|
// Notify UI of disconnections
|
||||||
@@ -2949,7 +2964,6 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
case .unauthorized:
|
case .unauthorized:
|
||||||
// User denied Bluetooth permission
|
// User denied Bluetooth permission
|
||||||
SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session)
|
SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session)
|
||||||
central.stopScan()
|
|
||||||
_ = linkStateStore.clearPeripherals()
|
_ = linkStateStore.clearPeripherals()
|
||||||
|
|
||||||
case .unsupported:
|
case .unsupported:
|
||||||
@@ -3097,6 +3111,7 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
pendingPeripheralWrites.discardAll(for: peripheralID)
|
pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||||
}
|
}
|
||||||
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
||||||
|
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||||||
_ = linkStateStore.removePeripheral(peripheralID)
|
_ = linkStateStore.removePeripheral(peripheralID)
|
||||||
// A duplicate link can drop while the peer stays live on another
|
// A duplicate link can drop while the peer stays live on another
|
||||||
// (the dual-role central link, or a second bound link after a
|
// (the dual-role central link, or a second bound link after a
|
||||||
@@ -3151,6 +3166,7 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
pendingPeripheralWrites.discardAll(for: peripheralID)
|
pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||||
}
|
}
|
||||||
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
||||||
|
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||||||
_ = linkStateStore.removePeripheral(peripheralID)
|
_ = linkStateStore.removePeripheral(peripheralID)
|
||||||
|
|
||||||
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
|
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
|
||||||
@@ -3258,6 +3274,7 @@ extension BLEService {
|
|||||||
self.pendingPeripheralWrites.discardAll(for: peripheralID)
|
self.pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||||
}
|
}
|
||||||
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
||||||
|
self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||||||
_ = self.linkStateStore.removePeripheral(peripheralID)
|
_ = self.linkStateStore.removePeripheral(peripheralID)
|
||||||
self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date())
|
self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date())
|
||||||
self.tryConnectFromQueue()
|
self.tryConnectFromQueue()
|
||||||
@@ -3838,8 +3855,19 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
case .poweredOff:
|
case .poweredOff:
|
||||||
// Bluetooth was turned off - clean up peripheral state
|
// Bluetooth was turned off - clean up peripheral state
|
||||||
SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session)
|
SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session)
|
||||||
peripheral.stopAdvertising()
|
|
||||||
// Clear subscribed centrals (they are now invalid)
|
// Clear subscribed centrals (they are now invalid)
|
||||||
|
let centralSnapshot = linkStateStore.subscribedCentralSnapshot
|
||||||
|
for central in centralSnapshot.centrals {
|
||||||
|
let centralID = central.identifier.uuidString
|
||||||
|
noiseAuthenticatedLinkOwners.removeValue(
|
||||||
|
forKey: .central(centralID)
|
||||||
|
)
|
||||||
|
noiseReconnectPolicy.endLinkEpoch(.central(centralID))
|
||||||
|
}
|
||||||
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
|
pendingNotifications.removeAll()
|
||||||
|
pendingWriteBuffers.removeAll()
|
||||||
|
}
|
||||||
let centralPeerIDs = linkStateStore.clearCentrals()
|
let centralPeerIDs = linkStateStore.clearCentrals()
|
||||||
subscriptionAnnounceLimiter.removeAll()
|
subscriptionAnnounceLimiter.removeAll()
|
||||||
characteristic = nil
|
characteristic = nil
|
||||||
@@ -3853,7 +3881,6 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
case .unauthorized:
|
case .unauthorized:
|
||||||
// User denied Bluetooth permission
|
// User denied Bluetooth permission
|
||||||
SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session)
|
SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session)
|
||||||
peripheral.stopAdvertising()
|
|
||||||
_ = linkStateStore.clearCentrals()
|
_ = linkStateStore.clearCentrals()
|
||||||
subscriptionAnnounceLimiter.removeAll()
|
subscriptionAnnounceLimiter.removeAll()
|
||||||
characteristic = nil
|
characteristic = nil
|
||||||
@@ -3966,6 +3993,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
pendingNotifications.removeTarget { $0.identifier.uuidString == centralID }
|
pendingNotifications.removeTarget { $0.identifier.uuidString == centralID }
|
||||||
}
|
}
|
||||||
noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID))
|
noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID))
|
||||||
|
noiseReconnectPolicy.endLinkEpoch(.central(centralID))
|
||||||
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
|
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
|
||||||
|
|
||||||
// Ensure we're still advertising for other devices to find us
|
// Ensure we're still advertising for other devices to find us
|
||||||
@@ -4583,6 +4611,40 @@ extension BLEService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A peer-level session can outlive the physical link that established it.
|
||||||
|
/// Revalidate a fresh direct link with an ordinary XX exchange, retiring
|
||||||
|
/// cached sending keys atomically before message 1 can leave.
|
||||||
|
private func refreshNoiseSessionForVerifiedDirectLink(
|
||||||
|
_ packet: BitchatPacket,
|
||||||
|
peerID: PeerID
|
||||||
|
) {
|
||||||
|
guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasEstablishedSession = noiseService.hasEstablishedSession(with: peerID)
|
||||||
|
let authenticatedPeerLinks = currentNoiseAuthenticatedLinks(to: peerID)
|
||||||
|
let shouldRevalidate = readLinkState { store in
|
||||||
|
guard boundPeerID(for: link, in: store) == peerID else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return noiseReconnectPolicy.shouldRevalidate(
|
||||||
|
on: link,
|
||||||
|
hasEstablishedSession: hasEstablishedSession,
|
||||||
|
isNoiseAuthenticatedLink: noiseAuthenticatedLinkOwners[link] == peerID,
|
||||||
|
hasAuthenticatedPeerLink: !authenticatedPeerLinks.isEmpty,
|
||||||
|
now: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard shouldRevalidate else { return }
|
||||||
|
|
||||||
|
SecureLogger.info(
|
||||||
|
"🔄 Revalidating cached Noise session on fresh direct link to \(peerID.id.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
initiateNoiseReconnectHandshake(with: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
||||||
service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in
|
service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in
|
||||||
@@ -4595,13 +4657,94 @@ extension BLEService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
service.onRekeyHandshakeReady = { [weak self] peerID, message in
|
service.onRekeyHandshakeReady = {
|
||||||
self?.messageQueue.async { [weak self] in
|
[weak self, weak service] peerID, initiation in
|
||||||
guard let self else { return }
|
self?.messageQueue.async(flags: .barrier) {
|
||||||
|
[weak self, weak service] in
|
||||||
|
guard let self,
|
||||||
|
let service,
|
||||||
|
self.noiseService === service else {
|
||||||
|
return
|
||||||
|
}
|
||||||
self.noteNoiseSessionCleared(for: peerID)
|
self.noteNoiseSessionCleared(for: peerID)
|
||||||
|
guard let message = service.claimHandshakeInitiation(
|
||||||
|
initiation,
|
||||||
|
for: peerID
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
self.broadcastNoiseHandshake(message, to: peerID)
|
self.broadcastNoiseHandshake(message, to: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
service.onHandshakeRecoveryRequired = {
|
||||||
|
[weak self, weak service] request in
|
||||||
|
guard let self, let service else { return }
|
||||||
|
self.messageQueue.async(flags: .barrier) {
|
||||||
|
[weak self, weak service] in
|
||||||
|
guard let self,
|
||||||
|
let service,
|
||||||
|
self.noiseService === service else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let peerID = request.peerID
|
||||||
|
guard self.isPeerReachable(peerID) else {
|
||||||
|
service.cancelHandshakeRecovery(request)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
guard let preparation =
|
||||||
|
try service.prepareHandshakeRecovery(request) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch preparation {
|
||||||
|
case .ordinary(let initiation):
|
||||||
|
self.noteNoiseSessionCleared(for: peerID)
|
||||||
|
guard let handshakeData =
|
||||||
|
service.claimHandshakeInitiation(
|
||||||
|
initiation,
|
||||||
|
for: peerID
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.broadcastNoiseHandshake(
|
||||||
|
handshakeData,
|
||||||
|
to: peerID
|
||||||
|
)
|
||||||
|
case .transferred:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error(
|
||||||
|
"Failed to prepare handshake recovery with \(peerID.id.prefix(8))…: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation in
|
||||||
|
guard let self, let service else { return }
|
||||||
|
self.messageQueue.async { [weak self, weak service] in
|
||||||
|
guard let self,
|
||||||
|
let service,
|
||||||
|
self.noiseService === service,
|
||||||
|
let fingerprint = service.getPeerFingerprint(peerID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug(
|
||||||
|
"🔐 Restored quarantined Noise session with \(peerID.id.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
// Re-enter the same generation-bound transition used after a
|
||||||
|
// successful handshake. This restores authenticated protocol
|
||||||
|
// state and drains both PM and typed-payload queues.
|
||||||
|
self.handleNoisePeerAuthenticated(
|
||||||
|
peerID: peerID,
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
sessionGeneration: generation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleNoisePeerAuthenticated(
|
private func handleNoisePeerAuthenticated(
|
||||||
@@ -4855,8 +4998,9 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard noiseService.hasSession(with: peerID) else {
|
guard noiseService.hasEstablishedSession(with: peerID) else {
|
||||||
// No session yet - queue the payload SYNCHRONOUSLY before initiating handshake
|
// No established session yet - queue the payload synchronously
|
||||||
|
// before initiating a handshake
|
||||||
// to prevent race where fast handshake completion drains empty queue
|
// to prevent race where fast handshake completion drains empty queue
|
||||||
collectionsQueue.sync(flags: .barrier) {
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
self.pendingNoiseSessionQueues.appendTypedPayload(typedPayload, for: peerID)
|
self.pendingNoiseSessionQueues.appendTypedPayload(typedPayload, for: peerID)
|
||||||
@@ -5786,6 +5930,7 @@ extension BLEService {
|
|||||||
self.pendingPeripheralWrites.discardAll(for: peripheralID)
|
self.pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||||
}
|
}
|
||||||
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
||||||
|
self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||||||
_ = self.linkStateStore.removePeripheral(peripheralID)
|
_ = self.linkStateStore.removePeripheral(peripheralID)
|
||||||
cancelled += 1
|
cancelled += 1
|
||||||
}
|
}
|
||||||
@@ -5851,12 +5996,27 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func initiateNoiseHandshake(with peerID: PeerID) {
|
private func initiateNoiseHandshake(with peerID: PeerID) {
|
||||||
// Use NoiseEncryptionService for handshake
|
let service = noiseService
|
||||||
guard !noiseService.hasSession(with: peerID) else { return }
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let handshakeData = try noiseService.initiateHandshake(with: peerID)
|
guard let initiation = try service.initiateHandshakeIfNeeded(
|
||||||
broadcastNoiseHandshake(handshakeData, to: peerID)
|
with: peerID,
|
||||||
|
retryOnTimeout: true
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messageQueue.async(flags: .barrier) {
|
||||||
|
[weak self, weak service] in
|
||||||
|
guard let self,
|
||||||
|
let service,
|
||||||
|
self.noiseService === service,
|
||||||
|
let handshakeData = service.claimHandshakeInitiation(
|
||||||
|
initiation,
|
||||||
|
for: peerID
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.broadcastNoiseHandshake(handshakeData, to: peerID)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Failed to initiate handshake: \(error)")
|
SecureLogger.error("Failed to initiate handshake: \(error)")
|
||||||
}
|
}
|
||||||
@@ -5874,6 +6034,42 @@ extension BLEService {
|
|||||||
)
|
)
|
||||||
broadcastPacket(packet)
|
broadcastPacket(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Starts a wire-compatible ordinary XX reconnect. The manager prepares
|
||||||
|
/// the initiator before atomically retiring the cached transport; the
|
||||||
|
/// one-shot claim prevents a crossed inbound message from making a stale
|
||||||
|
/// message 1 leave after this peer has already become responder.
|
||||||
|
private func initiateNoiseReconnectHandshake(with peerID: PeerID) {
|
||||||
|
let service = noiseService
|
||||||
|
do {
|
||||||
|
let initiation = try service.initiateReconnectHandshake(
|
||||||
|
with: peerID,
|
||||||
|
retryOnTimeout: true
|
||||||
|
)
|
||||||
|
messageQueue.async(flags: .barrier) { [weak self, weak service] in
|
||||||
|
guard let self,
|
||||||
|
let service,
|
||||||
|
self.noiseService === service else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.noteNoiseSessionCleared(for: peerID)
|
||||||
|
guard let handshakeData = service.claimHandshakeInitiation(
|
||||||
|
initiation,
|
||||||
|
for: peerID
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.broadcastNoiseHandshake(handshakeData, to: peerID)
|
||||||
|
}
|
||||||
|
} catch NoiseSessionError.notEstablished {
|
||||||
|
initiateNoiseHandshake(with: peerID)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error(
|
||||||
|
"Failed to initiate ordinary reconnect: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func sendPendingMessagesAfterHandshake(for peerID: PeerID) {
|
private func sendPendingMessagesAfterHandshake(for peerID: PeerID) {
|
||||||
// Atomically take all pending messages to process (prevents concurrent modification)
|
// Atomically take all pending messages to process (prevents concurrent modification)
|
||||||
@@ -6429,6 +6625,9 @@ extension BLEService {
|
|||||||
// consolidate duplicate same-role connections onto that link.
|
// consolidate duplicate same-role connections onto that link.
|
||||||
if let result, result.isVerified, result.isDirectAnnounce {
|
if let result, result.isVerified, result.isDirectAnnounce {
|
||||||
rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID)
|
rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID)
|
||||||
|
#if DEBUG
|
||||||
|
_test_afterVerifiedDirectRebindEnqueued?()
|
||||||
|
#endif
|
||||||
retireRedundantPeripheralLinks(packet, to: result.peerID)
|
retireRedundantPeripheralLinks(packet, to: result.peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6462,11 +6661,9 @@ extension BLEService {
|
|||||||
deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
|
deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
|
||||||
if result.isDirectAnnounce,
|
if result.isDirectAnnounce,
|
||||||
!hasCurrentNoiseAuthenticatedLink(to: result.peerID) {
|
!hasCurrentNoiseAuthenticatedLink(to: result.peerID) {
|
||||||
if noiseService.hasEstablishedSession(with: result.peerID) {
|
// A cached session may predate this physical link.
|
||||||
// A session with no surviving authenticated link is stale;
|
// rebindLinkAfterVerifiedDirectAnnounce performs its atomic
|
||||||
// force the current link to prove possession again.
|
// ordinary reconnect after the binding is published.
|
||||||
clearNoiseSession(for: result.peerID)
|
|
||||||
}
|
|
||||||
if !noiseService.hasSession(with: result.peerID) {
|
if !noiseService.hasSession(with: result.peerID) {
|
||||||
initiateNoiseHandshake(with: result.peerID)
|
initiateNoiseHandshake(with: result.peerID)
|
||||||
}
|
}
|
||||||
@@ -6495,7 +6692,14 @@ extension BLEService {
|
|||||||
linkUUID = centralUUID
|
linkUUID = centralUUID
|
||||||
previousPeerID = self.linkStateStore.peerID(forCentralUUID: centralUUID)
|
previousPeerID = self.linkStateStore.peerID(forCentralUUID: centralUUID)
|
||||||
}
|
}
|
||||||
guard let previousPeerID, previousPeerID != peerID else { return }
|
guard let previousPeerID else { return }
|
||||||
|
guard previousPeerID != peerID else {
|
||||||
|
self.refreshNoiseSessionForVerifiedDirectLink(
|
||||||
|
packet,
|
||||||
|
peerID: peerID
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// The signature does not authenticate directness (TTL is excluded
|
// The signature does not authenticate directness (TTL is excluded
|
||||||
// from signing because relays mutate it), so a "verified direct"
|
// from signing because relays mutate it), so a "verified direct"
|
||||||
@@ -6522,12 +6726,20 @@ extension BLEService {
|
|||||||
// it across an announce-driven rebind, whose direct TTL is
|
// it across an announce-driven rebind, whose direct TTL is
|
||||||
// replayable; the new owner must complete a fresh handshake.
|
// replayable; the new owner must complete a fresh handshake.
|
||||||
self.noiseAuthenticatedLinkOwners.removeValue(forKey: link)
|
self.noiseAuthenticatedLinkOwners.removeValue(forKey: link)
|
||||||
|
self.noiseReconnectPolicy.endLinkEpoch(link)
|
||||||
switch link {
|
switch link {
|
||||||
case .peripheral(let peripheralUUID):
|
case .peripheral(let peripheralUUID):
|
||||||
self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID)
|
self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID)
|
||||||
case .central(let centralUUID):
|
case .central(let centralUUID):
|
||||||
self.linkStateStore.bindCentral(centralUUID, to: peerID)
|
self.linkStateStore.bindCentral(centralUUID, to: peerID)
|
||||||
}
|
}
|
||||||
|
// Keep the rebind and reconnect decision in one bleQueue critical
|
||||||
|
// section. No observer may see the new binding while a cached
|
||||||
|
// peer-level sender is still considered established.
|
||||||
|
self.refreshNoiseSessionForVerifiedDirectLink(
|
||||||
|
packet,
|
||||||
|
peerID: peerID
|
||||||
|
)
|
||||||
SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))…", category: .session)
|
SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))…", category: .session)
|
||||||
self.refreshLocalTopology()
|
self.refreshLocalTopology()
|
||||||
// The announce that triggered this rebind was upserted as
|
// The announce that triggered this rebind was upserted as
|
||||||
@@ -6619,6 +6831,7 @@ extension BLEService {
|
|||||||
pendingPeripheralWrites.discardAll(for: uuid)
|
pendingPeripheralWrites.discardAll(for: uuid)
|
||||||
}
|
}
|
||||||
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid))
|
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid))
|
||||||
|
noiseReconnectPolicy.endLinkEpoch(.peripheral(uuid))
|
||||||
_ = linkStateStore.removePeripheral(uuid)
|
_ = linkStateStore.removePeripheral(uuid)
|
||||||
SecureLogger.info(
|
SecureLogger.info(
|
||||||
"🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))…\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")",
|
"🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))…\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")",
|
||||||
@@ -7001,10 +7214,16 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
|
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
|
||||||
let result = noisePacketHandler.handleHandshakeWithResult(
|
let result = noisePacketHandler.handleHandshakeWithResult(
|
||||||
packet,
|
packet,
|
||||||
from: peerID
|
from: peerID
|
||||||
)
|
)
|
||||||
|
let isEstablished = noiseService.hasEstablishedSession(with: peerID)
|
||||||
|
if wasEstablished, result.processed,
|
||||||
|
!isEstablished {
|
||||||
|
noteNoiseSessionCleared(for: peerID)
|
||||||
|
}
|
||||||
if result.didEstablishAuthenticatedSession {
|
if result.didEstablishAuthenticatedSession {
|
||||||
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
|
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,11 +184,17 @@ final class NoiseEncryptionService {
|
|||||||
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||||
private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = []
|
private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = []
|
||||||
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||||
/// Automatic rekey removed the old session and produced XX message 1.
|
/// Automatic rekey prepared XX message 1. The transport must claim the
|
||||||
/// The transport must clear session-scoped state and put these exact bytes
|
/// exact attempt at its actual BLE handoff; a crossed inbound initiation
|
||||||
/// on the wire; merely reporting "handshake required" strands the partial
|
/// can invalidate the token before that point.
|
||||||
/// initiator session because a second initiate call sees it already exists.
|
var onRekeyHandshakeReady:
|
||||||
var onRekeyHandshakeReady: ((_ peerID: PeerID, _ message: Data) -> Void)?
|
((_ peerID: PeerID, _ initiation: NoiseHandshakeInitiation) -> Void)?
|
||||||
|
var onHandshakeRecoveryRequired:
|
||||||
|
((_ request: NoiseHandshakeRecoveryRequest) -> Void)?
|
||||||
|
/// An unauthenticated reconnect attempt failed or timed out and the
|
||||||
|
/// receive-only rollback session became the active transport again.
|
||||||
|
/// Transport queues must be drained for this exact restored generation.
|
||||||
|
var onSessionRestoredWithGeneration: ((_ peerID: PeerID, _ generation: UUID) -> Void)?
|
||||||
|
|
||||||
// Add a handler for peer authentication
|
// Add a handler for peer authentication
|
||||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
||||||
@@ -219,7 +225,17 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol) {
|
init(
|
||||||
|
keychain: KeychainManagerProtocol,
|
||||||
|
ordinaryHandshakeTimeout: TimeInterval =
|
||||||
|
NoiseSecurityConstants.ordinaryHandshakeTimeout,
|
||||||
|
ordinaryResponderHandshakeTimeout: TimeInterval =
|
||||||
|
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
|
||||||
|
recentInitiatorCompletionGracePeriod: TimeInterval =
|
||||||
|
NoiseSecurityConstants.recentInitiatorCompletionGracePeriod,
|
||||||
|
ordinaryReconnectRollbackCooldown: TimeInterval =
|
||||||
|
NoiseSecurityConstants.ordinaryReconnectRollbackCooldown
|
||||||
|
) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
|
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
|
||||||
|
|
||||||
@@ -309,7 +325,17 @@ final class NoiseEncryptionService {
|
|||||||
self.signingPublicKey = signingKey.publicKey
|
self.signingPublicKey = signingKey.publicKey
|
||||||
|
|
||||||
// Initialize session manager
|
// Initialize session manager
|
||||||
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
self.sessionManager = NoiseSessionManager(
|
||||||
|
localStaticKey: staticIdentityKey,
|
||||||
|
keychain: keychain,
|
||||||
|
ordinaryHandshakeTimeout: ordinaryHandshakeTimeout,
|
||||||
|
ordinaryResponderHandshakeTimeout:
|
||||||
|
ordinaryResponderHandshakeTimeout,
|
||||||
|
recentInitiatorCompletionGracePeriod:
|
||||||
|
recentInitiatorCompletionGracePeriod,
|
||||||
|
ordinaryReconnectRollbackCooldown:
|
||||||
|
ordinaryReconnectRollbackCooldown
|
||||||
|
)
|
||||||
|
|
||||||
// Set up session callbacks
|
// Set up session callbacks
|
||||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in
|
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in
|
||||||
@@ -319,6 +345,12 @@ final class NoiseEncryptionService {
|
|||||||
sessionGeneration: generation
|
sessionGeneration: generation
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
sessionManager.onSessionRestored = { [weak self] peerID, generation in
|
||||||
|
self?.onSessionRestoredWithGeneration?(peerID, generation)
|
||||||
|
}
|
||||||
|
sessionManager.onHandshakeRecoveryRequired = { [weak self] request in
|
||||||
|
self?.onHandshakeRecoveryRequired?(request)
|
||||||
|
}
|
||||||
|
|
||||||
// Start session maintenance timer
|
// Start session maintenance timer
|
||||||
startRekeyTimer()
|
startRekeyTimer()
|
||||||
@@ -682,6 +714,90 @@ final class NoiseEncryptionService {
|
|||||||
let handshakeData = try sessionManager.initiateHandshake(with: peerID)
|
let handshakeData = try sessionManager.initiateHandshake(with: peerID)
|
||||||
return handshakeData
|
return handshakeData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Atomically admits and prepares one initial ordinary handshake. Returns
|
||||||
|
/// nil when another discovery callback already created a session.
|
||||||
|
func initiateHandshakeIfNeeded(
|
||||||
|
with peerID: PeerID,
|
||||||
|
retryOnTimeout: Bool = false
|
||||||
|
) throws -> NoiseHandshakeInitiation? {
|
||||||
|
guard peerID.isValid else {
|
||||||
|
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||||
|
throw NoiseSecurityError.invalidPeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let initiation = try sessionManager.initiateHandshakeIfAbsent(
|
||||||
|
with: peerID,
|
||||||
|
notifyOnTimeout: retryOnTimeout,
|
||||||
|
authorize: { [rateLimiter] in
|
||||||
|
guard rateLimiter.allowHandshake(from: peerID) else {
|
||||||
|
SecureLogger.warning(
|
||||||
|
.authenticationFailed(peerID: "Rate limited: \(peerID)")
|
||||||
|
)
|
||||||
|
throw NoiseSecurityError.rateLimitExceeded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
SecureLogger.info(.handshakeStarted(peerID: peerID.id))
|
||||||
|
return initiation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically prepares an ordinary reconnect for a peer whose cached
|
||||||
|
/// transport belongs to an earlier physical link. Failed authorization or
|
||||||
|
/// handshake setup preserves the established session.
|
||||||
|
func initiateReconnectHandshake(
|
||||||
|
with peerID: PeerID,
|
||||||
|
retryOnTimeout: Bool = false
|
||||||
|
) throws -> NoiseHandshakeInitiation {
|
||||||
|
guard peerID.isValid else {
|
||||||
|
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||||
|
throw NoiseSecurityError.invalidPeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
return try sessionManager.initiateReconnectHandshake(
|
||||||
|
with: peerID,
|
||||||
|
notifyOnTimeout: retryOnTimeout,
|
||||||
|
authorize: { [rateLimiter] in
|
||||||
|
guard rateLimiter.allowHandshake(from: peerID) else {
|
||||||
|
SecureLogger.warning(
|
||||||
|
.authenticationFailed(peerID: "Rate limited: \(peerID)")
|
||||||
|
)
|
||||||
|
throw NoiseSecurityError.rateLimitExceeded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareHandshakeRecovery(
|
||||||
|
_ request: NoiseHandshakeRecoveryRequest
|
||||||
|
) throws -> NoiseHandshakeRecoveryPreparation? {
|
||||||
|
try sessionManager.prepareHandshakeRecovery(
|
||||||
|
request,
|
||||||
|
authorizeAttempt: { [rateLimiter] in
|
||||||
|
guard rateLimiter.allowHandshake(from: request.peerID) else {
|
||||||
|
SecureLogger.warning(
|
||||||
|
.authenticationFailed(
|
||||||
|
peerID: "Rate limited: \(request.peerID)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw NoiseSecurityError.rateLimitExceeded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelHandshakeRecovery(_ request: NoiseHandshakeRecoveryRequest) {
|
||||||
|
sessionManager.cancelHandshakeRecovery(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func claimHandshakeInitiation(
|
||||||
|
_ initiation: NoiseHandshakeInitiation,
|
||||||
|
for peerID: PeerID
|
||||||
|
) -> Data? {
|
||||||
|
sessionManager.claimHandshakeInitiation(initiation, for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
/// Process an incoming handshake message
|
/// Process an incoming handshake message
|
||||||
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
|
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
|
||||||
@@ -819,8 +935,10 @@ final class NoiseEncryptionService {
|
|||||||
throw NoiseSecurityError.rateLimitExceeded
|
throw NoiseSecurityError.rateLimitExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have an established session
|
// A quarantined transport is deliberately unavailable for outbound
|
||||||
guard hasEstablishedSession(with: peerID) else {
|
// state, but remains receive-only until the responder proves identity
|
||||||
|
// or the bounded rollback restores it.
|
||||||
|
guard sessionManager.hasReceiveSession(for: peerID) else {
|
||||||
throw NoiseEncryptionError.sessionNotEstablished
|
throw NoiseEncryptionError.sessionNotEstablished
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -943,9 +1061,9 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func initiateAutomaticRekey(for peerID: PeerID) throws {
|
private func initiateAutomaticRekey(for peerID: PeerID) throws {
|
||||||
let handshakeMessage = try sessionManager.initiateRekey(for: peerID)
|
let initiation = try sessionManager.initiateRekey(for: peerID)
|
||||||
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
|
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
|
||||||
onRekeyHandshakeReady?(peerID, handshakeMessage)
|
onRekeyHandshakeReady?(peerID, initiation)
|
||||||
onHandshakeRequired?(peerID)
|
onHandshakeRequired?(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -502,14 +502,26 @@ struct BLEServiceCoreTests {
|
|||||||
)
|
)
|
||||||
let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce")
|
let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce")
|
||||||
#expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink))
|
#expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink))
|
||||||
|
let rebindGate = VerifiedDirectRebindGate()
|
||||||
|
ble._test_afterVerifiedDirectRebindEnqueued = rebindGate.pause
|
||||||
|
defer {
|
||||||
|
rebindGate.release()
|
||||||
|
ble._test_afterVerifiedDirectRebindEnqueued = nil
|
||||||
|
}
|
||||||
ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false)
|
ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false)
|
||||||
|
|
||||||
let rebound = await TestHelpers.waitUntil(
|
let announcePaused = await TestHelpers.waitUntil(
|
||||||
{ ble._test_centralBinding(attackerLink) == victimPeerID },
|
{ rebindGate.hasPaused },
|
||||||
timeout: TestConstants.longTimeout
|
timeout: TestConstants.longTimeout
|
||||||
)
|
)
|
||||||
#expect(rebound)
|
try #require(announcePaused)
|
||||||
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
|
||||||
|
// Rebind and ordinary reconnect preparation are one bleQueue
|
||||||
|
// critical section. Once the binding is visible, stale sending keys
|
||||||
|
// must already be unavailable.
|
||||||
|
#expect(ble._test_centralBinding(attackerLink) == victimPeerID)
|
||||||
|
#expect(!ble.canDeliverSecurely(to: victimPeerID))
|
||||||
|
rebindGate.release()
|
||||||
|
|
||||||
let outbound = OutboundPacketTap()
|
let outbound = OutboundPacketTap()
|
||||||
ble._test_onOutboundPacket = { outbound.record($0) }
|
ble._test_onOutboundPacket = { outbound.record($0) }
|
||||||
@@ -537,20 +549,26 @@ struct BLEServiceCoreTests {
|
|||||||
|
|
||||||
// Preserve a working victim session while an unauthenticated
|
// Preserve a working victim session while an unauthenticated
|
||||||
// replacement candidate arrives on a newly bound physical link.
|
// replacement candidate arrives on a newly bound physical link.
|
||||||
let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID)
|
// Establish BLE as responder so the replacement candidate below is
|
||||||
|
// not coalesced by the initiator-completion grace path.
|
||||||
|
let message1 = try victim.initiateHandshake(with: ble.myPeerID)
|
||||||
let message2 = try #require(
|
let message2 = try #require(
|
||||||
try victim.processHandshakeMessage(from: ble.myPeerID, message: message1)
|
|
||||||
)
|
|
||||||
let message3 = try #require(
|
|
||||||
try ble._test_noiseProcessHandshakeMessage(
|
try ble._test_noiseProcessHandshakeMessage(
|
||||||
from: victimPeerID,
|
from: victimPeerID,
|
||||||
|
message: message1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try victim.processHandshakeMessage(
|
||||||
|
from: ble.myPeerID,
|
||||||
message: message2
|
message: message2
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
_ = try victim.processHandshakeMessage(
|
_ = try ble._test_noiseProcessHandshakeMessage(
|
||||||
from: ble.myPeerID,
|
from: victimPeerID,
|
||||||
message: message3
|
message: message3
|
||||||
)
|
)
|
||||||
|
await ble._test_drainNoiseMessagePipeline()
|
||||||
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
||||||
|
|
||||||
let centralUUID = "central-replacement-xx-message-one"
|
let centralUUID = "central-replacement-xx-message-one"
|
||||||
@@ -614,7 +632,134 @@ struct BLEServiceCoreTests {
|
|||||||
for: victimPeerID
|
for: victimPeerID
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
// Ordinary reconnect hardening quarantines the cached transport while
|
||||||
|
// this candidate proves the claimed identity. It must be unavailable
|
||||||
|
// for sending as well as unable to authenticate this ingress link.
|
||||||
|
#expect(!ble.canDeliverSecurely(to: victimPeerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func failedInboundReconnectRestoresAndDrainsTypedPayloadQueue() async throws {
|
||||||
|
let ble = makeService()
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
|
||||||
|
// Establish BLE as responder so the following inbound reconnect is
|
||||||
|
// not intentionally coalesced by the initiator-completion grace path.
|
||||||
|
let message1 = try alice.initiateHandshake(with: ble.myPeerID)
|
||||||
|
let message2 = try #require(
|
||||||
|
try ble._test_noiseProcessHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: message1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try alice.processHandshakeMessage(
|
||||||
|
from: ble.myPeerID,
|
||||||
|
message: message2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ = try ble._test_noiseProcessHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: message3
|
||||||
|
)
|
||||||
|
await ble._test_drainNoiseMessagePipeline()
|
||||||
|
#expect(ble.canDeliverSecurely(to: alicePeerID))
|
||||||
|
|
||||||
|
let outbound = OutboundPacketTap()
|
||||||
|
ble._test_onOutboundPacket = outbound.record
|
||||||
|
let forgedMessage1 = try mallory.initiateHandshake(with: ble.myPeerID)
|
||||||
|
let firstPacket = BitchatPacket(
|
||||||
|
type: MessageType.noiseHandshake.rawValue,
|
||||||
|
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||||
|
recipientID: Data(hexString: ble.myPeerID.id),
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000),
|
||||||
|
payload: forgedMessage1,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 7
|
||||||
|
)
|
||||||
|
ble._test_handlePacket(firstPacket, fromPeerID: alicePeerID)
|
||||||
|
|
||||||
|
let responseReady = await TestHelpers.waitUntil(
|
||||||
|
{
|
||||||
|
outbound.snapshot().contains {
|
||||||
|
$0.type == MessageType.noiseHandshake.rawValue
|
||||||
|
&& PeerID(hexData: $0.senderID) == ble.myPeerID
|
||||||
|
&& $0.payload.count
|
||||||
|
!= NoiseSecurityConstants.xxInitialMessageSize
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
try #require(responseReady)
|
||||||
|
let forgedMessage2 = try #require(
|
||||||
|
outbound.snapshot().first {
|
||||||
|
$0.type == MessageType.noiseHandshake.rawValue
|
||||||
|
&& PeerID(hexData: $0.senderID) == ble.myPeerID
|
||||||
|
&& $0.payload.count
|
||||||
|
!= NoiseSecurityConstants.xxInitialMessageSize
|
||||||
|
}?.payload
|
||||||
|
)
|
||||||
|
#expect(!ble.canDeliverSecurely(to: alicePeerID))
|
||||||
|
|
||||||
|
// Typed control traffic must queue behind the ordinary responder,
|
||||||
|
// rather than attempting encryption and disappearing.
|
||||||
|
let privateMessageID = "quarantine-pm-\(UUID().uuidString)"
|
||||||
|
ble.sendPrivateMessage(
|
||||||
|
"queued private message",
|
||||||
|
to: alicePeerID,
|
||||||
|
recipientNickname: "Alice",
|
||||||
|
messageID: privateMessageID
|
||||||
|
)
|
||||||
|
ble.sendGroupInvite(Data("queued-during-quarantine".utf8), to: alicePeerID)
|
||||||
|
await ble._test_drainNoiseMessagePipeline()
|
||||||
|
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
|
||||||
|
|
||||||
|
let forgedMessage3 = try #require(
|
||||||
|
try mallory.processHandshakeMessage(
|
||||||
|
from: ble.myPeerID,
|
||||||
|
message: forgedMessage2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let thirdPacket = BitchatPacket(
|
||||||
|
type: MessageType.noiseHandshake.rawValue,
|
||||||
|
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||||
|
recipientID: Data(hexString: ble.myPeerID.id),
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 1,
|
||||||
|
payload: forgedMessage3,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 7
|
||||||
|
)
|
||||||
|
ble._test_handlePacket(thirdPacket, fromPeerID: alicePeerID)
|
||||||
|
|
||||||
|
// Restore re-enters the generation-bound authentication transition:
|
||||||
|
// authenticated state and both outbound queues drain exactly once.
|
||||||
|
let drained = await TestHelpers.waitUntil(
|
||||||
|
{ outbound.count(ofType: .noiseEncrypted) >= 3 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
try #require(drained)
|
||||||
|
await ble._test_drainNoiseMessagePipeline()
|
||||||
|
let plaintexts = try outbound.snapshot()
|
||||||
|
.filter { $0.type == MessageType.noiseEncrypted.rawValue }
|
||||||
|
.map { try alice.decrypt($0.payload, from: ble.myPeerID) }
|
||||||
|
#expect(plaintexts.count == 3)
|
||||||
|
#expect(
|
||||||
|
plaintexts.filter {
|
||||||
|
$0.first == NoisePayloadType.authenticatedPeerState.rawValue
|
||||||
|
}.count == 1
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
plaintexts.filter {
|
||||||
|
$0.first == NoisePayloadType.privateMessage.rawValue
|
||||||
|
}.count == 1
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
plaintexts.filter {
|
||||||
|
$0.first == NoisePayloadType.groupInvite.rawValue
|
||||||
|
}.count == 1
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A legitimate rotation announce necessarily arrives on a link still
|
/// A legitimate rotation announce necessarily arrives on a link still
|
||||||
@@ -943,6 +1088,40 @@ private final class OutboundPacketTap {
|
|||||||
lock.lock(); defer { lock.unlock() }
|
lock.lock(); defer { lock.unlock() }
|
||||||
return packets.filter { $0.type == type.rawValue }.count
|
return packets.filter { $0.type == type.rawValue }.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func snapshot() -> [BitchatPacket] {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
return packets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class VerifiedDirectRebindGate: @unchecked Sendable {
|
||||||
|
private let condition = NSCondition()
|
||||||
|
private var paused = false
|
||||||
|
private var released = false
|
||||||
|
|
||||||
|
var hasPaused: Bool {
|
||||||
|
condition.lock()
|
||||||
|
defer { condition.unlock() }
|
||||||
|
return paused
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
condition.lock()
|
||||||
|
paused = true
|
||||||
|
condition.broadcast()
|
||||||
|
while !released {
|
||||||
|
condition.wait()
|
||||||
|
}
|
||||||
|
condition.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func release() {
|
||||||
|
condition.lock()
|
||||||
|
released = true
|
||||||
|
condition.broadcast()
|
||||||
|
condition.unlock()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class ReceivePacketHandoffGate: @unchecked Sendable {
|
private final class ReceivePacketHandoffGate: @unchecked Sendable {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Testing
|
|||||||
@testable import BitFoundation // to avoid unnecessary public's
|
@testable import BitFoundation // to avoid unnecessary public's
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite("Integration Tests", .serialized)
|
||||||
struct IntegrationTests {
|
struct IntegrationTests {
|
||||||
|
|
||||||
private var helper = TestNetworkHelper()
|
private var helper = TestNetworkHelper()
|
||||||
@@ -272,8 +273,18 @@ struct IntegrationTests {
|
|||||||
// Re-establish Noise handshake explicitly via managers
|
// Re-establish Noise handshake explicitly via managers
|
||||||
do {
|
do {
|
||||||
let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID)
|
let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID)
|
||||||
let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)!
|
let m2 = try #require(
|
||||||
let m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)!
|
try helper.noiseManagers["Alice"]!.handleIncomingHandshake(
|
||||||
|
from: helper.nodes["Bob"]!.peerID,
|
||||||
|
message: m1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let m3 = try #require(
|
||||||
|
try helper.noiseManagers["Bob"]!.handleIncomingHandshake(
|
||||||
|
from: helper.nodes["Alice"]!.peerID,
|
||||||
|
message: m2
|
||||||
|
)
|
||||||
|
)
|
||||||
_ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3)
|
_ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3)
|
||||||
} catch {
|
} catch {
|
||||||
Issue.record("Failed to re-establish Noise session after restart: \(error)")
|
Issue.record("Failed to re-establish Noise session after restart: \(error)")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import Testing
|
||||||
@testable import BitFoundation // to avoid unnecessary public's
|
@testable import BitFoundation // to avoid unnecessary public's
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
@@ -27,9 +28,14 @@ final class TestNetworkHelper {
|
|||||||
node.mockNickname = name
|
node.mockNickname = name
|
||||||
nodes[name] = node
|
nodes[name] = node
|
||||||
|
|
||||||
// Create/replace Noise manager for this node
|
// This synchronous helper directly drives all three XX messages and
|
||||||
|
// has no transport callback loop for delayed collision recovery.
|
||||||
let key = Curve25519.KeyAgreement.PrivateKey()
|
let key = Curve25519.KeyAgreement.PrivateKey()
|
||||||
noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)
|
noiseManagers[name] = NoiseSessionManager(
|
||||||
|
localStaticKey: key,
|
||||||
|
keychain: mockKeychain,
|
||||||
|
recentInitiatorCompletionGracePeriod: 0
|
||||||
|
)
|
||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,8 +114,18 @@ final class TestNetworkHelper {
|
|||||||
let peer2ID = nodes[node2]?.peerID else { return }
|
let peer2ID = nodes[node2]?.peerID else { return }
|
||||||
|
|
||||||
let msg1 = try manager1.initiateHandshake(with: peer2ID)
|
let msg1 = try manager1.initiateHandshake(with: peer2ID)
|
||||||
let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)!
|
let msg2 = try #require(
|
||||||
let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)!
|
try manager2.handleIncomingHandshake(
|
||||||
|
from: peer1ID,
|
||||||
|
message: msg1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let msg3 = try #require(
|
||||||
|
try manager1.handleIncomingHandshake(
|
||||||
|
from: peer2ID,
|
||||||
|
message: msg2
|
||||||
|
)
|
||||||
|
)
|
||||||
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
|
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import BitFoundation
|
|||||||
|
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
@Suite("Noise Coverage Tests")
|
@Suite("Noise Coverage Tests", .serialized)
|
||||||
struct NoiseCoverageTests {
|
struct NoiseCoverageTests {
|
||||||
private let keychain = MockKeychain()
|
private let keychain = MockKeychain()
|
||||||
private let aliceStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
private let aliceStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
@@ -633,8 +633,16 @@ struct NoiseCoverageTests {
|
|||||||
)
|
)
|
||||||
let replacementSession = try #require(manager.getSession(for: alicePeerID))
|
let replacementSession = try #require(manager.getSession(for: alicePeerID))
|
||||||
|
|
||||||
#expect(replacementResponse != nil)
|
let localPeerID = PeerID(
|
||||||
#expect(replacementSession !== restartedSession)
|
publicKey: aliceStaticKey.publicKey.rawRepresentation
|
||||||
|
)
|
||||||
|
if localPeerID < alicePeerID {
|
||||||
|
#expect(replacementResponse == nil)
|
||||||
|
#expect(replacementSession === restartedSession)
|
||||||
|
} else {
|
||||||
|
#expect(replacementResponse != nil)
|
||||||
|
#expect(replacementSession !== restartedSession)
|
||||||
|
}
|
||||||
|
|
||||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
|
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
|
||||||
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
||||||
@@ -654,7 +662,13 @@ struct NoiseCoverageTests {
|
|||||||
try aliceManager.initiateHandshake(with: alicePeerID)
|
try aliceManager.initiateHandshake(with: alicePeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
let rekeyHandshake = try aliceManager.initiateRekey(for: alicePeerID)
|
let rekeyInitiation = try aliceManager.initiateRekey(for: alicePeerID)
|
||||||
|
let rekeyHandshake = try #require(
|
||||||
|
aliceManager.claimHandshakeInitiation(
|
||||||
|
rekeyInitiation,
|
||||||
|
for: alicePeerID
|
||||||
|
)
|
||||||
|
)
|
||||||
#expect(!rekeyHandshake.isEmpty)
|
#expect(!rekeyHandshake.isEmpty)
|
||||||
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
|
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
|
||||||
|
|
||||||
@@ -667,6 +681,7 @@ struct NoiseCoverageTests {
|
|||||||
let aliceManager = NoiseSessionManager(
|
let aliceManager = NoiseSessionManager(
|
||||||
localStaticKey: aliceStaticKey,
|
localStaticKey: aliceStaticKey,
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
|
recentInitiatorCompletionGracePeriod: 0,
|
||||||
sessionFactory: { peerID, role in
|
sessionFactory: { peerID, role in
|
||||||
BlockingDecryptNoiseSession(
|
BlockingDecryptNoiseSession(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
|
|||||||
@@ -357,8 +357,18 @@ struct NoiseProtocolTests {
|
|||||||
|
|
||||||
@Test func peerRestartDetection() throws {
|
@Test func peerRestartDetection() throws {
|
||||||
// Establish initial sessions
|
// Establish initial sessions
|
||||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
// This test explicitly drives the three synchronous XX messages and
|
||||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
// does not exercise the transport's delayed collision recovery.
|
||||||
|
let aliceManager = NoiseSessionManager(
|
||||||
|
localStaticKey: aliceKey,
|
||||||
|
keychain: mockKeychain,
|
||||||
|
recentInitiatorCompletionGracePeriod: 0
|
||||||
|
)
|
||||||
|
let bobManager = NoiseSessionManager(
|
||||||
|
localStaticKey: bobKey,
|
||||||
|
keychain: mockKeychain,
|
||||||
|
recentInitiatorCompletionGracePeriod: 0
|
||||||
|
)
|
||||||
|
|
||||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||||
|
|
||||||
@@ -377,15 +387,24 @@ struct NoiseProtocolTests {
|
|||||||
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID)
|
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID)
|
||||||
|
|
||||||
// Alice should accept the new handshake (clearing old session)
|
// Alice should accept the new handshake (clearing old session)
|
||||||
let newHandshake2 = try aliceManager.handleIncomingHandshake(
|
let newHandshake2 = try #require(
|
||||||
from: alicePeerID, message: newHandshake1)
|
try aliceManager.handleIncomingHandshake(
|
||||||
#expect(newHandshake2 != nil)
|
from: alicePeerID,
|
||||||
|
message: newHandshake1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
// Complete the new handshake
|
// Complete the new handshake
|
||||||
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(
|
let newHandshake3 = try #require(
|
||||||
from: bobPeerID, message: newHandshake2!)
|
try bobManagerRestarted.handleIncomingHandshake(
|
||||||
#expect(newHandshake3 != nil)
|
from: bobPeerID,
|
||||||
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
|
message: newHandshake2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ = try aliceManager.handleIncomingHandshake(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: newHandshake3
|
||||||
|
)
|
||||||
|
|
||||||
// Should be able to exchange messages with new sessions
|
// Should be able to exchange messages with new sessions
|
||||||
let testMessage = Data("After restart".utf8)
|
let testMessage = Data("After restart".utf8)
|
||||||
@@ -543,8 +562,18 @@ struct NoiseProtocolTests {
|
|||||||
|
|
||||||
@Test func nonceDesynchronizationCausesRehandshake() throws {
|
@Test func nonceDesynchronizationCausesRehandshake() throws {
|
||||||
// Test that nonce desynchronization leads to proper re-handshake
|
// Test that nonce desynchronization leads to proper re-handshake
|
||||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
// This test explicitly drives the three synchronous XX messages and
|
||||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
// does not exercise the transport's delayed collision recovery.
|
||||||
|
let aliceManager = NoiseSessionManager(
|
||||||
|
localStaticKey: aliceKey,
|
||||||
|
keychain: mockKeychain,
|
||||||
|
recentInitiatorCompletionGracePeriod: 0
|
||||||
|
)
|
||||||
|
let bobManager = NoiseSessionManager(
|
||||||
|
localStaticKey: bobKey,
|
||||||
|
keychain: mockKeychain,
|
||||||
|
recentInitiatorCompletionGracePeriod: 0
|
||||||
|
)
|
||||||
|
|
||||||
// Establish sessions
|
// Establish sessions
|
||||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||||
@@ -572,15 +601,25 @@ struct NoiseProtocolTests {
|
|||||||
let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID)
|
let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID)
|
||||||
|
|
||||||
// Alice should accept despite having a "valid" (but desynced) session
|
// Alice should accept despite having a "valid" (but desynced) session
|
||||||
let rehandshake2 = try aliceManager.handleIncomingHandshake(
|
let rehandshake2 = try #require(
|
||||||
from: alicePeerID, message: rehandshake1)
|
try aliceManager.handleIncomingHandshake(
|
||||||
#expect(rehandshake2 != nil, "Alice should accept handshake to fix desync")
|
from: alicePeerID,
|
||||||
|
message: rehandshake1
|
||||||
|
),
|
||||||
|
"Alice should accept handshake to fix desync"
|
||||||
|
)
|
||||||
|
|
||||||
// Complete handshake
|
// Complete handshake
|
||||||
let rehandshake3 = try bobManager.handleIncomingHandshake(
|
let rehandshake3 = try #require(
|
||||||
from: bobPeerID, message: rehandshake2!)
|
try bobManager.handleIncomingHandshake(
|
||||||
#expect(rehandshake3 != nil)
|
from: bobPeerID,
|
||||||
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
|
message: rehandshake2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ = try aliceManager.handleIncomingHandshake(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: rehandshake3
|
||||||
|
)
|
||||||
|
|
||||||
// Verify communication works again
|
// Verify communication works again
|
||||||
let testResynced = Data("Resynced".utf8)
|
let testResynced = Data("Resynced".utf8)
|
||||||
|
|||||||
@@ -198,6 +198,24 @@ struct BLENoisePacketHandlerTests {
|
|||||||
#expect(recorder.broadcastPackets.isEmpty)
|
#expect(recorder.broadcastPackets.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func managedHandshakeFailureDoesNotStartASecondRecovery() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.handshakeResult = .failure(
|
||||||
|
NoiseManagedHandshakeFailure(underlying: TestError())
|
||||||
|
)
|
||||||
|
recorder.hasSession = false
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let packet = makeHandshakePacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(!handler.handleHandshake(packet, from: remotePeerID))
|
||||||
|
#expect(recorder.hasSessionQueries.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
#expect(recorder.broadcastPackets.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Encrypted
|
// MARK: Encrypted
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite("BLE Noise reconnect policy")
|
||||||
|
struct BLENoiseReconnectPolicyTests {
|
||||||
|
@Test("Revalidation requires a cached session and no authenticated link")
|
||||||
|
func revalidationPreconditions() {
|
||||||
|
var policy = BLENoiseReconnectPolicy()
|
||||||
|
let link = BLEIngressLinkID.peripheral("peripheral-a")
|
||||||
|
let now = Date(timeIntervalSince1970: 1_000)
|
||||||
|
|
||||||
|
let withoutSession = policy.shouldRevalidate(
|
||||||
|
on: link,
|
||||||
|
hasEstablishedSession: false,
|
||||||
|
isNoiseAuthenticatedLink: false,
|
||||||
|
hasAuthenticatedPeerLink: false,
|
||||||
|
now: now
|
||||||
|
)
|
||||||
|
#expect(!withoutSession)
|
||||||
|
let authenticated = policy.shouldRevalidate(
|
||||||
|
on: link,
|
||||||
|
hasEstablishedSession: true,
|
||||||
|
isNoiseAuthenticatedLink: true,
|
||||||
|
hasAuthenticatedPeerLink: true,
|
||||||
|
now: now
|
||||||
|
)
|
||||||
|
#expect(!authenticated)
|
||||||
|
let eligible = policy.shouldRevalidate(
|
||||||
|
on: link,
|
||||||
|
hasEstablishedSession: true,
|
||||||
|
isNoiseAuthenticatedLink: false,
|
||||||
|
hasAuthenticatedPeerLink: false,
|
||||||
|
now: now
|
||||||
|
)
|
||||||
|
#expect(eligible)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Revalidation is once per link epoch or after sixty seconds")
|
||||||
|
func revalidationIsBoundPerLinkEpoch() {
|
||||||
|
var policy = BLENoiseReconnectPolicy()
|
||||||
|
let link = BLEIngressLinkID.central("central-a")
|
||||||
|
let start = Date(timeIntervalSince1970: 2_000)
|
||||||
|
|
||||||
|
let initial = policy.shouldRevalidate(
|
||||||
|
on: link,
|
||||||
|
hasEstablishedSession: true,
|
||||||
|
isNoiseAuthenticatedLink: false,
|
||||||
|
hasAuthenticatedPeerLink: false,
|
||||||
|
now: start
|
||||||
|
)
|
||||||
|
#expect(initial)
|
||||||
|
let duringCooldown = policy.shouldRevalidate(
|
||||||
|
on: link,
|
||||||
|
hasEstablishedSession: true,
|
||||||
|
isNoiseAuthenticatedLink: false,
|
||||||
|
hasAuthenticatedPeerLink: false,
|
||||||
|
now: start.addingTimeInterval(59.999)
|
||||||
|
)
|
||||||
|
#expect(!duringCooldown)
|
||||||
|
let afterCooldown = policy.shouldRevalidate(
|
||||||
|
on: link,
|
||||||
|
hasEstablishedSession: true,
|
||||||
|
isNoiseAuthenticatedLink: false,
|
||||||
|
hasAuthenticatedPeerLink: false,
|
||||||
|
now: start.addingTimeInterval(60)
|
||||||
|
)
|
||||||
|
#expect(afterCooldown)
|
||||||
|
|
||||||
|
policy.endLinkEpoch(link)
|
||||||
|
let nextEpoch = policy.shouldRevalidate(
|
||||||
|
on: link,
|
||||||
|
hasEstablishedSession: true,
|
||||||
|
isNoiseAuthenticatedLink: false,
|
||||||
|
hasAuthenticatedPeerLink: false,
|
||||||
|
now: start.addingTimeInterval(60.001)
|
||||||
|
)
|
||||||
|
#expect(nextEpoch)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An authenticated sibling suppresses redundant reconnect")
|
||||||
|
func authenticatedSiblingSuppressesReconnect() {
|
||||||
|
var policy = BLENoiseReconnectPolicy()
|
||||||
|
let link = BLEIngressLinkID.peripheral("unproven-sibling")
|
||||||
|
let start = Date(timeIntervalSince1970: 3_000)
|
||||||
|
|
||||||
|
let suppressed = policy.shouldRevalidate(
|
||||||
|
on: link,
|
||||||
|
hasEstablishedSession: true,
|
||||||
|
isNoiseAuthenticatedLink: false,
|
||||||
|
hasAuthenticatedPeerLink: true,
|
||||||
|
now: start
|
||||||
|
)
|
||||||
|
#expect(!suppressed)
|
||||||
|
let eligible = policy.shouldRevalidate(
|
||||||
|
on: link,
|
||||||
|
hasEstablishedSession: true,
|
||||||
|
isNoiseAuthenticatedLink: false,
|
||||||
|
hasAuthenticatedPeerLink: false,
|
||||||
|
now: start
|
||||||
|
)
|
||||||
|
#expect(eligible)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Reserved replacement bit is not advertised")
|
||||||
|
func reservedReplacementBitIsNotAdvertised() {
|
||||||
|
#expect(
|
||||||
|
!PeerCapabilities.localSupported.contains(
|
||||||
|
.nonDestructiveNoiseReplacement
|
||||||
|
)
|
||||||
|
)
|
||||||
|
#expect(PeerCapabilities.localSupported.contains(.privateMedia))
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,11 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
|
|||||||
/// before outer BLE fragmentation. Peers that omit this bit require the
|
/// before outer BLE fragmentation. Peers that omit this bit require the
|
||||||
/// signed directed raw-file migration fallback.
|
/// signed directed raw-file migration fallback.
|
||||||
public static let privateMedia = PeerCapabilities(rawValue: 1 << 8)
|
public static let privateMedia = PeerCapabilities(rawValue: 1 << 8)
|
||||||
|
/// Reserved for test builds that briefly advertised non-destructive Noise
|
||||||
|
/// replacement. Current clients intentionally do not advertise or act on
|
||||||
|
/// this bit; keep it decodable so the wire assignment is never reused.
|
||||||
|
public static let nonDestructiveNoiseReplacement =
|
||||||
|
PeerCapabilities(rawValue: 1 << 10)
|
||||||
|
|
||||||
/// Minimal little-endian byte encoding; always at least one byte so an
|
/// Minimal little-endian byte encoding; always at least one byte so an
|
||||||
/// empty set is distinguishable from an absent TLV.
|
/// empty set is distinguishable from an absent TLV.
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ struct PeerCapabilitiesTests {
|
|||||||
|
|
||||||
let high = PeerCapabilities(rawValue: 1 << 9)
|
let high = PeerCapabilities(rawValue: 1 << 9)
|
||||||
#expect(high.encoded() == Data([0x00, 0x02]))
|
#expect(high.encoded() == Data([0x00, 0x02]))
|
||||||
|
#expect(
|
||||||
|
PeerCapabilities.nonDestructiveNoiseReplacement.encoded()
|
||||||
|
== Data([0x00, 0x04])
|
||||||
|
)
|
||||||
|
|
||||||
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia]
|
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia]
|
||||||
#expect(PeerCapabilities(encoded: all.encoded()) == all)
|
#expect(PeerCapabilities(encoded: all.encoded()) == all)
|
||||||
|
|||||||
Reference in New Issue
Block a user