mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 22:25:19 +00:00
Fix ordinary Noise handshake races
This commit is contained in:
@@ -37,10 +37,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
|
// Bounds the receive-only rollback quarantine created by an unauthenticated
|
||||||
// inbound message 1. A lost message 3 must not strand outbound traffic.
|
// inbound message 1. A lost message 3 must not strand outbound traffic.
|
||||||
static let ordinaryResponderHandshakeTimeout: TimeInterval = 20
|
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
@@ -89,6 +89,11 @@ final class BLENoisePacketHandler {
|
|||||||
// Session establishment will trigger onPeerAuthenticated callback
|
// Session establishment will trigger onPeerAuthenticated callback
|
||||||
// which will send any pending messages at the right time
|
// which will send any pending messages at the right time
|
||||||
return true
|
return true
|
||||||
|
} catch let managedFailure as NoiseManagedHandshakeFailure {
|
||||||
|
SecureLogger.error(
|
||||||
|
"Failed to process handshake; manager owns recovery: \(managedFailure.underlying)"
|
||||||
|
)
|
||||||
|
return false
|
||||||
} catch NoiseSessionError.peerIdentityMismatch {
|
} catch NoiseSessionError.peerIdentityMismatch {
|
||||||
// The responder 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
|
||||||
|
|||||||
@@ -2937,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
|
||||||
@@ -2957,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:
|
||||||
@@ -3849,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
|
||||||
@@ -3864,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
|
||||||
@@ -4641,13 +4657,71 @@ 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
|
service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation in
|
||||||
guard let self, let service else { return }
|
guard let self, let service else { return }
|
||||||
self.messageQueue.async { [weak self, weak service] in
|
self.messageQueue.async { [weak self, weak service] in
|
||||||
@@ -5922,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)")
|
||||||
}
|
}
|
||||||
@@ -5953,13 +6042,18 @@ extension BLEService {
|
|||||||
private func initiateNoiseReconnectHandshake(with peerID: PeerID) {
|
private func initiateNoiseReconnectHandshake(with peerID: PeerID) {
|
||||||
let service = noiseService
|
let service = noiseService
|
||||||
do {
|
do {
|
||||||
let initiation = try service.initiateReconnectHandshake(with: peerID)
|
let initiation = try service.initiateReconnectHandshake(
|
||||||
noteNoiseSessionCleared(for: peerID)
|
with: peerID,
|
||||||
|
retryOnTimeout: true
|
||||||
|
)
|
||||||
messageQueue.async(flags: .barrier) { [weak self, weak service] in
|
messageQueue.async(flags: .barrier) { [weak self, weak service] in
|
||||||
guard let self,
|
guard let self,
|
||||||
let service,
|
let service,
|
||||||
self.noiseService === service,
|
self.noiseService === service else {
|
||||||
let handshakeData = service.claimHandshakeInitiation(
|
return
|
||||||
|
}
|
||||||
|
self.noteNoiseSessionCleared(for: peerID)
|
||||||
|
guard let handshakeData = service.claimHandshakeInitiation(
|
||||||
initiation,
|
initiation,
|
||||||
for: peerID
|
for: peerID
|
||||||
) else {
|
) else {
|
||||||
|
|||||||
@@ -184,11 +184,13 @@ 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
|
/// An unauthenticated reconnect attempt failed or timed out and the
|
||||||
/// receive-only rollback session became the active transport again.
|
/// receive-only rollback session became the active transport again.
|
||||||
/// Transport queues must be drained for this exact restored generation.
|
/// Transport queues must be drained for this exact restored generation.
|
||||||
@@ -225,8 +227,14 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
keychain: KeychainManagerProtocol,
|
keychain: KeychainManagerProtocol,
|
||||||
|
ordinaryHandshakeTimeout: TimeInterval =
|
||||||
|
NoiseSecurityConstants.ordinaryHandshakeTimeout,
|
||||||
ordinaryResponderHandshakeTimeout: TimeInterval =
|
ordinaryResponderHandshakeTimeout: TimeInterval =
|
||||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
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)
|
||||||
@@ -320,7 +328,13 @@ final class NoiseEncryptionService {
|
|||||||
self.sessionManager = NoiseSessionManager(
|
self.sessionManager = NoiseSessionManager(
|
||||||
localStaticKey: staticIdentityKey,
|
localStaticKey: staticIdentityKey,
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
ordinaryResponderHandshakeTimeout: ordinaryResponderHandshakeTimeout
|
ordinaryHandshakeTimeout: ordinaryHandshakeTimeout,
|
||||||
|
ordinaryResponderHandshakeTimeout:
|
||||||
|
ordinaryResponderHandshakeTimeout,
|
||||||
|
recentInitiatorCompletionGracePeriod:
|
||||||
|
recentInitiatorCompletionGracePeriod,
|
||||||
|
ordinaryReconnectRollbackCooldown:
|
||||||
|
ordinaryReconnectRollbackCooldown
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set up session callbacks
|
// Set up session callbacks
|
||||||
@@ -334,6 +348,9 @@ final class NoiseEncryptionService {
|
|||||||
sessionManager.onSessionRestored = { [weak self] peerID, generation in
|
sessionManager.onSessionRestored = { [weak self] peerID, generation in
|
||||||
self?.onSessionRestoredWithGeneration?(peerID, generation)
|
self?.onSessionRestoredWithGeneration?(peerID, generation)
|
||||||
}
|
}
|
||||||
|
sessionManager.onHandshakeRecoveryRequired = { [weak self] request in
|
||||||
|
self?.onHandshakeRecoveryRequired?(request)
|
||||||
|
}
|
||||||
|
|
||||||
// Start session maintenance timer
|
// Start session maintenance timer
|
||||||
startRekeyTimer()
|
startRekeyTimer()
|
||||||
@@ -698,11 +715,41 @@ final class NoiseEncryptionService {
|
|||||||
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
|
/// Atomically prepares an ordinary reconnect for a peer whose cached
|
||||||
/// transport belongs to an earlier physical link. Failed authorization or
|
/// transport belongs to an earlier physical link. Failed authorization or
|
||||||
/// handshake setup preserves the established session.
|
/// handshake setup preserves the established session.
|
||||||
func initiateReconnectHandshake(
|
func initiateReconnectHandshake(
|
||||||
with peerID: PeerID
|
with peerID: PeerID,
|
||||||
|
retryOnTimeout: Bool = false
|
||||||
) throws -> NoiseHandshakeInitiation {
|
) throws -> NoiseHandshakeInitiation {
|
||||||
guard peerID.isValid else {
|
guard peerID.isValid else {
|
||||||
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||||
@@ -711,6 +758,7 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
return try sessionManager.initiateReconnectHandshake(
|
return try sessionManager.initiateReconnectHandshake(
|
||||||
with: peerID,
|
with: peerID,
|
||||||
|
notifyOnTimeout: retryOnTimeout,
|
||||||
authorize: { [rateLimiter] in
|
authorize: { [rateLimiter] in
|
||||||
guard rateLimiter.allowHandshake(from: peerID) else {
|
guard rateLimiter.allowHandshake(from: peerID) else {
|
||||||
SecureLogger.warning(
|
SecureLogger.warning(
|
||||||
@@ -722,6 +770,28 @@ final class NoiseEncryptionService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
func claimHandshakeInitiation(
|
||||||
_ initiation: NoiseHandshakeInitiation,
|
_ initiation: NoiseHandshakeInitiation,
|
||||||
for peerID: PeerID
|
for peerID: PeerID
|
||||||
@@ -979,9 +1049,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -175,6 +175,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
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Testing
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
@Suite("NoiseEncryptionService Tests")
|
@Suite("NoiseEncryptionService Tests", .serialized)
|
||||||
struct NoiseEncryptionServiceTests {
|
struct NoiseEncryptionServiceTests {
|
||||||
|
|
||||||
@Test("Encryption status accessors cover all cases")
|
@Test("Encryption status accessors cover all cases")
|
||||||
@@ -267,10 +267,10 @@ struct NoiseEncryptionServiceTests {
|
|||||||
#expect(leaseRan)
|
#expect(leaseRan)
|
||||||
|
|
||||||
var emittedPeerID: PeerID?
|
var emittedPeerID: PeerID?
|
||||||
var emittedMessage: Data?
|
var emittedInitiation: NoiseHandshakeInitiation?
|
||||||
alice.onRekeyHandshakeReady = { peerID, message in
|
alice.onRekeyHandshakeReady = { peerID, initiation in
|
||||||
emittedPeerID = peerID
|
emittedPeerID = peerID
|
||||||
emittedMessage = message
|
emittedInitiation = initiation
|
||||||
}
|
}
|
||||||
try alice._test_initiateAutomaticRekey(for: bobPeerID)
|
try alice._test_initiateAutomaticRekey(for: bobPeerID)
|
||||||
|
|
||||||
@@ -286,7 +286,10 @@ struct NoiseEncryptionServiceTests {
|
|||||||
}
|
}
|
||||||
#expect(staleLease == nil)
|
#expect(staleLease == nil)
|
||||||
#expect(!leaseRan)
|
#expect(!leaseRan)
|
||||||
let message1 = try #require(emittedMessage)
|
let initiation = try #require(emittedInitiation)
|
||||||
|
let message1 = try #require(
|
||||||
|
alice.claimHandshakeInitiation(initiation, for: bobPeerID)
|
||||||
|
)
|
||||||
#expect(!message1.isEmpty)
|
#expect(!message1.isEmpty)
|
||||||
#expect(alice.hasSession(with: bobPeerID))
|
#expect(alice.hasSession(with: bobPeerID))
|
||||||
#expect(!alice.hasEstablishedSession(with: bobPeerID))
|
#expect(!alice.hasEstablishedSession(with: bobPeerID))
|
||||||
@@ -353,6 +356,700 @@ struct NoiseEncryptionServiceTests {
|
|||||||
#expect(decrypted == typedPayload)
|
#expect(decrypted == typedPayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Concurrent BLE starts preserve one ordinary attempt")
|
||||||
|
func duplicateHandshakeIfNeededPreservesFirstAttempt() throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
|
let starts = HandshakeInitiationRecorder()
|
||||||
|
|
||||||
|
DispatchQueue.concurrentPerform(iterations: 20) { _ in
|
||||||
|
do {
|
||||||
|
starts.record(
|
||||||
|
try alice.initiateHandshakeIfNeeded(with: bobPeerID)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
starts.record(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(starts.errorCount == 0)
|
||||||
|
let attempt = try #require(starts.initiations.first)
|
||||||
|
#expect(starts.initiations.count == 1)
|
||||||
|
let message1 = try #require(
|
||||||
|
alice.claimHandshakeInitiation(attempt, for: bobPeerID)
|
||||||
|
)
|
||||||
|
let message2 = try #require(
|
||||||
|
try bob.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try alice.processHandshakeMessage(from: bobPeerID, message: message2)
|
||||||
|
)
|
||||||
|
_ = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
|
||||||
|
|
||||||
|
let ciphertext = try alice.encrypt(
|
||||||
|
Data("one atomic start".utf8),
|
||||||
|
for: bobPeerID
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
try bob.decrypt(ciphertext, from: alicePeerID)
|
||||||
|
== Data("one atomic start".utf8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Restarted peer establishes against a retained ordinary session")
|
||||||
|
func restartedPeerCompletesRetainedRemoteRehandshake() throws {
|
||||||
|
let aliceKeychain = MockKeychain()
|
||||||
|
let alice = NoiseEncryptionService(keychain: aliceKeychain)
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
|
try establishSessions(alice: alice, bob: bob)
|
||||||
|
|
||||||
|
let restartedAlice = NoiseEncryptionService(keychain: aliceKeychain)
|
||||||
|
let attempt = try #require(
|
||||||
|
try restartedAlice.initiateHandshakeIfNeeded(with: bobPeerID)
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
try restartedAlice.initiateHandshakeIfNeeded(with: bobPeerID)
|
||||||
|
== nil
|
||||||
|
)
|
||||||
|
let message1 = try #require(
|
||||||
|
restartedAlice.claimHandshakeInitiation(
|
||||||
|
attempt,
|
||||||
|
for: bobPeerID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let message2 = try #require(
|
||||||
|
try bob.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try restartedAlice.processHandshakeMessage(
|
||||||
|
from: bobPeerID,
|
||||||
|
message: message2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
|
||||||
|
|
||||||
|
let forward = try restartedAlice.encrypt(
|
||||||
|
Data("after restart".utf8),
|
||||||
|
for: bobPeerID
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
try bob.decrypt(forward, from: alicePeerID)
|
||||||
|
== Data("after restart".utf8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Crossed ordinary initiations choose one deterministic initiator")
|
||||||
|
func crossedOrdinaryInitiationsResolveDeterministically() throws {
|
||||||
|
let endpoints = orderedServices()
|
||||||
|
let lowerAttempt = try #require(
|
||||||
|
try endpoints.lower.initiateHandshakeIfNeeded(
|
||||||
|
with: endpoints.higherPeerID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let higherAttempt = try #require(
|
||||||
|
try endpoints.higher.initiateHandshakeIfNeeded(
|
||||||
|
with: endpoints.lowerPeerID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let lowerMessage1 = try #require(
|
||||||
|
endpoints.lower.claimHandshakeInitiation(
|
||||||
|
lowerAttempt,
|
||||||
|
for: endpoints.higherPeerID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let higherMessage1 = try #require(
|
||||||
|
endpoints.higher.claimHandshakeInitiation(
|
||||||
|
higherAttempt,
|
||||||
|
for: endpoints.lowerPeerID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
try endpoints.lower.processHandshakeMessage(
|
||||||
|
from: endpoints.higherPeerID,
|
||||||
|
message: higherMessage1
|
||||||
|
) == nil
|
||||||
|
)
|
||||||
|
let message2 = try #require(
|
||||||
|
try endpoints.higher.processHandshakeMessage(
|
||||||
|
from: endpoints.lowerPeerID,
|
||||||
|
message: lowerMessage1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try endpoints.lower.processHandshakeMessage(
|
||||||
|
from: endpoints.higherPeerID,
|
||||||
|
message: message2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ = try endpoints.higher.processHandshakeMessage(
|
||||||
|
from: endpoints.lowerPeerID,
|
||||||
|
message: message3
|
||||||
|
)
|
||||||
|
|
||||||
|
let ciphertext = try endpoints.lower.encrypt(
|
||||||
|
Data("crossed".utf8),
|
||||||
|
for: endpoints.higherPeerID
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
try endpoints.higher.decrypt(
|
||||||
|
ciphertext,
|
||||||
|
from: endpoints.lowerPeerID
|
||||||
|
) == Data("crossed".utf8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Delayed losing message one cannot replace the fresh winner")
|
||||||
|
func delayedCrossedInitiationIsSuppressed() throws {
|
||||||
|
let endpoints = orderedServices()
|
||||||
|
let lowerAttempt = try #require(
|
||||||
|
try endpoints.lower.initiateHandshakeIfNeeded(
|
||||||
|
with: endpoints.higherPeerID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let higherAttempt = try #require(
|
||||||
|
try endpoints.higher.initiateHandshakeIfNeeded(
|
||||||
|
with: endpoints.lowerPeerID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let lowerMessage1 = try #require(
|
||||||
|
endpoints.lower.claimHandshakeInitiation(
|
||||||
|
lowerAttempt,
|
||||||
|
for: endpoints.higherPeerID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let delayedHigherMessage1 = try #require(
|
||||||
|
endpoints.higher.claimHandshakeInitiation(
|
||||||
|
higherAttempt,
|
||||||
|
for: endpoints.lowerPeerID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let message2 = try #require(
|
||||||
|
try endpoints.higher.processHandshakeMessage(
|
||||||
|
from: endpoints.lowerPeerID,
|
||||||
|
message: lowerMessage1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try endpoints.lower.processHandshakeMessage(
|
||||||
|
from: endpoints.higherPeerID,
|
||||||
|
message: message2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
try endpoints.lower.processHandshakeMessage(
|
||||||
|
from: endpoints.higherPeerID,
|
||||||
|
message: delayedHigherMessage1
|
||||||
|
) == nil
|
||||||
|
)
|
||||||
|
_ = try endpoints.higher.processHandshakeMessage(
|
||||||
|
from: endpoints.lowerPeerID,
|
||||||
|
message: message3
|
||||||
|
)
|
||||||
|
|
||||||
|
let ciphertext = try endpoints.higher.encrypt(
|
||||||
|
Data("winner intact".utf8),
|
||||||
|
for: endpoints.lowerPeerID
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
try endpoints.lower.decrypt(
|
||||||
|
ciphertext,
|
||||||
|
from: endpoints.higherPeerID
|
||||||
|
) == Data("winner intact".utf8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Automatic rekey token dies if an inbound initiation wins first")
|
||||||
|
func automaticRekeyClaimsOnlyAtTransportHandoff() throws {
|
||||||
|
let endpoints = orderedServices()
|
||||||
|
try establishSessions(
|
||||||
|
alice: endpoints.lower,
|
||||||
|
bob: endpoints.higher
|
||||||
|
)
|
||||||
|
|
||||||
|
var preparedRekey: NoiseHandshakeInitiation?
|
||||||
|
endpoints.higher.onRekeyHandshakeReady = { _, initiation in
|
||||||
|
preparedRekey = initiation
|
||||||
|
}
|
||||||
|
try endpoints.higher._test_initiateAutomaticRekey(
|
||||||
|
for: endpoints.lowerPeerID
|
||||||
|
)
|
||||||
|
let staleRekey = try #require(preparedRekey)
|
||||||
|
|
||||||
|
let winningAttempt = try endpoints.lower.initiateReconnectHandshake(
|
||||||
|
with: endpoints.higherPeerID
|
||||||
|
)
|
||||||
|
let winningMessage1 = try #require(
|
||||||
|
endpoints.lower.claimHandshakeInitiation(
|
||||||
|
winningAttempt,
|
||||||
|
for: endpoints.higherPeerID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let message2 = try #require(
|
||||||
|
try endpoints.higher.processHandshakeMessage(
|
||||||
|
from: endpoints.lowerPeerID,
|
||||||
|
message: winningMessage1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
endpoints.higher.claimHandshakeInitiation(
|
||||||
|
staleRekey,
|
||||||
|
for: endpoints.lowerPeerID
|
||||||
|
) == nil
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try endpoints.lower.processHandshakeMessage(
|
||||||
|
from: endpoints.higherPeerID,
|
||||||
|
message: message2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ = try endpoints.higher.processHandshakeMessage(
|
||||||
|
from: endpoints.lowerPeerID,
|
||||||
|
message: message3
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
endpoints.higher.hasEstablishedSession(
|
||||||
|
with: endpoints.lowerPeerID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ordinary timeout produces exactly one bounded retry")
|
||||||
|
func ordinaryInitiationTimeoutIsBounded() async throws {
|
||||||
|
let service = NoiseEncryptionService(
|
||||||
|
keychain: MockKeychain(),
|
||||||
|
ordinaryHandshakeTimeout: 0.03
|
||||||
|
)
|
||||||
|
let peerID = PeerID(str: "1021324354657687")
|
||||||
|
let recorder = HandshakeStartRecorder()
|
||||||
|
service.onHandshakeRecoveryRequired = { [weak service] request in
|
||||||
|
guard let service else { return }
|
||||||
|
do {
|
||||||
|
let payload = try claimPreparedRecoveryPayload(
|
||||||
|
service,
|
||||||
|
request: request
|
||||||
|
)
|
||||||
|
recorder.recordTimeout()
|
||||||
|
recorder.record(message: payload)
|
||||||
|
} catch {
|
||||||
|
recorder.record(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let first = try #require(
|
||||||
|
try service.initiateHandshakeIfNeeded(
|
||||||
|
with: peerID,
|
||||||
|
retryOnTimeout: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
service.claimHandshakeInitiation(first, for: peerID) != nil
|
||||||
|
)
|
||||||
|
let retried = await TestHelpers.waitUntil(
|
||||||
|
{ recorder.messages.count == 1 },
|
||||||
|
timeout: 1
|
||||||
|
)
|
||||||
|
#expect(retried)
|
||||||
|
let retryExpired = await TestHelpers.waitUntil(
|
||||||
|
{ !service.hasSession(with: peerID) },
|
||||||
|
timeout: 1
|
||||||
|
)
|
||||||
|
#expect(retryExpired)
|
||||||
|
#expect(recorder.timeoutCount == 1)
|
||||||
|
#expect(recorder.errorCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Claim gives an attempt a full on-wire timeout window")
|
||||||
|
func handshakeClaimRearmsDeadline() async throws {
|
||||||
|
let service = NoiseEncryptionService(
|
||||||
|
keychain: MockKeychain(),
|
||||||
|
ordinaryHandshakeTimeout: 0.08
|
||||||
|
)
|
||||||
|
let peerID = PeerID(str: "1021324354657687")
|
||||||
|
let recorder = HandshakeStartRecorder()
|
||||||
|
service.onHandshakeRecoveryRequired = { [weak service] request in
|
||||||
|
service?.cancelHandshakeRecovery(request)
|
||||||
|
recorder.recordTimeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
let attempt = try #require(
|
||||||
|
try service.initiateHandshakeIfNeeded(
|
||||||
|
with: peerID,
|
||||||
|
retryOnTimeout: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
#expect(
|
||||||
|
service.claimHandshakeInitiation(attempt, for: peerID)
|
||||||
|
== attempt.payload
|
||||||
|
)
|
||||||
|
try? await Task.sleep(nanoseconds: 45_000_000)
|
||||||
|
#expect(service.hasSession(with: peerID))
|
||||||
|
#expect(recorder.timeoutCount == 0)
|
||||||
|
let expired = await TestHelpers.waitUntil(
|
||||||
|
{ recorder.timeoutCount == 1 },
|
||||||
|
timeout: 1
|
||||||
|
)
|
||||||
|
#expect(expired)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Duplicate spoofed message one cannot extend rollback or repause during cooldown")
|
||||||
|
func pacedMessageOneCannotHoldOutboundPaused() async throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(
|
||||||
|
keychain: MockKeychain(),
|
||||||
|
ordinaryResponderHandshakeTimeout: 0.06,
|
||||||
|
ordinaryReconnectRollbackCooldown: 0.3
|
||||||
|
)
|
||||||
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
|
let recovery = HandshakeStartRecorder()
|
||||||
|
bob.onHandshakeRecoveryRequired = { [weak bob] request in
|
||||||
|
bob?.cancelHandshakeRecovery(request)
|
||||||
|
recovery.recordTimeout()
|
||||||
|
}
|
||||||
|
try establishSessions(alice: alice, bob: bob)
|
||||||
|
|
||||||
|
let spoofedMessage1 = try mallory.initiateHandshake(with: bobPeerID)
|
||||||
|
_ = try #require(
|
||||||
|
try bob.processHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: spoofedMessage1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try? await Task.sleep(nanoseconds: 35_000_000)
|
||||||
|
_ = try #require(
|
||||||
|
try bob.processHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: spoofedMessage1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let restored = await TestHelpers.waitUntil(
|
||||||
|
{ bob.hasEstablishedSession(with: alicePeerID) },
|
||||||
|
timeout: 1
|
||||||
|
)
|
||||||
|
#expect(restored)
|
||||||
|
let callbackArrived = await TestHelpers.waitUntil(
|
||||||
|
{ recovery.timeoutCount == 1 },
|
||||||
|
timeout: 1
|
||||||
|
)
|
||||||
|
#expect(callbackArrived)
|
||||||
|
|
||||||
|
// Still inside cooldown: the same unauthenticated initiation is
|
||||||
|
// coalesced without removing the restored outbound generation.
|
||||||
|
#expect(
|
||||||
|
try bob.processHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: spoofedMessage1
|
||||||
|
) == nil
|
||||||
|
)
|
||||||
|
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
||||||
|
let ciphertext = try alice.encrypt(
|
||||||
|
Data("not repaused".utf8),
|
||||||
|
for: bobPeerID
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
try bob.decrypt(ciphertext, from: alicePeerID)
|
||||||
|
== Data("not repaused".utf8)
|
||||||
|
)
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
#expect(recovery.timeoutCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Lost reconnect message three restores then retries once")
|
||||||
|
func lostReconnectCompletionGetsOneLocalRetry() async throws {
|
||||||
|
let alice = NoiseEncryptionService(
|
||||||
|
keychain: MockKeychain(),
|
||||||
|
ordinaryHandshakeTimeout: 0.04
|
||||||
|
)
|
||||||
|
let bob = NoiseEncryptionService(
|
||||||
|
keychain: MockKeychain(),
|
||||||
|
ordinaryHandshakeTimeout: 0.04,
|
||||||
|
ordinaryResponderHandshakeTimeout: 0.04
|
||||||
|
)
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
|
try establishSessions(alice: alice, bob: bob)
|
||||||
|
alice.clearSession(for: bobPeerID)
|
||||||
|
|
||||||
|
let recovery = HandshakeStartRecorder()
|
||||||
|
bob.onHandshakeRecoveryRequired = { [weak bob] request in
|
||||||
|
guard let bob else { return }
|
||||||
|
do {
|
||||||
|
recovery.recordTimeout()
|
||||||
|
recovery.record(
|
||||||
|
message: try claimPreparedRecoveryPayload(
|
||||||
|
bob,
|
||||||
|
request: request
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
recovery.record(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let message1 = try alice.initiateHandshake(with: bobPeerID)
|
||||||
|
let message2 = try #require(
|
||||||
|
try bob.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||||
|
)
|
||||||
|
_ = try #require(
|
||||||
|
try alice.processHandshakeMessage(from: bobPeerID, message: message2)
|
||||||
|
)
|
||||||
|
// Drop message 3. Bob restores its old receive-only transport and
|
||||||
|
// initiates one bounded convergence retry; drop that message 1 too.
|
||||||
|
let retryPrepared = await TestHelpers.waitUntil(
|
||||||
|
{ recovery.messages.count == 1 },
|
||||||
|
timeout: 1
|
||||||
|
)
|
||||||
|
#expect(retryPrepared)
|
||||||
|
let retryExpired = await TestHelpers.waitUntil(
|
||||||
|
{ !bob.hasSession(with: alicePeerID) },
|
||||||
|
timeout: 1
|
||||||
|
)
|
||||||
|
#expect(retryExpired)
|
||||||
|
#expect(recovery.timeoutCount == 1)
|
||||||
|
#expect(recovery.errorCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Deterministic responder recovers once from an always-yield peer")
|
||||||
|
func yieldedResponderRecoversFromLegacyDoubleYield() async throws {
|
||||||
|
let endpoints = orderedServices(
|
||||||
|
ordinaryHandshakeTimeout: 0.08,
|
||||||
|
ordinaryResponderHandshakeTimeout: 0.08
|
||||||
|
)
|
||||||
|
let modern = endpoints.higher
|
||||||
|
let legacy = endpoints.lower
|
||||||
|
let recovery = HandshakeStartRecorder()
|
||||||
|
modern.onHandshakeRecoveryRequired = { [weak modern] request in
|
||||||
|
guard let modern else { return }
|
||||||
|
do {
|
||||||
|
recovery.recordTimeout()
|
||||||
|
recovery.record(
|
||||||
|
message: try claimPreparedRecoveryPayload(
|
||||||
|
modern,
|
||||||
|
request: request
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
recovery.record(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let modernAttempt = try #require(
|
||||||
|
try modern.initiateHandshakeIfNeeded(
|
||||||
|
with: endpoints.lowerPeerID,
|
||||||
|
retryOnTimeout: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let legacyAttempt = try #require(
|
||||||
|
try legacy.initiateHandshakeIfNeeded(
|
||||||
|
with: endpoints.higherPeerID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let modernMessage1 = try #require(
|
||||||
|
modern.claimHandshakeInitiation(
|
||||||
|
modernAttempt,
|
||||||
|
for: endpoints.lowerPeerID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let legacyMessage1 = try #require(
|
||||||
|
legacy.claimHandshakeInitiation(
|
||||||
|
legacyAttempt,
|
||||||
|
for: endpoints.higherPeerID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let modernMessage2 = try #require(
|
||||||
|
try modern.processHandshakeMessage(
|
||||||
|
from: endpoints.lowerPeerID,
|
||||||
|
message: legacyMessage1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// Released peers yielded regardless of ID. Clearing their local
|
||||||
|
// initiator reproduces that role choice without changing wire bytes.
|
||||||
|
legacy.clearSession(for: endpoints.higherPeerID)
|
||||||
|
let legacyMessage2 = try #require(
|
||||||
|
try legacy.processHandshakeMessage(
|
||||||
|
from: endpoints.higherPeerID,
|
||||||
|
message: modernMessage1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try modern.processHandshakeMessage(
|
||||||
|
from: endpoints.lowerPeerID,
|
||||||
|
message: legacyMessage2
|
||||||
|
)
|
||||||
|
Issue.record("Expected the crossed responder message to fail")
|
||||||
|
} catch is NoiseManagedHandshakeFailure {
|
||||||
|
// The manager owns the single retry.
|
||||||
|
} catch {
|
||||||
|
Issue.record("Unexpected managed failure: \(error)")
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
_ = try legacy.processHandshakeMessage(
|
||||||
|
from: endpoints.higherPeerID,
|
||||||
|
message: modernMessage2
|
||||||
|
)
|
||||||
|
Issue.record("Expected the legacy responder message to fail")
|
||||||
|
} catch {
|
||||||
|
// Expected; this side did not own retry intent.
|
||||||
|
}
|
||||||
|
|
||||||
|
let didRecover = await TestHelpers.waitUntil(
|
||||||
|
{ recovery.messages.count == 1 },
|
||||||
|
timeout: 1
|
||||||
|
)
|
||||||
|
#expect(didRecover)
|
||||||
|
let retryMessage1 = try #require(recovery.messages.first)
|
||||||
|
let retryMessage2 = try #require(
|
||||||
|
try legacy.processHandshakeMessage(
|
||||||
|
from: endpoints.higherPeerID,
|
||||||
|
message: retryMessage1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let retryMessage3 = try #require(
|
||||||
|
try modern.processHandshakeMessage(
|
||||||
|
from: endpoints.lowerPeerID,
|
||||||
|
message: retryMessage2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ = try legacy.processHandshakeMessage(
|
||||||
|
from: endpoints.higherPeerID,
|
||||||
|
message: retryMessage3
|
||||||
|
)
|
||||||
|
try? await Task.sleep(nanoseconds: 120_000_000)
|
||||||
|
#expect(recovery.timeoutCount == 1)
|
||||||
|
#expect(recovery.errorCount == 0)
|
||||||
|
let ciphertext = try modern.encrypt(
|
||||||
|
Data("legacy converged".utf8),
|
||||||
|
for: endpoints.lowerPeerID
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
try legacy.decrypt(
|
||||||
|
ciphertext,
|
||||||
|
from: endpoints.higherPeerID
|
||||||
|
) == Data("legacy converged".utf8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Immediate legacy restart during completion grace converges once")
|
||||||
|
func immediateLegacyRestartDuringCompletionGrace() async throws {
|
||||||
|
let firstKeychain = MockKeychain()
|
||||||
|
let secondKeychain = MockKeychain()
|
||||||
|
let first = NoiseEncryptionService(
|
||||||
|
keychain: firstKeychain,
|
||||||
|
recentInitiatorCompletionGracePeriod: 0.03
|
||||||
|
)
|
||||||
|
let second = NoiseEncryptionService(
|
||||||
|
keychain: secondKeychain,
|
||||||
|
recentInitiatorCompletionGracePeriod: 0.03
|
||||||
|
)
|
||||||
|
let firstPeerID = PeerID(publicKey: first.getStaticPublicKeyData())
|
||||||
|
let secondPeerID = PeerID(publicKey: second.getStaticPublicKeyData())
|
||||||
|
|
||||||
|
let lower: NoiseEncryptionService
|
||||||
|
let higher: NoiseEncryptionService
|
||||||
|
let higherKeychain: MockKeychain
|
||||||
|
let lowerPeerID: PeerID
|
||||||
|
let higherPeerID: PeerID
|
||||||
|
if firstPeerID < secondPeerID {
|
||||||
|
lower = first
|
||||||
|
lowerPeerID = firstPeerID
|
||||||
|
higher = second
|
||||||
|
higherKeychain = secondKeychain
|
||||||
|
higherPeerID = secondPeerID
|
||||||
|
} else {
|
||||||
|
lower = second
|
||||||
|
lowerPeerID = secondPeerID
|
||||||
|
higher = first
|
||||||
|
higherKeychain = firstKeychain
|
||||||
|
higherPeerID = firstPeerID
|
||||||
|
}
|
||||||
|
try establishSessions(alice: lower, bob: higher)
|
||||||
|
|
||||||
|
let restartedHigher = NoiseEncryptionService(keychain: higherKeychain)
|
||||||
|
let recovery = HandshakeStartRecorder()
|
||||||
|
lower.onHandshakeRecoveryRequired = { [weak lower] request in
|
||||||
|
guard let lower else { return }
|
||||||
|
do {
|
||||||
|
recovery.recordTimeout()
|
||||||
|
recovery.record(
|
||||||
|
message: try claimPreparedRecoveryPayload(
|
||||||
|
lower,
|
||||||
|
request: request
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
recovery.record(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let restartAttempt = try #require(
|
||||||
|
try restartedHigher.initiateHandshakeIfNeeded(with: lowerPeerID)
|
||||||
|
)
|
||||||
|
let restartMessage1 = try #require(
|
||||||
|
restartedHigher.claimHandshakeInitiation(
|
||||||
|
restartAttempt,
|
||||||
|
for: lowerPeerID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
try lower.processHandshakeMessage(
|
||||||
|
from: higherPeerID,
|
||||||
|
message: restartMessage1
|
||||||
|
) == nil
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
try lower.processHandshakeMessage(
|
||||||
|
from: higherPeerID,
|
||||||
|
message: restartMessage1
|
||||||
|
) == nil
|
||||||
|
)
|
||||||
|
#expect(lower.hasEstablishedSession(with: higherPeerID))
|
||||||
|
|
||||||
|
let requested = await TestHelpers.waitUntil(
|
||||||
|
{ recovery.messages.count == 1 },
|
||||||
|
timeout: 1
|
||||||
|
)
|
||||||
|
#expect(requested)
|
||||||
|
let retryMessage1 = try #require(recovery.messages.first)
|
||||||
|
let retryMessage2 = try #require(
|
||||||
|
try restartedHigher.processHandshakeMessage(
|
||||||
|
from: lowerPeerID,
|
||||||
|
message: retryMessage1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let retryMessage3 = try #require(
|
||||||
|
try lower.processHandshakeMessage(
|
||||||
|
from: higherPeerID,
|
||||||
|
message: retryMessage2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ = try restartedHigher.processHandshakeMessage(
|
||||||
|
from: lowerPeerID,
|
||||||
|
message: retryMessage3
|
||||||
|
)
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
#expect(recovery.timeoutCount == 1)
|
||||||
|
#expect(recovery.errorCount == 0)
|
||||||
|
let ciphertext = try restartedHigher.encrypt(
|
||||||
|
Data("restart converged".utf8),
|
||||||
|
for: lowerPeerID
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
try lower.decrypt(ciphertext, from: higherPeerID)
|
||||||
|
== Data("restart converged".utf8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Atomic reconnect retires old sending keys before message one")
|
@Test("Atomic reconnect retires old sending keys before message one")
|
||||||
func atomicReconnectQueuesUntilOrdinaryHandshakeCompletes() throws {
|
func atomicReconnectQueuesUntilOrdinaryHandshakeCompletes() throws {
|
||||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
@@ -453,6 +1150,31 @@ struct NoiseEncryptionServiceTests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Malformed handshake bytes cannot tear down an established session")
|
||||||
|
func establishedSessionIgnoresNonInitialHandshakeGarbage() throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
|
try establishSessions(alice: alice, bob: bob)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
try bob.processHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: Data(repeating: 0xA5, count: 31)
|
||||||
|
) == nil
|
||||||
|
)
|
||||||
|
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
||||||
|
let ciphertext = try alice.encrypt(
|
||||||
|
Data("session survived".utf8),
|
||||||
|
for: bobPeerID
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
try bob.decrypt(ciphertext, from: alicePeerID)
|
||||||
|
== Data("session survived".utf8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Forged reconnect restores the quarantined transport")
|
@Test("Forged reconnect restores the quarantined transport")
|
||||||
func forgedReconnectRestoresQuarantinedTransport() throws {
|
func forgedReconnectRestoresQuarantinedTransport() throws {
|
||||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
@@ -661,6 +1383,56 @@ struct NoiseEncryptionServiceTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func orderedServices(
|
||||||
|
ordinaryHandshakeTimeout: TimeInterval =
|
||||||
|
NoiseSecurityConstants.ordinaryHandshakeTimeout,
|
||||||
|
ordinaryResponderHandshakeTimeout: TimeInterval =
|
||||||
|
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
||||||
|
) -> (
|
||||||
|
lower: NoiseEncryptionService,
|
||||||
|
lowerPeerID: PeerID,
|
||||||
|
higher: NoiseEncryptionService,
|
||||||
|
higherPeerID: PeerID
|
||||||
|
) {
|
||||||
|
let first = NoiseEncryptionService(
|
||||||
|
keychain: MockKeychain(),
|
||||||
|
ordinaryHandshakeTimeout: ordinaryHandshakeTimeout,
|
||||||
|
ordinaryResponderHandshakeTimeout:
|
||||||
|
ordinaryResponderHandshakeTimeout
|
||||||
|
)
|
||||||
|
let second = NoiseEncryptionService(
|
||||||
|
keychain: MockKeychain(),
|
||||||
|
ordinaryHandshakeTimeout: ordinaryHandshakeTimeout,
|
||||||
|
ordinaryResponderHandshakeTimeout:
|
||||||
|
ordinaryResponderHandshakeTimeout
|
||||||
|
)
|
||||||
|
let firstPeerID = PeerID(publicKey: first.getStaticPublicKeyData())
|
||||||
|
let secondPeerID = PeerID(publicKey: second.getStaticPublicKeyData())
|
||||||
|
if firstPeerID < secondPeerID {
|
||||||
|
return (first, firstPeerID, second, secondPeerID)
|
||||||
|
}
|
||||||
|
return (second, secondPeerID, first, firstPeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func claimPreparedRecoveryPayload(
|
||||||
|
_ service: NoiseEncryptionService,
|
||||||
|
request: NoiseHandshakeRecoveryRequest
|
||||||
|
) throws -> Data? {
|
||||||
|
guard let preparation =
|
||||||
|
try service.prepareHandshakeRecovery(request) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch preparation {
|
||||||
|
case .ordinary(let initiation):
|
||||||
|
return service.claimHandshakeInitiation(
|
||||||
|
initiation,
|
||||||
|
for: request.peerID
|
||||||
|
)
|
||||||
|
case .transferred:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private final class AuthenticationRecorder: @unchecked Sendable {
|
private final class AuthenticationRecorder: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var entries: [(PeerID, String)] = []
|
private var entries: [(PeerID, String)] = []
|
||||||
@@ -696,3 +1468,78 @@ private final class AuthenticationRecorder: @unchecked Sendable {
|
|||||||
return generationEntries.last { $0.0 == peerID }?.1
|
return generationEntries.last { $0.0 == peerID }?.1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class HandshakeInitiationRecorder: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var storedInitiations: [NoiseHandshakeInitiation] = []
|
||||||
|
private var storedErrorCount = 0
|
||||||
|
|
||||||
|
var initiations: [NoiseHandshakeInitiation] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return storedInitiations
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorCount: Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return storedErrorCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func record(_ initiation: NoiseHandshakeInitiation?) {
|
||||||
|
guard let initiation else { return }
|
||||||
|
lock.lock()
|
||||||
|
storedInitiations.append(initiation)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func record(error _: Error) {
|
||||||
|
lock.lock()
|
||||||
|
storedErrorCount += 1
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class HandshakeStartRecorder: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var storedMessages: [Data] = []
|
||||||
|
private var storedErrorCount = 0
|
||||||
|
private var storedTimeoutCount = 0
|
||||||
|
|
||||||
|
var messages: [Data] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return storedMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorCount: Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return storedErrorCount
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeoutCount: Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return storedTimeoutCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func record(message: Data?) {
|
||||||
|
guard let message else { return }
|
||||||
|
lock.lock()
|
||||||
|
storedMessages.append(message)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func record(error _: Error) {
|
||||||
|
lock.lock()
|
||||||
|
storedErrorCount += 1
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordTimeout() {
|
||||||
|
lock.lock()
|
||||||
|
storedTimeoutCount += 1
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user