Make ordinary Noise reconnects atomic and race-safe (#1463)

Replaces destroy-then-rebuild Noise re-handshakes with an atomic reconnect protocol: prepared XX message-1 handoff tokens (claim-once, invalidated by crossed inbound initiations), receive-only quarantine of the established transport while an inbound replacement proves identity (promote on success, rollback+cooldown on failure/timeout), deterministic lower-peerID-wins crossed-initiator resolution, and a per-link-epoch BLE revalidation policy that re-proves cached sessions inside the same bleQueue critical section as the link rebind. On main, a fresh msg1 simply destroys an established session and rekey spans two non-atomic barriers.

Includes the review fix: timeout-restores defer outbound queue draining until the convergence retry completes (restore reason plumbed end-to-end), so DMs are never drained under keys a restarted peer already discarded — with a deterministic interleaving test. Identity-mismatch restores drain immediately. Full local suite 1768 tests green.
This commit is contained in:
jack
2026-07-26 13:04:56 +02:00
committed by GitHub
parent e9275cb3d8
commit 78a81e5b57
17 changed files with 3248 additions and 179 deletions
@@ -36,6 +36,30 @@ enum NoiseSecurityConstants {
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
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
static let sessionTimeout: TimeInterval = 86400 // 24 hours
+6
View File
@@ -13,3 +13,9 @@ enum NoiseSessionError: Error, Equatable {
case alreadyEstablished
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.
/// Callers use this to distinguish an authenticated replacement completion
/// from a rejected candidate while an older session remains established.
/// Callers use this to distinguish an authenticated reconnect completion
/// from a rejected ordinary responder while rollback state is restored.
@discardableResult
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
handleHandshakeWithResult(packet, from: peerID).processed
@@ -112,8 +112,16 @@ final class BLENoisePacketHandler {
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 {
// 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
// handshake or recreate state for the attacker-selected ID.
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()
}
}
+302 -27
View File
@@ -241,6 +241,7 @@ final class BLEService: NSObject {
// that the session was established *on this current ingress link*, not
// merely that some session exists for the claimed ID. bleQueue-owned.
private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:]
private var noiseReconnectPolicy = BLENoiseReconnectPolicy()
// Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link
// store): entries older than the cooldown are pruned on insert.
@@ -311,6 +312,13 @@ final class BLEService: NSObject {
/// May block in tests to hold the serial message queue immediately before
/// the deferred private-media admission check.
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)?
/// May block the convergence-recovery callback on its global-queue thread
/// before it enqueues onto `messageQueue`. Tests use this boundary to
/// force the quarantine-restore handler to win the dispatch race.
var _test_beforeHandshakeRecoveryEnqueued: ((PeerID) -> Void)?
#endif
private var selfBroadcastTracker = BLESelfBroadcastTracker()
private let meshTopology = MeshTopologyTracker()
@@ -383,6 +391,9 @@ final class BLEService: NSObject {
// MARK: - Identity
private var noiseService: NoiseEncryptionService
/// Injected so tests can compress the quarantine/rollback window;
/// production always passes the security-constant default.
private let noiseResponderHandshakeTimeout: TimeInterval
private let identityManager: SecureIdentityStateManagerProtocol
private let keychain: KeychainManagerProtocol
private let idBridge: NostrIdentityBridge
@@ -499,14 +510,20 @@ final class BLEService: NSObject {
identityManager: SecureIdentityStateManagerProtocol,
initializeBluetoothManagers: Bool = true,
incomingFileStore: BLEIncomingFileStore = BLEIncomingFileStore(),
startSuspendedForPanicRecovery: Bool = false
startSuspendedForPanicRecovery: Bool = false,
noiseResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
) {
self.keychain = keychain
self.idBridge = idBridge
self.incomingFileStore = incomingFileStore
self.shouldInitializeBluetoothManagers = initializeBluetoothManagers
self._isPanicSuspended = startSuspendedForPanicRecovery
noiseService = NoiseEncryptionService(keychain: keychain)
self.noiseResponderHandshakeTimeout = noiseResponderHandshakeTimeout
noiseService = NoiseEncryptionService(
keychain: keychain,
ordinaryResponderHandshakeTimeout: noiseResponderHandshakeTimeout
)
self.identityManager = identityManager
super.init()
@@ -764,6 +781,8 @@ final class BLEService: NSObject {
bleQueue.sync {
pendingWriteBuffers.removeAll()
noiseAuthenticatedLinkOwners.removeAll()
noiseReconnectPolicy.removeAll()
connectionScheduler.reset()
}
disconnectNotifyDebouncer.removeAll()
@@ -777,7 +796,10 @@ final class BLEService: NSObject {
noiseService.clearEphemeralStateForPanic()
noiseService.clearPersistentIdentity()
let newNoise = NoiseEncryptionService(keychain: keychain)
let newNoise = NoiseEncryptionService(
keychain: keychain,
ordinaryResponderHandshakeTimeout: noiseResponderHandshakeTimeout
)
noiseService = newNoise
configureNoiseServiceCallbacks(for: newNoise)
refreshPeerIdentity()
@@ -1061,6 +1083,7 @@ final class BLEService: NSObject {
bleQueue.sync {
linkStateStore.clearAll()
noiseAuthenticatedLinkOwners.removeAll()
noiseReconnectPolicy.removeAll()
connectionScheduler.reset()
subscriptionAnnounceLimiter.removeAll()
}
@@ -2628,6 +2651,7 @@ final class BLEService: NSObject {
}
for link in departedLinks {
noiseAuthenticatedLinkOwners.removeValue(forKey: link)
noiseReconnectPolicy.endLinkEpoch(link)
}
}
_ = collectionsQueue.sync(flags: .barrier) {
@@ -2947,14 +2971,21 @@ extension BLEService: CBCentralManagerDelegate {
startScanning()
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)
central.stopScan()
// Mark all peripheral connections as disconnected (they are now invalid)
let peripheralStates = linkStateStore.peripheralStates
let peerIDs: [PeerID] = peripheralStates.compactMap(\.peerID)
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()
// Notify UI of disconnections
@@ -2967,7 +2998,6 @@ extension BLEService: CBCentralManagerDelegate {
case .unauthorized:
// User denied Bluetooth permission
SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session)
central.stopScan()
_ = linkStateStore.clearPeripherals()
case .unsupported:
@@ -3115,6 +3145,7 @@ extension BLEService: CBCentralManagerDelegate {
pendingPeripheralWrites.discardAll(for: peripheralID)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
_ = linkStateStore.removePeripheral(peripheralID)
// A duplicate link can drop while the peer stays live on another
// (the dual-role central link, or a second bound link after a
@@ -3169,6 +3200,7 @@ extension BLEService: CBCentralManagerDelegate {
pendingPeripheralWrites.discardAll(for: peripheralID)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
_ = linkStateStore.removePeripheral(peripheralID)
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
@@ -3276,6 +3308,7 @@ extension BLEService {
self.pendingPeripheralWrites.discardAll(for: peripheralID)
}
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
_ = self.linkStateStore.removePeripheral(peripheralID)
self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date())
self.tryConnectFromQueue()
@@ -3435,6 +3468,20 @@ extension BLEService {
try noiseService.processHandshakeMessage(from: peerID, message: message)
}
func _test_enqueuePendingPrivateMessage(
content: String,
messageID: String,
for peerID: PeerID
) {
collectionsQueue.sync(flags: .barrier) {
pendingNoiseSessionQueues.appendPrivateMessage(
content: content,
messageID: messageID,
for: peerID
)
}
}
func _test_enqueuePendingNoisePayload(
_ payload: Data,
transferId: String,
@@ -3856,8 +3903,19 @@ extension BLEService: CBPeripheralManagerDelegate {
case .poweredOff:
// Bluetooth was turned off - clean up peripheral state
SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session)
peripheral.stopAdvertising()
// 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()
subscriptionAnnounceLimiter.removeAll()
characteristic = nil
@@ -3871,7 +3929,6 @@ extension BLEService: CBPeripheralManagerDelegate {
case .unauthorized:
// User denied Bluetooth permission
SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session)
peripheral.stopAdvertising()
_ = linkStateStore.clearCentrals()
subscriptionAnnounceLimiter.removeAll()
characteristic = nil
@@ -3984,6 +4041,7 @@ extension BLEService: CBPeripheralManagerDelegate {
pendingNotifications.removeTarget { $0.identifier.uuidString == centralID }
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID))
noiseReconnectPolicy.endLinkEpoch(.central(centralID))
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
// Ensure we're still advertising for other devices to find us
@@ -4601,6 +4659,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) {
service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in
@@ -4613,19 +4705,111 @@ extension BLEService {
)
}
}
service.onRekeyHandshakeReady = { [weak self] peerID, message in
self?.messageQueue.async { [weak self] in
guard let self else { return }
service.onRekeyHandshakeReady = {
[weak self, weak service] peerID, initiation in
self?.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 message = service.claimHandshakeInitiation(
initiation,
for: peerID
) else {
return
}
self.broadcastNoiseHandshake(message, to: peerID)
}
}
service.onHandshakeRecoveryRequired = {
[weak self, weak service] request in
guard let self, let service else { return }
#if DEBUG
self._test_beforeHandshakeRecoveryEnqueued?(request.peerID)
#endif
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, reason 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 to restore authenticated protocol
// state. Only a terminal restore may also drain the PM and
// typed-payload queues: after a responder timeout the
// counterpart may have completed the replacement handshake
// and discarded the restored keys, so encrypting the queues
// under them would lose every message silently. The mandatory
// convergence retry that accompanies the restore drains them
// under the new session instead (any establishment does).
self.handleNoisePeerAuthenticated(
peerID: peerID,
fingerprint: fingerprint,
sessionGeneration: generation,
deferOutboundUntilConvergence: reason == .pendingConvergence
)
}
}
}
private func handleNoisePeerAuthenticated(
peerID: PeerID,
fingerprint: String,
sessionGeneration generation: UUID
sessionGeneration generation: UUID,
deferOutboundUntilConvergence: Bool = false
) {
let normalizedPeerID = peerID.toShort()
guard let transition = noiseService.withCurrentSessionGeneration(
@@ -4677,6 +4861,21 @@ extension BLEService {
nonce: watchdog.nonce
)
if deferOutboundUntilConvergence {
// Timeout-restore: the session is back for receive purposes and
// the generation-bound protocol state above is rebuilt, but the
// counterpart may already hold replacement keys that discarded
// this generation's. Encrypting the pending queues here would
// lose them silently, so leave them parked: the restore's
// mandatory convergence retry or any later handshake the
// reconnect policy initiates re-enters this transition with a
// fresh generation and drains them under keys both sides hold.
#if DEBUG
_test_onPrivateMediaSessionReconciled?(normalizedPeerID)
#endif
return
}
// `onPeerAuthenticated` can fire while the initiator is returning XX
// message 3. This callback is queued behind the handshake handler, so
// message 3 is broadcast first. Both peers also send one idempotent
@@ -4874,8 +5073,9 @@ extension BLEService {
}
return
}
guard noiseService.hasSession(with: peerID) else {
// No session yet - queue the payload SYNCHRONOUSLY before initiating handshake
guard noiseService.hasEstablishedSession(with: peerID) else {
// No established session yet - queue the payload synchronously
// before initiating a handshake
// to prevent race where fast handshake completion drains empty queue
collectionsQueue.sync(flags: .barrier) {
self.pendingNoiseSessionQueues.appendTypedPayload(typedPayload, for: peerID)
@@ -5805,6 +6005,7 @@ extension BLEService {
self.pendingPeripheralWrites.discardAll(for: peripheralID)
}
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
_ = self.linkStateStore.removePeripheral(peripheralID)
cancelled += 1
}
@@ -5870,12 +6071,27 @@ extension BLEService {
}
private func initiateNoiseHandshake(with peerID: PeerID) {
// Use NoiseEncryptionService for handshake
guard !noiseService.hasSession(with: peerID) else { return }
let service = noiseService
do {
let handshakeData = try noiseService.initiateHandshake(with: peerID)
broadcastNoiseHandshake(handshakeData, to: peerID)
guard let initiation = try service.initiateHandshakeIfNeeded(
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 {
SecureLogger.error("Failed to initiate handshake: \(error)")
}
@@ -5893,6 +6109,42 @@ extension BLEService {
)
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) {
// Atomically take all pending messages to process (prevents concurrent modification)
@@ -6448,6 +6700,9 @@ extension BLEService {
// consolidate duplicate same-role connections onto that link.
if let result, result.isVerified, result.isDirectAnnounce {
rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID)
#if DEBUG
_test_afterVerifiedDirectRebindEnqueued?()
#endif
retireRedundantPeripheralLinks(packet, to: result.peerID)
}
@@ -6481,11 +6736,9 @@ extension BLEService {
deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
if result.isDirectAnnounce,
!hasCurrentNoiseAuthenticatedLink(to: result.peerID) {
if noiseService.hasEstablishedSession(with: result.peerID) {
// A session with no surviving authenticated link is stale;
// force the current link to prove possession again.
clearNoiseSession(for: result.peerID)
}
// A cached session may predate this physical link.
// rebindLinkAfterVerifiedDirectAnnounce performs its atomic
// ordinary reconnect after the binding is published.
if !noiseService.hasSession(with: result.peerID) {
initiateNoiseHandshake(with: result.peerID)
}
@@ -6514,7 +6767,14 @@ extension BLEService {
linkUUID = 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
// from signing because relays mutate it), so a "verified direct"
@@ -6541,12 +6801,20 @@ extension BLEService {
// it across an announce-driven rebind, whose direct TTL is
// replayable; the new owner must complete a fresh handshake.
self.noiseAuthenticatedLinkOwners.removeValue(forKey: link)
self.noiseReconnectPolicy.endLinkEpoch(link)
switch link {
case .peripheral(let peripheralUUID):
self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID)
case .central(let centralUUID):
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)
self.refreshLocalTopology()
// The announce that triggered this rebind was upserted as
@@ -6638,6 +6906,7 @@ extension BLEService {
pendingPeripheralWrites.discardAll(for: uuid)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid))
noiseReconnectPolicy.endLinkEpoch(.peripheral(uuid))
_ = linkStateStore.removePeripheral(uuid)
SecureLogger.info(
"🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")",
@@ -7040,10 +7309,16 @@ extension BLEService {
}
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
let result = noisePacketHandler.handleHandshakeWithResult(
packet,
from: peerID
)
let isEstablished = noiseService.hasEstablishedSession(with: peerID)
if wasEstablished, result.processed,
!isEstablished {
noteNoiseSessionCleared(for: peerID)
}
if result.didEstablishAuthenticatedSession {
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
}
+132 -11
View File
@@ -184,11 +184,20 @@ final class NoiseEncryptionService {
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = []
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
/// Automatic rekey removed the old session and produced XX message 1.
/// The transport must clear session-scoped state and put these exact bytes
/// on the wire; merely reporting "handshake required" strands the partial
/// initiator session because a second initiate call sees it already exists.
var onRekeyHandshakeReady: ((_ peerID: PeerID, _ message: Data) -> Void)?
/// Automatic rekey prepared XX message 1. The transport must claim the
/// exact attempt at its actual BLE handoff; a crossed inbound initiation
/// can invalidate the token before that point.
var onRekeyHandshakeReady:
((_ 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 may only be drained for this exact restored
/// generation when the reason is terminal; a restore that owns a pending
/// convergence retry must keep them parked until the retry concludes.
var onSessionRestoredWithGeneration:
((_ peerID: PeerID, _ generation: UUID, _ reason: NoiseSessionRestoreReason) -> Void)?
// Add a handler for peer authentication
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
@@ -219,7 +228,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.localPrekeys = LocalPrekeyStore(keychain: keychain)
@@ -309,7 +328,17 @@ final class NoiseEncryptionService {
self.signingPublicKey = signingKey.publicKey
// 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
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in
@@ -319,6 +348,12 @@ final class NoiseEncryptionService {
sessionGeneration: generation
)
}
sessionManager.onSessionRestored = { [weak self] peerID, generation, reason in
self?.onSessionRestoredWithGeneration?(peerID, generation, reason)
}
sessionManager.onHandshakeRecoveryRequired = { [weak self] request in
self?.onHandshakeRecoveryRequired?(request)
}
// Start session maintenance timer
startRekeyTimer()
@@ -682,6 +717,90 @@ final class NoiseEncryptionService {
let handshakeData = try sessionManager.initiateHandshake(with: peerID)
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
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
@@ -819,8 +938,10 @@ final class NoiseEncryptionService {
throw NoiseSecurityError.rateLimitExceeded
}
// Check if we have an established session
guard hasEstablishedSession(with: peerID) else {
// A quarantined transport is deliberately unavailable for outbound
// 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
}
@@ -943,9 +1064,9 @@ final class NoiseEncryptionService {
}
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)
onRekeyHandshakeReady?(peerID, handshakeMessage)
onRekeyHandshakeReady?(peerID, initiation)
onHandshakeRequired?(peerID)
}
+431 -13
View File
@@ -502,14 +502,26 @@ struct BLEServiceCoreTests {
)
let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce")
#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)
let rebound = await TestHelpers.waitUntil(
{ ble._test_centralBinding(attackerLink) == victimPeerID },
let announcePaused = await TestHelpers.waitUntil(
{ rebindGate.hasPaused },
timeout: TestConstants.longTimeout
)
#expect(rebound)
#expect(ble.canDeliverSecurely(to: victimPeerID))
try #require(announcePaused)
// 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()
ble._test_onOutboundPacket = { outbound.record($0) }
@@ -537,20 +549,26 @@ struct BLEServiceCoreTests {
// Preserve a working victim session while an unauthenticated
// 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(
try victim.processHandshakeMessage(from: ble.myPeerID, message: message1)
)
let message3 = try #require(
try ble._test_noiseProcessHandshakeMessage(
from: victimPeerID,
message: message1
)
)
let message3 = try #require(
try victim.processHandshakeMessage(
from: ble.myPeerID,
message: message2
)
)
_ = try victim.processHandshakeMessage(
from: ble.myPeerID,
_ = try ble._test_noiseProcessHandshakeMessage(
from: victimPeerID,
message: message3
)
await ble._test_drainNoiseMessagePipeline()
#expect(ble.canDeliverSecurely(to: victimPeerID))
let centralUUID = "central-replacement-xx-message-one"
@@ -614,7 +632,330 @@ struct BLEServiceCoreTests {
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
)
}
/// The message-loss interleaving behind a legitimate peer restart: the
/// remote completes a replacement handshake (discarding the old keys)
/// but its completion never arrives, so the local responder timeout
/// restores the quarantined OLD generation and requests one convergence
/// retry both dispatched unordered. When the restore handler wins the
/// race, it must NOT drain the pending private-message/typed-payload
/// queues under the restored keys the remote no longer holds; the drain
/// has to wait for the convergence handshake and use its new session.
@Test
func timeoutRestoredSessionDefersQueueDrainUntilConvergence() async throws {
let ble = makeService(noiseResponderHandshakeTimeout: 0.3)
let alice = NoiseEncryptionService(keychain: MockKeychain())
let mallory = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
// Establish BLE as responder so the inbound reconnect below is not
// 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))
// The convergence retry only prepares for reachable peers.
ble._test_seedConnectedPeer(alicePeerID, nickname: "Alice")
let reconciled = SessionReconcileCounter()
ble._test_onPrivateMediaSessionReconciled = reconciled.record
// Park the convergence-recovery callback on its global-queue thread
// before it can enqueue onto messageQueue: the restore handler
// deterministically wins the dispatch race this test exercises.
let recoveryGate = HandshakeRecoveryEnqueueGate()
defer { recoveryGate.release() }
ble._test_beforeHandshakeRecoveryEnqueued = { _ in recoveryGate.pause() }
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record
// Park the traffic the race would lose directly in the pending
// queues the same place live sends land during quarantine so no
// assertion below depends on outrunning the responder deadline under
// parallel test load. Nothing drains these queues while the current
// session stays untouched.
ble._test_enqueuePendingPrivateMessage(
content: "deferred private message",
messageID: "deferred-pm-\(UUID().uuidString)",
for: alicePeerID
)
ble._test_enqueuePendingNoisePayload(
NoisePayload(
type: .groupInvite,
data: Data("queued-during-quarantine".utf8)
).encode(),
transferId: "deferred-invite-\(UUID().uuidString)",
for: alicePeerID
)
// An unauthenticated message 1 quarantines the established transport.
// Its message 3 never arrives, modeling the restarted peer whose
// completion was lost after it already discarded the old keys.
let reconnectMessage1 = try mallory.initiateHandshake(with: ble.myPeerID)
let reconnectPacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: Data(hexString: alicePeerID.id) ?? Data(),
recipientID: Data(hexString: ble.myPeerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000),
payload: reconnectMessage1,
signature: nil,
ttl: 7
)
ble._test_handlePacket(reconnectPacket, fromPeerID: alicePeerID)
// The responder's message 2 is a monotonic quarantine signal; the
// secure-delivery dip itself only lasts until the responder deadline,
// which parallel test load can outrun. (The recovery gate keeps the
// convergence retry's message 1 out of the tap until released.)
let responderReady = await TestHelpers.waitUntil(
{ outbound.count(ofType: .noiseHandshake) >= 1 },
timeout: TestConstants.longTimeout
)
try #require(responderReady)
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
// The responder timeout restores the quarantined generation; the
// gate guarantees its handler runs before the convergence retry.
let restoreRan = await TestHelpers.waitUntil(
{ reconciled.count(for: alicePeerID) == 1 },
timeout: TestConstants.longTimeout
)
try #require(restoreRan)
#expect(ble.canDeliverSecurely(to: alicePeerID))
await ble._test_drainNoiseMessagePipeline()
// The parked queues must not have been encrypted under the restored
// old generation the remote may no longer be able to read.
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
// Release the mandatory convergence retry: it retires the restored
// session and starts a fresh XX exchange with the live peer.
recoveryGate.release()
let retryStarted = 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(retryStarted)
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
let retryMessage1 = try #require(
outbound.snapshot().last {
$0.type == MessageType.noiseHandshake.rawValue
&& PeerID(hexData: $0.senderID) == ble.myPeerID
&& $0.payload.count
== NoiseSecurityConstants.xxInitialMessageSize
}?.payload
)
// Alice answers the retry as the restarted peer she models: her old
// session is gone, so the retry is a fresh responder exchange (and
// never the initiator-completion grace deferral, whose lower-peerID
// arm would otherwise coalesce the retry for random key orderings).
alice.clearSession(for: ble.myPeerID)
let retryMessage2 = try #require(
try alice.processHandshakeMessage(
from: ble.myPeerID,
message: retryMessage1
)
)
let retryPacket = 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: retryMessage2,
signature: nil,
ttl: 7
)
ble._test_handlePacket(retryPacket, fromPeerID: alicePeerID)
let drained = await TestHelpers.waitUntil(
{ outbound.count(ofType: .noiseEncrypted) >= 3 },
timeout: TestConstants.longTimeout
)
try #require(drained)
await ble._test_drainNoiseMessagePipeline()
// Alice completes with message 3, then must be able to decrypt every
// drained payload proving nothing left under the old generation.
let retryMessage3 = try #require(
outbound.snapshot().last {
$0.type == MessageType.noiseHandshake.rawValue
&& PeerID(hexData: $0.senderID) == ble.myPeerID
&& $0.payload.count
!= NoiseSecurityConstants.xxInitialMessageSize
}?.payload
)
_ = try alice.processHandshakeMessage(
from: ble.myPeerID,
message: retryMessage3
)
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
@@ -943,6 +1284,79 @@ private final class OutboundPacketTap {
lock.lock(); defer { lock.unlock() }
return packets.filter { $0.type == type.rawValue }.count
}
func snapshot() -> [BitchatPacket] {
lock.lock(); defer { lock.unlock() }
return packets
}
}
/// Blocks the convergence-recovery callback on its global-queue thread so a
/// test can prove the quarantine-restore handler wins the messageQueue race.
private final class HandshakeRecoveryEnqueueGate: @unchecked Sendable {
private let condition = NSCondition()
private var released = false
func pause() {
condition.lock()
while !released {
condition.wait()
}
condition.unlock()
}
func release() {
condition.lock()
released = true
condition.broadcast()
condition.unlock()
}
}
/// Thread-safe counter for `_test_onPrivateMediaSessionReconciled` firings.
private final class SessionReconcileCounter: @unchecked Sendable {
private let lock = NSLock()
private var reconciles: [PeerID] = []
func record(_ peerID: PeerID) {
lock.lock()
reconciles.append(peerID)
lock.unlock()
}
func count(for peerID: PeerID) -> Int {
lock.lock(); defer { lock.unlock() }
return reconciles.filter { $0 == peerID }.count
}
}
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 {
@@ -1007,7 +1421,10 @@ private final class PanicIngressObserver: @unchecked Sendable {
}
}
private func makeService() -> BLEService {
private func makeService(
noiseResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
) -> BLEService {
let keychain = MockKeychain()
let identityManager = MockIdentityManager(keychain)
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
@@ -1015,7 +1432,8 @@ private func makeService() -> BLEService {
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
initializeBluetoothManagers: false
initializeBluetoothManagers: false,
noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout
)
}
@@ -12,6 +12,7 @@ import Testing
@testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat
@Suite("Integration Tests", .serialized)
struct IntegrationTests {
private var helper = TestNetworkHelper()
@@ -272,8 +273,18 @@ struct IntegrationTests {
// Re-establish Noise handshake explicitly via managers
do {
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 m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)!
let m2 = try #require(
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)
} catch {
Issue.record("Failed to re-establish Noise session after restart: \(error)")
@@ -8,6 +8,7 @@
import Foundation
import CryptoKit
import Testing
@testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat
@@ -27,9 +28,14 @@ final class TestNetworkHelper {
node.mockNickname = name
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()
noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)
noiseManagers[name] = NoiseSessionManager(
localStaticKey: key,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
return node
}
@@ -108,8 +114,18 @@ final class TestNetworkHelper {
let peer2ID = nodes[node2]?.peerID else { return }
let msg1 = try manager1.initiateHandshake(with: peer2ID)
let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)!
let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)!
let msg2 = try #require(
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)
}
}
+19 -4
View File
@@ -5,7 +5,7 @@ import BitFoundation
@testable import bitchat
@Suite("Noise Coverage Tests")
@Suite("Noise Coverage Tests", .serialized)
struct NoiseCoverageTests {
private let keychain = MockKeychain()
private let aliceStaticKey = Curve25519.KeyAgreement.PrivateKey()
@@ -633,8 +633,16 @@ struct NoiseCoverageTests {
)
let replacementSession = try #require(manager.getSession(for: alicePeerID))
#expect(replacementResponse != nil)
#expect(replacementSession !== restartedSession)
let localPeerID = PeerID(
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 bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
@@ -654,7 +662,13 @@ struct NoiseCoverageTests {
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)
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
@@ -667,6 +681,7 @@ struct NoiseCoverageTests {
let aliceManager = NoiseSessionManager(
localStaticKey: aliceStaticKey,
keychain: keychain,
recentInitiatorCompletionGracePeriod: 0,
sessionFactory: { peerID, role in
BlockingDecryptNoiseSession(
peerID: peerID,
+57 -18
View File
@@ -357,8 +357,18 @@ struct NoiseProtocolTests {
@Test func peerRestartDetection() throws {
// Establish initial sessions
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// This test explicitly drives the three synchronous XX messages and
// 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)
@@ -377,15 +387,24 @@ struct NoiseProtocolTests {
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID)
// Alice should accept the new handshake (clearing old session)
let newHandshake2 = try aliceManager.handleIncomingHandshake(
from: alicePeerID, message: newHandshake1)
#expect(newHandshake2 != nil)
let newHandshake2 = try #require(
try aliceManager.handleIncomingHandshake(
from: alicePeerID,
message: newHandshake1
)
)
// Complete the new handshake
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(
from: bobPeerID, message: newHandshake2!)
#expect(newHandshake3 != nil)
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
let newHandshake3 = try #require(
try bobManagerRestarted.handleIncomingHandshake(
from: bobPeerID,
message: newHandshake2
)
)
_ = try aliceManager.handleIncomingHandshake(
from: alicePeerID,
message: newHandshake3
)
// Should be able to exchange messages with new sessions
let testMessage = Data("After restart".utf8)
@@ -543,8 +562,18 @@ struct NoiseProtocolTests {
@Test func nonceDesynchronizationCausesRehandshake() throws {
// Test that nonce desynchronization leads to proper re-handshake
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// This test explicitly drives the three synchronous XX messages and
// 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
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -572,15 +601,25 @@ struct NoiseProtocolTests {
let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID)
// Alice should accept despite having a "valid" (but desynced) session
let rehandshake2 = try aliceManager.handleIncomingHandshake(
from: alicePeerID, message: rehandshake1)
#expect(rehandshake2 != nil, "Alice should accept handshake to fix desync")
let rehandshake2 = try #require(
try aliceManager.handleIncomingHandshake(
from: alicePeerID,
message: rehandshake1
),
"Alice should accept handshake to fix desync"
)
// Complete handshake
let rehandshake3 = try bobManager.handleIncomingHandshake(
from: bobPeerID, message: rehandshake2!)
#expect(rehandshake3 != nil)
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
let rehandshake3 = try #require(
try bobManager.handleIncomingHandshake(
from: bobPeerID,
message: rehandshake2
)
)
_ = try aliceManager.handleIncomingHandshake(
from: alicePeerID,
message: rehandshake3
)
// Verify communication works again
let testResynced = Data("Resynced".utf8)
@@ -198,6 +198,24 @@ struct BLENoisePacketHandlerTests {
#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
@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
/// signed directed raw-file migration fallback.
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
/// empty set is distinguishable from an absent TLV.
@@ -20,6 +20,10 @@ struct PeerCapabilitiesTests {
let high = PeerCapabilities(rawValue: 1 << 9)
#expect(high.encoded() == Data([0x00, 0x02]))
#expect(
PeerCapabilities.nonDestructiveNoiseReplacement.encoded()
== Data([0x00, 0x04])
)
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia]
#expect(PeerCapabilities(encoded: all.encoded()) == all)