Fix ordinary Noise handshake races

This commit is contained in:
jack
2026-07-25 23:49:41 +02:00
parent 121d16ee39
commit 9a14d0dadd
10 changed files with 1850 additions and 129 deletions
@@ -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
+6
View File
@@ -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
}
+699 -62
View File
@@ -21,6 +21,16 @@ struct NoiseHandshakeInitiation: Equatable, Sendable {
let attemptID: UUID let attemptID: UUID
} }
struct NoiseHandshakeRecoveryRequest: Equatable, Sendable {
let peerID: PeerID
fileprivate let recoveryID: UUID
}
enum NoiseHandshakeRecoveryPreparation {
case ordinary(NoiseHandshakeInitiation)
case transferred
}
final class NoiseSessionManager { final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:] private var sessions: [PeerID: NoiseSession] = [:]
/// Opaque identity for each exact entry in `sessions`. The generation is /// Opaque identity for each exact entry in `sessions`. The generation is
@@ -30,6 +40,13 @@ final class NoiseSessionManager {
/// One-time handoff tokens prevent a prepared XX message 1 from leaving /// One-time handoff tokens prevent a prepared XX message 1 from leaving
/// after an inbound collision has already changed this peer's role. /// after an inbound collision has already changed this peer's role.
private var ordinaryInitiationIDs: [PeerID: UUID] = [:] private var ordinaryInitiationIDs: [PeerID: UUID] = [:]
private var ordinaryInitiatorTimeouts: [PeerID: DispatchWorkItem] = [:]
private var ordinaryInitiatorRetryNotifications: [PeerID: Bool] = [:]
private var ordinaryResponderTimeouts: [PeerID: DispatchWorkItem] = [:]
private var ordinaryResponderDeadlines: [PeerID: DispatchTime] = [:]
private var ordinaryResponderRetryNotifications: [PeerID: Bool] = [:]
private var ordinaryRespondersCreatedByYield: Set<PeerID> = []
private var recentOrdinaryInitiatorCompletions: [PeerID: Date] = [:]
private struct QuarantinedTransport { private struct QuarantinedTransport {
let session: NoiseSession let session: NoiseSession
let generation: UUID let generation: UUID
@@ -43,23 +60,44 @@ final class NoiseSessionManager {
/// claimed static identity, then is discarded on success or restored on /// claimed static identity, then is discarded on success or restored on
/// bounded failure. /// bounded failure.
private var quarantinedTransports: [PeerID: QuarantinedTransport] = [:] private var quarantinedTransports: [PeerID: QuarantinedTransport] = [:]
private var quarantinedResponderTimeouts: [PeerID: DispatchWorkItem] = [:] private var quarantineRollbackCooldownUntil: [PeerID: Date] = [:]
private var suppressedInitiationRecoveryTimeouts: [PeerID: DispatchWorkItem] = [:]
private var delayedHandshakeRecoveryWorkItems: [PeerID: DispatchWorkItem] = [:]
private var handshakeRecoveryCallbackIDs: [PeerID: UUID] = [:]
private var pendingHandshakeRecoveryIDs: [PeerID: UUID] = [:]
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
private let localPeerID: PeerID
private let ordinaryHandshakeTimeout: TimeInterval
private let ordinaryResponderHandshakeTimeout: TimeInterval private let ordinaryResponderHandshakeTimeout: TimeInterval
private let recentInitiatorCompletionGracePeriod: TimeInterval
private let ordinaryReconnectRollbackCooldown: TimeInterval
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent) private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
// Callbacks // Callbacks
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey, UUID) -> Void)? var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey, UUID) -> Void)?
var onSessionRestored: ((PeerID, UUID) -> Void)? var onSessionRestored: ((PeerID, UUID) -> Void)?
var onSessionFailed: ((PeerID, Error) -> Void)? var onSessionFailed: ((PeerID, Error) -> Void)?
var onHandshakeRecoveryRequired: ((NoiseHandshakeRecoveryRequest) -> Void)?
init( init(
localStaticKey: Curve25519.KeyAgreement.PrivateKey, localStaticKey: Curve25519.KeyAgreement.PrivateKey,
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.localPeerID = PeerID(publicKey: localStaticKey.publicKey.rawRepresentation)
self.ordinaryHandshakeTimeout = ordinaryHandshakeTimeout
self.ordinaryResponderHandshakeTimeout = ordinaryResponderHandshakeTimeout self.ordinaryResponderHandshakeTimeout = ordinaryResponderHandshakeTimeout
self.recentInitiatorCompletionGracePeriod =
recentInitiatorCompletionGracePeriod
self.ordinaryReconnectRollbackCooldown =
ordinaryReconnectRollbackCooldown
self.sessionFactory = { peerID, role in self.sessionFactory = { peerID, role in
SecureNoiseSession( SecureNoiseSession(
peerID: peerID, peerID: peerID,
@@ -72,13 +110,25 @@ final class NoiseSessionManager {
#if DEBUG #if DEBUG
init( init(
localStaticKey _: Curve25519.KeyAgreement.PrivateKey, localStaticKey: Curve25519.KeyAgreement.PrivateKey,
keychain _: KeychainManagerProtocol, keychain _: KeychainManagerProtocol,
ordinaryHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryHandshakeTimeout,
ordinaryResponderHandshakeTimeout: TimeInterval = ordinaryResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout, NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
recentInitiatorCompletionGracePeriod: TimeInterval =
NoiseSecurityConstants.recentInitiatorCompletionGracePeriod,
ordinaryReconnectRollbackCooldown: TimeInterval =
NoiseSecurityConstants.ordinaryReconnectRollbackCooldown,
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
) { ) {
self.localPeerID = PeerID(publicKey: localStaticKey.publicKey.rawRepresentation)
self.ordinaryHandshakeTimeout = ordinaryHandshakeTimeout
self.ordinaryResponderHandshakeTimeout = ordinaryResponderHandshakeTimeout self.ordinaryResponderHandshakeTimeout = ordinaryResponderHandshakeTimeout
self.recentInitiatorCompletionGracePeriod =
recentInitiatorCompletionGracePeriod
self.ordinaryReconnectRollbackCooldown =
ordinaryReconnectRollbackCooldown
self.sessionFactory = sessionFactory self.sessionFactory = sessionFactory
} }
#endif #endif
@@ -91,6 +141,110 @@ final class NoiseSessionManager {
} }
} }
/// Transfers one bounded recovery generation to whatever ordinary XX
/// handshake currently owns the peer, or starts the generation's single
/// retry. The request token prevents stale transport callbacks from
/// creating additional attempts.
func prepareHandshakeRecovery(
_ request: NoiseHandshakeRecoveryRequest,
authorizeAttempt: () throws -> Void
) throws -> NoiseHandshakeRecoveryPreparation? {
try managerQueue.sync(flags: .barrier) {
let peerID = request.peerID
guard pendingHandshakeRecoveryIDs[peerID] == request.recoveryID else {
return nil
}
if let current = sessions[peerID],
current.getState() == .handshaking {
if current.role == .initiator {
scheduleOrdinaryInitiatorTimeoutLocked(
current,
for: peerID,
notifyOnTimeout: true
)
} else {
// A recovery may transfer to a responder that raced ahead
// of the callback. Preserve an existing quarantine deadline
// so duplicate unauthenticated message 1 packets cannot
// extend the outbound pause.
scheduleOrdinaryResponderTimeoutLocked(
current,
for: peerID,
notifyOnTimeout: true,
createdByYield: true,
rearmDeadline: quarantinedTransports[peerID] == nil
)
}
return .transferred
}
do {
try authorizeAttempt()
} catch {
redispatchHandshakeRecoveryLocked(
request,
after: NoiseSecurityConstants.handshakeRateLimitRecoveryDelay
)
throw error
}
let next = sessionFactory(peerID, .initiator)
let payload: Data
do {
payload = try next.startHandshake()
} catch {
next.reset()
redispatchHandshakeRecoveryLocked(
request,
after: NoiseSecurityConstants.handshakeCollisionRecoveryDelay
)
throw error
}
// Start before retiring a working transport. If preparation fails,
// the established session and its generation are untouched.
if let current = sessions.removeValue(forKey: peerID) {
current.reset()
}
if let quarantined = quarantinedTransports.removeValue(forKey: peerID) {
quarantined.session.reset()
}
sessionGenerations.removeValue(forKey: peerID)
ordinaryInitiationIDs.removeValue(forKey: peerID)
cancelOrdinaryInitiatorTimeoutLocked(for: peerID)
cancelOrdinaryResponderTimeoutLocked(for: peerID)
cancelSuppressedInitiationRecoveryLocked(for: peerID)
recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID)
quarantineRollbackCooldownUntil.removeValue(forKey: peerID)
let attemptID = UUID()
sessions[peerID] = next
sessionGenerations[peerID] = UUID()
ordinaryInitiationIDs[peerID] = attemptID
// This is the one retry owned by `request`; it may time out but
// must not recursively mint another generation.
scheduleOrdinaryInitiatorTimeoutLocked(
next,
for: peerID,
notifyOnTimeout: false
)
consumeHandshakeRecoveryLocked(request)
return .ordinary(
NoiseHandshakeInitiation(
payload: payload,
attemptID: attemptID
)
)
}
}
func cancelHandshakeRecovery(_ request: NoiseHandshakeRecoveryRequest) {
managerQueue.sync(flags: .barrier) {
consumeHandshakeRecoveryLocked(request)
}
}
func removeSession(for peerID: PeerID) { func removeSession(for peerID: PeerID) {
managerQueue.sync(flags: .barrier) { managerQueue.sync(flags: .barrier) {
removeSessionLocked(for: peerID) removeSessionLocked(for: peerID)
@@ -104,9 +258,14 @@ final class NoiseSessionManager {
if let quarantined = quarantinedTransports.removeValue(forKey: peerID) { if let quarantined = quarantinedTransports.removeValue(forKey: peerID) {
quarantined.session.reset() quarantined.session.reset()
} }
quarantinedResponderTimeouts.removeValue(forKey: peerID)?.cancel()
sessionGenerations.removeValue(forKey: peerID) sessionGenerations.removeValue(forKey: peerID)
ordinaryInitiationIDs.removeValue(forKey: peerID) ordinaryInitiationIDs.removeValue(forKey: peerID)
cancelOrdinaryInitiatorTimeoutLocked(for: peerID)
cancelOrdinaryResponderTimeoutLocked(for: peerID)
recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID)
quarantineRollbackCooldownUntil.removeValue(forKey: peerID)
cancelSuppressedInitiationRecoveryLocked(for: peerID)
cancelDelayedHandshakeRecoveryLocked(for: peerID)
} }
func removeAllSessions() { func removeAllSessions() {
@@ -117,14 +276,34 @@ final class NoiseSessionManager {
for (_, quarantined) in quarantinedTransports { for (_, quarantined) in quarantinedTransports {
quarantined.session.reset() quarantined.session.reset()
} }
for (_, timeout) in quarantinedResponderTimeouts { for (_, timeout) in ordinaryInitiatorTimeouts {
timeout.cancel()
}
for (_, timeout) in ordinaryResponderTimeouts {
timeout.cancel()
}
for (_, timeout) in suppressedInitiationRecoveryTimeouts {
timeout.cancel()
}
for (_, timeout) in delayedHandshakeRecoveryWorkItems {
timeout.cancel() timeout.cancel()
} }
sessions.removeAll() sessions.removeAll()
sessionGenerations.removeAll() sessionGenerations.removeAll()
ordinaryInitiationIDs.removeAll() ordinaryInitiationIDs.removeAll()
ordinaryInitiatorTimeouts.removeAll()
ordinaryInitiatorRetryNotifications.removeAll()
ordinaryResponderTimeouts.removeAll()
ordinaryResponderDeadlines.removeAll()
ordinaryResponderRetryNotifications.removeAll()
ordinaryRespondersCreatedByYield.removeAll()
recentOrdinaryInitiatorCompletions.removeAll()
quarantinedTransports.removeAll() quarantinedTransports.removeAll()
quarantinedResponderTimeouts.removeAll() quarantineRollbackCooldownUntil.removeAll()
suppressedInitiationRecoveryTimeouts.removeAll()
delayedHandshakeRecoveryWorkItems.removeAll()
handshakeRecoveryCallbackIDs.removeAll()
pendingHandshakeRecoveryIDs.removeAll()
} }
} }
@@ -140,11 +319,9 @@ final class NoiseSessionManager {
// Remove any existing non-established session // Remove any existing non-established session
if let existingSession = sessions[peerID], !existingSession.isEstablished() { if let existingSession = sessions[peerID], !existingSession.isEstablished() {
_ = sessions.removeValue(forKey: peerID) removeSessionLocked(for: peerID)
sessionGenerations.removeValue(forKey: peerID)
ordinaryInitiationIDs.removeValue(forKey: peerID)
existingSession.reset()
} }
recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID)
// Create new initiator session // Create new initiator session
let session = sessionFactory(peerID, .initiator) let session = sessionFactory(peerID, .initiator)
@@ -153,12 +330,19 @@ final class NoiseSessionManager {
do { do {
let handshakeData = try session.startHandshake() let handshakeData = try session.startHandshake()
scheduleOrdinaryInitiatorTimeoutLocked(
session,
for: peerID,
notifyOnTimeout: false
)
cancelDelayedHandshakeRecoveryLocked(for: peerID)
return handshakeData return handshakeData
} catch { } catch {
// Clean up failed session // Clean up failed session
_ = sessions.removeValue(forKey: peerID) _ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID) sessionGenerations.removeValue(forKey: peerID)
ordinaryInitiationIDs.removeValue(forKey: peerID) ordinaryInitiationIDs.removeValue(forKey: peerID)
cancelOrdinaryInitiatorTimeoutLocked(for: peerID)
session.reset() session.reset()
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription)) SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
throw error throw error
@@ -166,12 +350,61 @@ final class NoiseSessionManager {
} }
} }
/// Atomically starts an ordinary initiator only when no other session
/// already owns the peer. BLE discovery can report the same peer on
/// multiple links; combining the absence check, authorization, creation,
/// and handoff token prevents two different message 1 packets escaping.
func initiateHandshakeIfAbsent(
with peerID: PeerID,
notifyOnTimeout: Bool,
authorize: () throws -> Void
) throws -> NoiseHandshakeInitiation? {
try managerQueue.sync(flags: .barrier) {
guard sessions[peerID] == nil,
quarantinedTransports[peerID] == nil else {
return nil
}
try authorize()
let session = sessionFactory(peerID, .initiator)
do {
let payload = try session.startHandshake()
let attemptID = UUID()
sessions[peerID] = session
sessionGenerations[peerID] = UUID()
ordinaryInitiationIDs[peerID] = attemptID
recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID)
quarantineRollbackCooldownUntil.removeValue(forKey: peerID)
scheduleOrdinaryInitiatorTimeoutLocked(
session,
for: peerID,
notifyOnTimeout: notifyOnTimeout
)
cancelDelayedHandshakeRecoveryLocked(for: peerID)
return NoiseHandshakeInitiation(
payload: payload,
attemptID: attemptID
)
} catch {
session.reset()
SecureLogger.error(
.handshakeFailed(
peerID: peerID.id,
error: error.localizedDescription
)
)
throw error
}
}
}
/// Prepares an ordinary reconnect before atomically retiring the current /// Prepares an ordinary reconnect before atomically retiring the current
/// transport. Authorization and handshake-start failure leave the working /// transport. Authorization and handshake-start failure leave the working
/// session untouched. Once this returns, encryption observes only the new /// session untouched. Once this returns, encryption observes only the new
/// handshaking session and must queue until it establishes. /// handshaking session and must queue until it establishes.
func initiateReconnectHandshake( func initiateReconnectHandshake(
with peerID: PeerID, with peerID: PeerID,
notifyOnTimeout: Bool,
authorize: () throws -> Void authorize: () throws -> Void
) throws -> NoiseHandshakeInitiation { ) throws -> NoiseHandshakeInitiation {
try managerQueue.sync(flags: .barrier) { try managerQueue.sync(flags: .barrier) {
@@ -196,11 +429,29 @@ final class NoiseSessionManager {
throw error throw error
} }
removeSessionLocked(for: peerID) // Retire only after the replacement initiator has successfully
// produced message 1. Do not use the broad removal helper here:
// this transition owns the exact new session installed below.
_ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
ordinaryInitiationIDs.removeValue(forKey: peerID)
cancelOrdinaryInitiatorTimeoutLocked(for: peerID)
cancelOrdinaryResponderTimeoutLocked(for: peerID)
cancelSuppressedInitiationRecoveryLocked(for: peerID)
cancelDelayedHandshakeRecoveryLocked(for: peerID)
recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID)
quarantineRollbackCooldownUntil.removeValue(forKey: peerID)
established.reset()
let attemptID = UUID() let attemptID = UUID()
sessions[peerID] = next sessions[peerID] = next
sessionGenerations[peerID] = UUID() sessionGenerations[peerID] = UUID()
ordinaryInitiationIDs[peerID] = attemptID ordinaryInitiationIDs[peerID] = attemptID
scheduleOrdinaryInitiatorTimeoutLocked(
next,
for: peerID,
notifyOnTimeout: notifyOnTimeout
)
return NoiseHandshakeInitiation( return NoiseHandshakeInitiation(
payload: payload, payload: payload,
attemptID: attemptID attemptID: attemptID
@@ -222,6 +473,16 @@ final class NoiseSessionManager {
return nil return nil
} }
ordinaryInitiationIDs.removeValue(forKey: peerID) ordinaryInitiationIDs.removeValue(forKey: peerID)
let notifyOnTimeout =
ordinaryInitiatorRetryNotifications[peerID] ?? false
// Preparation and on-wire exchange have independent bounds. Once
// BLE owns these exact bytes, give the peer the full response
// window without changing retry ownership.
scheduleOrdinaryInitiatorTimeoutLocked(
session,
for: peerID,
notifyOnTimeout: notifyOnTimeout
)
return initiation.payload return initiation.payload
} }
} }
@@ -253,21 +514,112 @@ final class NoiseSessionManager {
generation: UUID generation: UUID
)? )?
) = try managerQueue.sync(flags: .barrier) { ) = try managerQueue.sync(flags: .barrier) {
let session: NoiseSession var yieldedInitiatorShouldRetry = false
var didYieldLocalInitiator = false
var inheritedResponderShouldRetry = false
var inheritedResponderWasCreatedByYield = false
let existingAtIngress = sessions[peerID]
let isFreshInitiation =
message.count == NoiseSecurityConstants.xxInitialMessageSize
|| existingAtIngress == nil
|| (
existingAtIngress?.isEstablished() == true
&& message.count
> NoiseSecurityConstants.xxInitialMessageSize
)
if let existing = sessions[peerID], if isFreshInitiation {
existing.isEstablished() if let cooldownUntil = quarantineRollbackCooldownUntil[peerID] {
|| message.count if cooldownUntil > Date(),
== NoiseSecurityConstants.xxInitialMessageSize { sessions[peerID]?.isEstablished() == true {
if existing.isEstablished(), SecureLogger.debug(
let generation = sessionGenerations[peerID] { "Ignoring unauthenticated reconnect initiation during rollback cooldown for \(peerID)",
// Message 1 cannot prove that the remote still owns its category: .session
// old keys. Remove them from every sending/generation API )
// immediately, but retain the object solely for bounded return (nil, nil)
// rollback until the ordinary responder authenticates. }
quarantineRollbackCooldownUntil.removeValue(forKey: peerID)
}
if suppressedInitiationRecoveryTimeouts[peerID] != nil,
localPeerID < peerID.toShort(),
sessions[peerID]?.isEstablished() == true {
SecureLogger.debug(
"Coalescing duplicate initiation while convergence recovery is pending for \(peerID)",
category: .session
)
return (nil, nil)
}
if let completedAt = recentOrdinaryInitiatorCompletions[peerID] {
let stillInGrace = Date().timeIntervalSince(completedAt)
< recentInitiatorCompletionGracePeriod
if stillInGrace,
localPeerID < peerID.toShort(),
let established = sessions[peerID],
established.isEstablished() {
recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID)
scheduleSuppressedInitiationRecoveryLocked(
established,
completedAt: completedAt,
for: peerID
)
SecureLogger.debug(
"Deferring delayed crossed initiation from \(peerID) after initiator completion",
category: .session
)
return (nil, nil)
}
recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID)
}
if let ordinaryInitiator = sessions[peerID],
ordinaryInitiator.role == .initiator,
ordinaryInitiator.getState() == .handshaking {
if localPeerID < peerID.toShort() {
SecureLogger.debug(
"Ignoring crossed ordinary initiation from \(peerID); keeping deterministic initiator role",
category: .session
)
return (nil, nil)
}
yieldedInitiatorShouldRetry =
ordinaryInitiatorRetryNotifications[peerID] ?? false
didYieldLocalInitiator = true
_ = sessions.removeValue(forKey: peerID) _ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID) sessionGenerations.removeValue(forKey: peerID)
ordinaryInitiationIDs.removeValue(forKey: peerID) ordinaryInitiationIDs.removeValue(forKey: peerID)
cancelOrdinaryInitiatorTimeoutLocked(for: peerID)
cancelOrdinaryResponderTimeoutLocked(for: peerID)
ordinaryInitiator.reset()
}
}
let session: NoiseSession
if let existing = sessions[peerID] {
if existing.isEstablished(),
!isFreshInitiation {
// An established ordinary XX transport has no remaining
// handshake messages to consume. Unauthenticated garbage
// must not tear down its working keys.
SecureLogger.debug(
"Ignoring non-initial handshake bytes for established peer \(peerID)",
category: .session
)
return (nil, nil)
}
if isFreshInitiation {
if existing.isEstablished(),
let generation = sessionGenerations[peerID] {
// Message 1 is unauthenticated. Remove the old
// transport from every outbound/generation API now,
// retaining it only as receive-only rollback state.
_ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
ordinaryInitiationIDs.removeValue(forKey: peerID)
cancelOrdinaryInitiatorTimeoutLocked(for: peerID)
cancelOrdinaryResponderTimeoutLocked(for: peerID)
if let prior = quarantinedTransports.updateValue( if let prior = quarantinedTransports.updateValue(
QuarantinedTransport( QuarantinedTransport(
session: existing, session: existing,
@@ -280,23 +632,30 @@ final class NoiseSessionManager {
prior.session.reset() prior.session.reset()
} }
} else { } else {
inheritedResponderShouldRetry =
ordinaryResponderRetryNotifications[peerID] ?? false
inheritedResponderWasCreatedByYield =
ordinaryRespondersCreatedByYield.contains(peerID)
_ = sessions.removeValue(forKey: peerID) _ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID) sessionGenerations.removeValue(forKey: peerID)
ordinaryInitiationIDs.removeValue(forKey: peerID) ordinaryInitiationIDs.removeValue(forKey: peerID)
// Keep the first responder/quarantine deadline. A
// repeated message 1 may refresh message 2, but never
// refreshes the attacker's outbound-pause budget.
cancelOrdinaryResponderTimeoutLocked(
for: peerID,
preserveDeadline: true
)
existing.reset() existing.reset()
} }
let replacement = sessionFactory(peerID, .responder) let replacement = sessionFactory(peerID, .responder)
sessions[peerID] = replacement sessions[peerID] = replacement
sessionGenerations[peerID] = UUID() sessionGenerations[peerID] = UUID()
session = replacement session = replacement
if quarantinedTransports[peerID] != nil { } else {
scheduleQuarantinedResponderTimeoutLocked(
replacement,
for: peerID
)
}
} else if let existing = sessions[peerID] {
session = existing session = existing
}
} else { } else {
let newSession = sessionFactory(peerID, .responder) let newSession = sessionFactory(peerID, .responder)
sessions[peerID] = newSession sessions[peerID] = newSession
@@ -304,8 +663,21 @@ final class NoiseSessionManager {
session = newSession session = newSession
} }
// Process the handshake message within the synchronized block
do { do {
if isFreshInitiation,
session.role == .responder {
scheduleOrdinaryResponderTimeoutLocked(
session,
for: peerID,
notifyOnTimeout:
yieldedInitiatorShouldRetry
|| inheritedResponderShouldRetry,
createdByYield:
didYieldLocalInitiator
|| inheritedResponderWasCreatedByYield
)
}
let response = try session.processHandshakeMessage(message) let response = try session.processHandshakeMessage(message)
// Check the exact session that processed this message. A // Check the exact session that processed this message. A
@@ -324,12 +696,21 @@ final class NoiseSessionManager {
if let quarantined = quarantinedTransports.removeValue( if let quarantined = quarantinedTransports.removeValue(
forKey: peerID forKey: peerID
) { ) {
quarantinedResponderTimeouts.removeValue(
forKey: peerID
)?.cancel()
quarantined.session.reset() quarantined.session.reset()
} }
ordinaryInitiationIDs.removeValue(forKey: peerID) ordinaryInitiationIDs.removeValue(forKey: peerID)
cancelOrdinaryInitiatorTimeoutLocked(for: peerID)
cancelOrdinaryResponderTimeoutLocked(for: peerID)
quarantineRollbackCooldownUntil.removeValue(forKey: peerID)
if session.role == .initiator {
recentOrdinaryInitiatorCompletions[peerID] = Date()
} else {
recentOrdinaryInitiatorCompletions.removeValue(
forKey: peerID
)
}
cancelSuppressedInitiationRecoveryLocked(for: peerID)
cancelDelayedHandshakeRecoveryLocked(for: peerID)
guard let generation = sessionGenerations[peerID] else { guard let generation = sessionGenerations[peerID] else {
throw NoiseEncryptionError.sessionNotEstablished throw NoiseEncryptionError.sessionNotEstablished
} }
@@ -338,19 +719,36 @@ final class NoiseSessionManager {
return (response, establishedSession) return (response, establishedSession)
} catch { } catch {
var shouldRequestRecovery = false
var shouldSuppressImmediateHandlerRestart = false
if session.role == .initiator {
shouldRequestRecovery =
ordinaryInitiatorRetryNotifications[peerID] ?? false
shouldSuppressImmediateHandlerRestart = true
} else {
shouldRequestRecovery =
ordinaryResponderRetryNotifications[peerID] ?? false
shouldSuppressImmediateHandlerRestart =
ordinaryRespondersCreatedByYield.contains(peerID)
}
if let storedSession = sessions[peerID], if let storedSession = sessions[peerID],
storedSession === session { storedSession === session {
_ = sessions.removeValue(forKey: peerID) _ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID) sessionGenerations.removeValue(forKey: peerID)
} }
ordinaryInitiationIDs.removeValue(forKey: peerID) ordinaryInitiationIDs.removeValue(forKey: peerID)
cancelOrdinaryInitiatorTimeoutLocked(for: peerID)
cancelOrdinaryResponderTimeoutLocked(for: peerID)
recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID)
session.reset() session.reset()
quarantinedResponderTimeouts.removeValue(forKey: peerID)?.cancel()
let restoredGeneration: UUID? let restoredGeneration: UUID?
if let quarantined = quarantinedTransports.removeValue(forKey: peerID) { if let quarantined = quarantinedTransports.removeValue(forKey: peerID) {
sessions[peerID] = quarantined.session sessions[peerID] = quarantined.session
sessionGenerations[peerID] = quarantined.generation sessionGenerations[peerID] = quarantined.generation
restoredGeneration = quarantined.generation restoredGeneration = quarantined.generation
markRollbackCooldownLocked(for: peerID)
} else { } else {
restoredGeneration = nil restoredGeneration = nil
} }
@@ -363,7 +761,33 @@ final class NoiseSessionManager {
self?.onSessionFailed?(peerID, error) self?.onSessionFailed?(peerID, error)
} }
let isIdentityMismatch =
(error as? NoiseSessionError) == .peerIdentityMismatch
if pendingHandshakeRecoveryIDs[peerID] != nil {
shouldSuppressImmediateHandlerRestart = true
}
if shouldRequestRecovery, !isIdentityMismatch {
requestHandshakeRecovery(
for: peerID,
after: NoiseSecurityConstants
.handshakeCollisionRecoveryDelay
)
} else if isIdentityMismatch,
let recoveryID = pendingHandshakeRecoveryIDs[peerID] {
redispatchHandshakeRecoveryLocked(
NoiseHandshakeRecoveryRequest(
peerID: peerID,
recoveryID: recoveryID
),
after: NoiseSecurityConstants
.handshakeCollisionRecoveryDelay
)
}
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription)) SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
if shouldSuppressImmediateHandlerRestart, !isIdentityMismatch {
throw NoiseManagedHandshakeFailure(underlying: error)
}
throw error throw error
} }
} }
@@ -377,47 +801,263 @@ final class NoiseSessionManager {
) )
} }
private func scheduleQuarantinedResponderTimeoutLocked( private func scheduleOrdinaryInitiatorTimeoutLocked(
_ responder: NoiseSession, _ session: NoiseSession,
for peerID: PeerID for peerID: PeerID,
notifyOnTimeout: Bool
) { ) {
quarantinedResponderTimeouts.removeValue(forKey: peerID)?.cancel() cancelOrdinaryInitiatorTimeoutLocked(for: peerID)
let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak responder] in ordinaryInitiatorRetryNotifications[peerID] = notifyOnTimeout
let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak session] in
guard let self, guard let self,
let responder, let session,
let current = self.sessions[peerID], let current = self.sessions[peerID],
current === responder, current === session,
current.role == .responder, current.role == .initiator,
current.getState() == .handshaking, current.getState() == .handshaking else {
let quarantined = self.quarantinedTransports.removeValue(
forKey: peerID
) else {
return return
} }
_ = self.sessions.removeValue(forKey: peerID) _ = self.sessions.removeValue(forKey: peerID)
self.sessionGenerations.removeValue(forKey: peerID) self.sessionGenerations.removeValue(forKey: peerID)
self.ordinaryInitiationIDs.removeValue(forKey: peerID) self.ordinaryInitiationIDs.removeValue(forKey: peerID)
self.quarantinedResponderTimeouts.removeValue(forKey: peerID) self.ordinaryInitiatorTimeouts.removeValue(forKey: peerID)
responder.reset() self.ordinaryInitiatorRetryNotifications.removeValue(forKey: peerID)
self.sessions[peerID] = quarantined.session self.recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID)
self.sessionGenerations[peerID] = quarantined.generation session.reset()
SecureLogger.debug( SecureLogger.debug(
"Ordinary responder handshake with \(peerID) timed out; restored quarantined transport", "Ordinary initiator handshake with \(peerID) timed out",
category: .session category: .session
) )
DispatchQueue.global().async { [weak self] in
self?.onSessionRestored?(peerID, quarantined.generation) if notifyOnTimeout {
self.requestHandshakeRecovery(for: peerID)
} }
} }
quarantinedResponderTimeouts[peerID] = timeout ordinaryInitiatorTimeouts[peerID] = timeout
guard let deadline = quarantinedTransports[peerID]?.rollbackDeadline else { managerQueue.asyncAfter(
quarantinedResponderTimeouts.removeValue(forKey: peerID) deadline: .now() + ordinaryHandshakeTimeout,
execute: timeout
)
}
private func cancelOrdinaryInitiatorTimeoutLocked(for peerID: PeerID) {
ordinaryInitiatorTimeouts.removeValue(forKey: peerID)?.cancel()
ordinaryInitiatorRetryNotifications.removeValue(forKey: peerID)
}
private func scheduleOrdinaryResponderTimeoutLocked(
_ session: NoiseSession,
for peerID: PeerID,
notifyOnTimeout: Bool,
createdByYield: Bool,
rearmDeadline: Bool = false
) {
let previousDeadline = ordinaryResponderDeadlines[peerID]
cancelOrdinaryResponderTimeoutLocked(
for: peerID,
preserveDeadline: true
)
ordinaryResponderRetryNotifications[peerID] = notifyOnTimeout
if createdByYield {
ordinaryRespondersCreatedByYield.insert(peerID)
}
let deadline: DispatchTime
if let rollbackDeadline =
quarantinedTransports[peerID]?.rollbackDeadline {
deadline = rollbackDeadline
} else if !rearmDeadline, let previousDeadline {
deadline = previousDeadline
} else {
deadline = .now() + ordinaryResponderHandshakeTimeout
}
ordinaryResponderDeadlines[peerID] = deadline
let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak session] in
guard let self,
let session,
let current = self.sessions[peerID],
current === session,
current.role == .responder,
current.getState() == .handshaking else {
return return
} }
_ = self.sessions.removeValue(forKey: peerID)
self.sessionGenerations.removeValue(forKey: peerID)
self.ordinaryInitiationIDs.removeValue(forKey: peerID)
self.ordinaryResponderTimeouts.removeValue(forKey: peerID)
self.ordinaryResponderDeadlines.removeValue(forKey: peerID)
self.ordinaryResponderRetryNotifications.removeValue(forKey: peerID)
self.ordinaryRespondersCreatedByYield.remove(peerID)
self.recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID)
session.reset()
let restored: QuarantinedTransport?
if let quarantined =
self.quarantinedTransports.removeValue(forKey: peerID) {
self.sessions[peerID] = quarantined.session
self.sessionGenerations[peerID] = quarantined.generation
self.markRollbackCooldownLocked(for: peerID)
restored = quarantined
} else {
restored = nil
}
SecureLogger.debug(
restored == nil
? "Ordinary responder handshake with \(peerID) timed out"
: "Ordinary responder handshake with \(peerID) timed out; restored quarantined transport",
category: .session
)
if let restored {
DispatchQueue.global().async { [weak self] in
self?.onSessionRestored?(peerID, restored.generation)
}
}
// A rollback always owns one local convergence attempt. The retry
// retires the restored session atomically, so an attacker cannot
// pace unauthenticated message 1 packets to pause outbound forever.
if notifyOnTimeout || restored != nil {
self.requestHandshakeRecovery(for: peerID)
}
}
ordinaryResponderTimeouts[peerID] = timeout
managerQueue.asyncAfter(deadline: deadline, execute: timeout) managerQueue.asyncAfter(deadline: deadline, execute: timeout)
} }
private func cancelOrdinaryResponderTimeoutLocked(
for peerID: PeerID,
preserveDeadline: Bool = false
) {
ordinaryResponderTimeouts.removeValue(forKey: peerID)?.cancel()
if !preserveDeadline {
ordinaryResponderDeadlines.removeValue(forKey: peerID)
}
ordinaryResponderRetryNotifications.removeValue(forKey: peerID)
ordinaryRespondersCreatedByYield.remove(peerID)
}
private func markRollbackCooldownLocked(for peerID: PeerID) {
quarantineRollbackCooldownUntil[peerID] =
Date().addingTimeInterval(ordinaryReconnectRollbackCooldown)
}
private func scheduleSuppressedInitiationRecoveryLocked(
_ establishedSession: NoiseSession,
completedAt: Date,
for peerID: PeerID
) {
cancelSuppressedInitiationRecoveryLocked(for: peerID)
let elapsed = max(0, Date().timeIntervalSince(completedAt))
let remainingGrace = max(
0,
recentInitiatorCompletionGracePeriod - elapsed
)
let timeout = DispatchWorkItem(flags: .barrier) {
[weak self, weak establishedSession] in
guard let self,
let establishedSession,
let current = self.sessions[peerID],
current === establishedSession,
current.isEstablished() else {
return
}
self.suppressedInitiationRecoveryTimeouts.removeValue(
forKey: peerID
)
self.requestHandshakeRecovery(for: peerID)
}
suppressedInitiationRecoveryTimeouts[peerID] = timeout
managerQueue.asyncAfter(
deadline: .now() + remainingGrace,
execute: timeout
)
}
private func cancelSuppressedInitiationRecoveryLocked(for peerID: PeerID) {
suppressedInitiationRecoveryTimeouts.removeValue(forKey: peerID)?
.cancel()
}
private func requestHandshakeRecovery(
for peerID: PeerID,
after delay: TimeInterval = 0
) {
cancelDelayedHandshakeRecoveryLocked(for: peerID)
let request = NoiseHandshakeRecoveryRequest(
peerID: peerID,
recoveryID: UUID()
)
pendingHandshakeRecoveryIDs[peerID] = request.recoveryID
scheduleHandshakeRecoveryCallbackLocked(request, after: delay)
}
private func redispatchHandshakeRecoveryLocked(
_ request: NoiseHandshakeRecoveryRequest,
after delay: TimeInterval
) {
guard pendingHandshakeRecoveryIDs[request.peerID]
== request.recoveryID else {
return
}
scheduleHandshakeRecoveryCallbackLocked(request, after: delay)
}
private func scheduleHandshakeRecoveryCallbackLocked(
_ request: NoiseHandshakeRecoveryRequest,
after delay: TimeInterval
) {
let peerID = request.peerID
guard pendingHandshakeRecoveryIDs[peerID] == request.recoveryID else {
return
}
delayedHandshakeRecoveryWorkItems.removeValue(forKey: peerID)?.cancel()
let callbackID = UUID()
handshakeRecoveryCallbackIDs[peerID] = callbackID
let callback = DispatchWorkItem(flags: .barrier) { [weak self] in
guard let self,
self.pendingHandshakeRecoveryIDs[peerID]
== request.recoveryID,
self.handshakeRecoveryCallbackIDs[peerID] == callbackID else {
return
}
self.delayedHandshakeRecoveryWorkItems.removeValue(forKey: peerID)
self.handshakeRecoveryCallbackIDs.removeValue(forKey: peerID)
let handler = self.onHandshakeRecoveryRequired
DispatchQueue.global().async {
handler?(request)
}
}
delayedHandshakeRecoveryWorkItems[peerID] = callback
managerQueue.asyncAfter(
deadline: .now() + max(0, delay),
execute: callback
)
}
private func consumeHandshakeRecoveryLocked(
_ request: NoiseHandshakeRecoveryRequest
) {
let peerID = request.peerID
guard pendingHandshakeRecoveryIDs[peerID] == request.recoveryID else {
return
}
delayedHandshakeRecoveryWorkItems.removeValue(forKey: peerID)?.cancel()
handshakeRecoveryCallbackIDs.removeValue(forKey: peerID)
pendingHandshakeRecoveryIDs.removeValue(forKey: peerID)
}
private func cancelDelayedHandshakeRecoveryLocked(for peerID: PeerID) {
delayedHandshakeRecoveryWorkItems.removeValue(forKey: peerID)?.cancel()
handshakeRecoveryCallbackIDs.removeValue(forKey: peerID)
pendingHandshakeRecoveryIDs.removeValue(forKey: peerID)
}
/// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are /// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are
/// also accepted by internal callers when they exactly match the static /// also accepted by internal callers when they exactly match the static
/// key. Non-wire identifiers remain available to protocol test harnesses; /// key. Non-wire identifiers remain available to protocol test harnesses;
@@ -563,14 +1203,11 @@ final class NoiseSessionManager {
} }
} }
func initiateRekey(for peerID: PeerID) throws -> Data { func initiateRekey(for peerID: PeerID) throws -> NoiseHandshakeInitiation {
let initiation = try initiateReconnectHandshake( try initiateReconnectHandshake(
with: peerID, with: peerID,
notifyOnTimeout: true,
authorize: {} authorize: {}
) )
guard let payload = claimHandshakeInitiation(initiation, for: peerID) else {
throw NoiseSessionError.invalidState
}
return payload
} }
} }
@@ -112,6 +112,14 @@ final class BLENoisePacketHandler {
didEstablishAuthenticatedSession: didEstablishAuthenticatedSession:
result.didEstablishAuthenticatedSession result.didEstablishAuthenticatedSession
) )
} catch let managedFailure as NoiseManagedHandshakeFailure {
SecureLogger.error(
"Failed to process handshake; manager owns recovery: \(managedFailure.underlying)"
)
return BLENoiseHandshakeHandlingResult(
processed: false,
didEstablishAuthenticatedSession: false
)
} catch NoiseSessionError.peerIdentityMismatch { } catch NoiseSessionError.peerIdentityMismatch {
// The 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
+113 -19
View File
@@ -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 {
+80 -10
View File
@@ -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
@@ -991,9 +1061,9 @@ final class NoiseEncryptionService {
} }
private func initiateAutomaticRekey(for peerID: PeerID) throws { private func initiateAutomaticRekey(for peerID: PeerID) throws {
let handshakeMessage = try sessionManager.initiateRekey(for: peerID) let initiation = try sessionManager.initiateRekey(for: peerID)
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security) SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
onRekeyHandshakeReady?(peerID, handshakeMessage) onRekeyHandshakeReady?(peerID, initiation)
onHandshakeRequired?(peerID) onHandshakeRequired?(peerID)
} }
+12 -6
View File
@@ -549,20 +549,26 @@ struct BLEServiceCoreTests {
// Preserve a working victim session while an unauthenticated // Preserve a working victim session while an unauthenticated
// replacement candidate arrives on a newly bound physical link. // replacement candidate arrives on a newly bound physical link.
let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID) // Establish BLE as responder so the replacement candidate below is
// not coalesced by the initiator-completion grace path.
let message1 = try victim.initiateHandshake(with: ble.myPeerID)
let message2 = try #require( let message2 = try #require(
try victim.processHandshakeMessage(from: ble.myPeerID, message: message1)
)
let message3 = try #require(
try ble._test_noiseProcessHandshakeMessage( try ble._test_noiseProcessHandshakeMessage(
from: victimPeerID, from: victimPeerID,
message: message1
)
)
let message3 = try #require(
try victim.processHandshakeMessage(
from: ble.myPeerID,
message: message2 message: message2
) )
) )
_ = try victim.processHandshakeMessage( _ = try ble._test_noiseProcessHandshakeMessage(
from: ble.myPeerID, from: victimPeerID,
message: message3 message: message3
) )
await ble._test_drainNoiseMessagePipeline()
#expect(ble.canDeliverSecurely(to: victimPeerID)) #expect(ble.canDeliverSecurely(to: victimPeerID))
let centralUUID = "central-replacement-xx-message-one" let centralUUID = "central-replacement-xx-message-one"
+17 -2
View File
@@ -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))
let localPeerID = PeerID(
publicKey: aliceStaticKey.publicKey.rawRepresentation
)
if localPeerID < alicePeerID {
#expect(replacementResponse == nil)
#expect(replacementSession === restartedSession)
} else {
#expect(replacementResponse != nil) #expect(replacementResponse != nil)
#expect(replacementSession !== restartedSession) #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,
@@ -198,6 +198,24 @@ struct BLENoisePacketHandlerTests {
#expect(recorder.broadcastPackets.isEmpty) #expect(recorder.broadcastPackets.isEmpty)
} }
@Test
func managedHandshakeFailureDoesNotStartASecondRecovery() {
let recorder = Recorder()
recorder.handshakeResult = .failure(
NoiseManagedHandshakeFailure(underlying: TestError())
)
recorder.hasSession = false
let handler = makeHandler(recorder: recorder)
let packet = makeHandshakePacket(
recipientID: Data(hexString: localPeerID.id)
)
#expect(!handler.handleHandshake(packet, from: remotePeerID))
#expect(recorder.hasSessionQueries.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
#expect(recorder.broadcastPackets.isEmpty)
}
// MARK: Encrypted // MARK: Encrypted
@Test @Test
@@ -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()
}
}