mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 13:25:20 +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.
|
||||
static let xxInitialMessageSize = 32
|
||||
|
||||
// Bounds an ordinary initiator whose message 1 or 2 is lost.
|
||||
static let ordinaryHandshakeTimeout: TimeInterval = 10
|
||||
|
||||
// Bounds the receive-only rollback quarantine created by an unauthenticated
|
||||
// inbound message 1. A lost message 3 must not strand outbound traffic.
|
||||
static let ordinaryResponderHandshakeTimeout: TimeInterval = 20
|
||||
|
||||
// A released client may immediately retry after both crossed initiators
|
||||
// yielded. Give that unilateral retry a brief head start before the
|
||||
// patched side spends its one bounded recovery.
|
||||
static let handshakeCollisionRecoveryDelay: TimeInterval = 0.2
|
||||
|
||||
// Rate-limited recovery remains actionable without spinning.
|
||||
static let handshakeRateLimitRecoveryDelay: TimeInterval = 60
|
||||
|
||||
// Covers only reordering between a winning message 3 and the losing
|
||||
// crossed message 1.
|
||||
static let recentInitiatorCompletionGracePeriod: TimeInterval = 1
|
||||
|
||||
// After unauthenticated responder rollback, reject another attempt long
|
||||
// enough that paced message 1 traffic cannot keep outbound paused. A
|
||||
// legitimate peer converges through the one manager-owned local retry.
|
||||
static let ordinaryReconnectRollbackCooldown: TimeInterval = 60
|
||||
|
||||
// Session timeout - sessions older than this should be renegotiated
|
||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||
|
||||
|
||||
@@ -13,3 +13,9 @@ enum NoiseSessionError: Error, Equatable {
|
||||
case alreadyEstablished
|
||||
case peerIdentityMismatch
|
||||
}
|
||||
|
||||
/// The manager owns the exact attempt's one bounded recovery. Packet handling
|
||||
/// must not launch its historical second, immediate restart for this failure.
|
||||
struct NoiseManagedHandshakeFailure: Error {
|
||||
let underlying: Error
|
||||
}
|
||||
|
||||
@@ -21,6 +21,16 @@ struct NoiseHandshakeInitiation: Equatable, Sendable {
|
||||
let attemptID: UUID
|
||||
}
|
||||
|
||||
struct NoiseHandshakeRecoveryRequest: Equatable, Sendable {
|
||||
let peerID: PeerID
|
||||
fileprivate let recoveryID: UUID
|
||||
}
|
||||
|
||||
enum NoiseHandshakeRecoveryPreparation {
|
||||
case ordinary(NoiseHandshakeInitiation)
|
||||
case transferred
|
||||
}
|
||||
|
||||
final class NoiseSessionManager {
|
||||
private var sessions: [PeerID: NoiseSession] = [:]
|
||||
/// 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
|
||||
/// after an inbound collision has already changed this peer's role.
|
||||
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 {
|
||||
let session: NoiseSession
|
||||
let generation: UUID
|
||||
@@ -43,23 +60,44 @@ final class NoiseSessionManager {
|
||||
/// claimed static identity, then is discarded on success or restored on
|
||||
/// bounded failure.
|
||||
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 localPeerID: PeerID
|
||||
private let ordinaryHandshakeTimeout: TimeInterval
|
||||
private let ordinaryResponderHandshakeTimeout: TimeInterval
|
||||
private let recentInitiatorCompletionGracePeriod: TimeInterval
|
||||
private let ordinaryReconnectRollbackCooldown: TimeInterval
|
||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||
|
||||
// Callbacks
|
||||
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey, UUID) -> Void)?
|
||||
var onSessionRestored: ((PeerID, UUID) -> Void)?
|
||||
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||
var onHandshakeRecoveryRequired: ((NoiseHandshakeRecoveryRequest) -> Void)?
|
||||
|
||||
init(
|
||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
keychain: KeychainManagerProtocol,
|
||||
ordinaryHandshakeTimeout: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryHandshakeTimeout,
|
||||
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.recentInitiatorCompletionGracePeriod =
|
||||
recentInitiatorCompletionGracePeriod
|
||||
self.ordinaryReconnectRollbackCooldown =
|
||||
ordinaryReconnectRollbackCooldown
|
||||
self.sessionFactory = { peerID, role in
|
||||
SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
@@ -72,13 +110,25 @@ final class NoiseSessionManager {
|
||||
|
||||
#if DEBUG
|
||||
init(
|
||||
localStaticKey _: Curve25519.KeyAgreement.PrivateKey,
|
||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
keychain _: KeychainManagerProtocol,
|
||||
ordinaryHandshakeTimeout: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryHandshakeTimeout,
|
||||
ordinaryResponderHandshakeTimeout: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
|
||||
recentInitiatorCompletionGracePeriod: TimeInterval =
|
||||
NoiseSecurityConstants.recentInitiatorCompletionGracePeriod,
|
||||
ordinaryReconnectRollbackCooldown: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryReconnectRollbackCooldown,
|
||||
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
|
||||
) {
|
||||
self.localPeerID = PeerID(publicKey: localStaticKey.publicKey.rawRepresentation)
|
||||
self.ordinaryHandshakeTimeout = ordinaryHandshakeTimeout
|
||||
self.ordinaryResponderHandshakeTimeout = ordinaryResponderHandshakeTimeout
|
||||
self.recentInitiatorCompletionGracePeriod =
|
||||
recentInitiatorCompletionGracePeriod
|
||||
self.ordinaryReconnectRollbackCooldown =
|
||||
ordinaryReconnectRollbackCooldown
|
||||
self.sessionFactory = sessionFactory
|
||||
}
|
||||
#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) {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
removeSessionLocked(for: peerID)
|
||||
@@ -104,9 +258,14 @@ final class NoiseSessionManager {
|
||||
if let quarantined = quarantinedTransports.removeValue(forKey: peerID) {
|
||||
quarantined.session.reset()
|
||||
}
|
||||
quarantinedResponderTimeouts.removeValue(forKey: peerID)?.cancel()
|
||||
sessionGenerations.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() {
|
||||
@@ -117,14 +276,34 @@ final class NoiseSessionManager {
|
||||
for (_, quarantined) in quarantinedTransports {
|
||||
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()
|
||||
}
|
||||
sessions.removeAll()
|
||||
sessionGenerations.removeAll()
|
||||
ordinaryInitiationIDs.removeAll()
|
||||
ordinaryInitiatorTimeouts.removeAll()
|
||||
ordinaryInitiatorRetryNotifications.removeAll()
|
||||
ordinaryResponderTimeouts.removeAll()
|
||||
ordinaryResponderDeadlines.removeAll()
|
||||
ordinaryResponderRetryNotifications.removeAll()
|
||||
ordinaryRespondersCreatedByYield.removeAll()
|
||||
recentOrdinaryInitiatorCompletions.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
|
||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
ordinaryInitiationIDs.removeValue(forKey: peerID)
|
||||
existingSession.reset()
|
||||
removeSessionLocked(for: peerID)
|
||||
}
|
||||
recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID)
|
||||
|
||||
// Create new initiator session
|
||||
let session = sessionFactory(peerID, .initiator)
|
||||
@@ -153,12 +330,19 @@ final class NoiseSessionManager {
|
||||
|
||||
do {
|
||||
let handshakeData = try session.startHandshake()
|
||||
scheduleOrdinaryInitiatorTimeoutLocked(
|
||||
session,
|
||||
for: peerID,
|
||||
notifyOnTimeout: false
|
||||
)
|
||||
cancelDelayedHandshakeRecoveryLocked(for: peerID)
|
||||
return handshakeData
|
||||
} catch {
|
||||
// Clean up failed session
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
ordinaryInitiationIDs.removeValue(forKey: peerID)
|
||||
cancelOrdinaryInitiatorTimeoutLocked(for: peerID)
|
||||
session.reset()
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||
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
|
||||
/// transport. Authorization and handshake-start failure leave the working
|
||||
/// session untouched. Once this returns, encryption observes only the new
|
||||
/// handshaking session and must queue until it establishes.
|
||||
func initiateReconnectHandshake(
|
||||
with peerID: PeerID,
|
||||
notifyOnTimeout: Bool,
|
||||
authorize: () throws -> Void
|
||||
) throws -> NoiseHandshakeInitiation {
|
||||
try managerQueue.sync(flags: .barrier) {
|
||||
@@ -196,11 +429,29 @@ final class NoiseSessionManager {
|
||||
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()
|
||||
sessions[peerID] = next
|
||||
sessionGenerations[peerID] = UUID()
|
||||
ordinaryInitiationIDs[peerID] = attemptID
|
||||
scheduleOrdinaryInitiatorTimeoutLocked(
|
||||
next,
|
||||
for: peerID,
|
||||
notifyOnTimeout: notifyOnTimeout
|
||||
)
|
||||
return NoiseHandshakeInitiation(
|
||||
payload: payload,
|
||||
attemptID: attemptID
|
||||
@@ -222,6 +473,16 @@ final class NoiseSessionManager {
|
||||
return nil
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -253,21 +514,112 @@ final class NoiseSessionManager {
|
||||
generation: UUID
|
||||
)?
|
||||
) = 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],
|
||||
existing.isEstablished()
|
||||
|| message.count
|
||||
== NoiseSecurityConstants.xxInitialMessageSize {
|
||||
if existing.isEstablished(),
|
||||
let generation = sessionGenerations[peerID] {
|
||||
// Message 1 cannot prove that the remote still owns its
|
||||
// old keys. Remove them from every sending/generation API
|
||||
// immediately, but retain the object solely for bounded
|
||||
// rollback until the ordinary responder authenticates.
|
||||
if isFreshInitiation {
|
||||
if let cooldownUntil = quarantineRollbackCooldownUntil[peerID] {
|
||||
if cooldownUntil > Date(),
|
||||
sessions[peerID]?.isEstablished() == true {
|
||||
SecureLogger.debug(
|
||||
"Ignoring unauthenticated reconnect initiation during rollback cooldown for \(peerID)",
|
||||
category: .session
|
||||
)
|
||||
return (nil, nil)
|
||||
}
|
||||
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)
|
||||
sessionGenerations.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(
|
||||
QuarantinedTransport(
|
||||
session: existing,
|
||||
@@ -280,23 +632,30 @@ final class NoiseSessionManager {
|
||||
prior.session.reset()
|
||||
}
|
||||
} else {
|
||||
inheritedResponderShouldRetry =
|
||||
ordinaryResponderRetryNotifications[peerID] ?? false
|
||||
inheritedResponderWasCreatedByYield =
|
||||
ordinaryRespondersCreatedByYield.contains(peerID)
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
sessionGenerations.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()
|
||||
}
|
||||
|
||||
let replacement = sessionFactory(peerID, .responder)
|
||||
sessions[peerID] = replacement
|
||||
sessionGenerations[peerID] = UUID()
|
||||
session = replacement
|
||||
if quarantinedTransports[peerID] != nil {
|
||||
scheduleQuarantinedResponderTimeoutLocked(
|
||||
replacement,
|
||||
for: peerID
|
||||
)
|
||||
}
|
||||
} else if let existing = sessions[peerID] {
|
||||
} else {
|
||||
session = existing
|
||||
}
|
||||
} else {
|
||||
let newSession = sessionFactory(peerID, .responder)
|
||||
sessions[peerID] = newSession
|
||||
@@ -304,8 +663,21 @@ final class NoiseSessionManager {
|
||||
session = newSession
|
||||
}
|
||||
|
||||
// Process the handshake message within the synchronized block
|
||||
do {
|
||||
if isFreshInitiation,
|
||||
session.role == .responder {
|
||||
scheduleOrdinaryResponderTimeoutLocked(
|
||||
session,
|
||||
for: peerID,
|
||||
notifyOnTimeout:
|
||||
yieldedInitiatorShouldRetry
|
||||
|| inheritedResponderShouldRetry,
|
||||
createdByYield:
|
||||
didYieldLocalInitiator
|
||||
|| inheritedResponderWasCreatedByYield
|
||||
)
|
||||
}
|
||||
|
||||
let response = try session.processHandshakeMessage(message)
|
||||
|
||||
// Check the exact session that processed this message. A
|
||||
@@ -324,12 +696,21 @@ final class NoiseSessionManager {
|
||||
if let quarantined = quarantinedTransports.removeValue(
|
||||
forKey: peerID
|
||||
) {
|
||||
quarantinedResponderTimeouts.removeValue(
|
||||
forKey: peerID
|
||||
)?.cancel()
|
||||
quarantined.session.reset()
|
||||
}
|
||||
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 {
|
||||
throw NoiseEncryptionError.sessionNotEstablished
|
||||
}
|
||||
@@ -338,19 +719,36 @@ final class NoiseSessionManager {
|
||||
|
||||
return (response, establishedSession)
|
||||
} 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],
|
||||
storedSession === session {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
}
|
||||
ordinaryInitiationIDs.removeValue(forKey: peerID)
|
||||
cancelOrdinaryInitiatorTimeoutLocked(for: peerID)
|
||||
cancelOrdinaryResponderTimeoutLocked(for: peerID)
|
||||
recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID)
|
||||
session.reset()
|
||||
quarantinedResponderTimeouts.removeValue(forKey: peerID)?.cancel()
|
||||
|
||||
let restoredGeneration: UUID?
|
||||
if let quarantined = quarantinedTransports.removeValue(forKey: peerID) {
|
||||
sessions[peerID] = quarantined.session
|
||||
sessionGenerations[peerID] = quarantined.generation
|
||||
restoredGeneration = quarantined.generation
|
||||
markRollbackCooldownLocked(for: peerID)
|
||||
} else {
|
||||
restoredGeneration = nil
|
||||
}
|
||||
@@ -363,7 +761,33 @@ final class NoiseSessionManager {
|
||||
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))
|
||||
if shouldSuppressImmediateHandlerRestart, !isIdentityMismatch {
|
||||
throw NoiseManagedHandshakeFailure(underlying: error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -377,47 +801,263 @@ final class NoiseSessionManager {
|
||||
)
|
||||
}
|
||||
|
||||
private func scheduleQuarantinedResponderTimeoutLocked(
|
||||
_ responder: NoiseSession,
|
||||
for peerID: PeerID
|
||||
private func scheduleOrdinaryInitiatorTimeoutLocked(
|
||||
_ session: NoiseSession,
|
||||
for peerID: PeerID,
|
||||
notifyOnTimeout: Bool
|
||||
) {
|
||||
quarantinedResponderTimeouts.removeValue(forKey: peerID)?.cancel()
|
||||
let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak responder] in
|
||||
cancelOrdinaryInitiatorTimeoutLocked(for: peerID)
|
||||
ordinaryInitiatorRetryNotifications[peerID] = notifyOnTimeout
|
||||
let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak session] in
|
||||
guard let self,
|
||||
let responder,
|
||||
let session,
|
||||
let current = self.sessions[peerID],
|
||||
current === responder,
|
||||
current.role == .responder,
|
||||
current.getState() == .handshaking,
|
||||
let quarantined = self.quarantinedTransports.removeValue(
|
||||
forKey: peerID
|
||||
) else {
|
||||
current === session,
|
||||
current.role == .initiator,
|
||||
current.getState() == .handshaking else {
|
||||
return
|
||||
}
|
||||
|
||||
_ = self.sessions.removeValue(forKey: peerID)
|
||||
self.sessionGenerations.removeValue(forKey: peerID)
|
||||
self.ordinaryInitiationIDs.removeValue(forKey: peerID)
|
||||
self.quarantinedResponderTimeouts.removeValue(forKey: peerID)
|
||||
responder.reset()
|
||||
self.sessions[peerID] = quarantined.session
|
||||
self.sessionGenerations[peerID] = quarantined.generation
|
||||
self.ordinaryInitiatorTimeouts.removeValue(forKey: peerID)
|
||||
self.ordinaryInitiatorRetryNotifications.removeValue(forKey: peerID)
|
||||
self.recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID)
|
||||
session.reset()
|
||||
SecureLogger.debug(
|
||||
"Ordinary responder handshake with \(peerID) timed out; restored quarantined transport",
|
||||
"Ordinary initiator handshake with \(peerID) timed out",
|
||||
category: .session
|
||||
)
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionRestored?(peerID, quarantined.generation)
|
||||
|
||||
if notifyOnTimeout {
|
||||
self.requestHandshakeRecovery(for: peerID)
|
||||
}
|
||||
}
|
||||
quarantinedResponderTimeouts[peerID] = timeout
|
||||
guard let deadline = quarantinedTransports[peerID]?.rollbackDeadline else {
|
||||
quarantinedResponderTimeouts.removeValue(forKey: peerID)
|
||||
ordinaryInitiatorTimeouts[peerID] = timeout
|
||||
managerQueue.asyncAfter(
|
||||
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
|
||||
}
|
||||
|
||||
_ = 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)
|
||||
}
|
||||
|
||||
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
|
||||
/// also accepted by internal callers when they exactly match the static
|
||||
/// key. Non-wire identifiers remain available to protocol test harnesses;
|
||||
@@ -563,14 +1203,11 @@ final class NoiseSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
func initiateRekey(for peerID: PeerID) throws -> Data {
|
||||
let initiation = try initiateReconnectHandshake(
|
||||
func initiateRekey(for peerID: PeerID) throws -> NoiseHandshakeInitiation {
|
||||
try initiateReconnectHandshake(
|
||||
with: peerID,
|
||||
notifyOnTimeout: true,
|
||||
authorize: {}
|
||||
)
|
||||
guard let payload = claimHandshakeInitiation(initiation, for: peerID) else {
|
||||
throw NoiseSessionError.invalidState
|
||||
}
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,14 @@ final class BLENoisePacketHandler {
|
||||
didEstablishAuthenticatedSession:
|
||||
result.didEstablishAuthenticatedSession
|
||||
)
|
||||
} catch let managedFailure as NoiseManagedHandshakeFailure {
|
||||
SecureLogger.error(
|
||||
"Failed to process handshake; manager owns recovery: \(managedFailure.underlying)"
|
||||
)
|
||||
return BLENoiseHandshakeHandlingResult(
|
||||
processed: false,
|
||||
didEstablishAuthenticatedSession: false
|
||||
)
|
||||
} catch NoiseSessionError.peerIdentityMismatch {
|
||||
// The responder was already discarded by the session manager.
|
||||
// Do not let a spoofed claimed ID trigger a fresh outbound
|
||||
|
||||
@@ -2955,14 +2955,21 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
startScanning()
|
||||
|
||||
case .poweredOff:
|
||||
// Bluetooth was turned off - stop scanning and clean up connection state
|
||||
// CoreBluetooth has already transitioned out of poweredOn. Do
|
||||
// not issue stop/cancel commands now; they are rejected as API
|
||||
// misuse. Retire our link state locally instead.
|
||||
SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session)
|
||||
central.stopScan()
|
||||
// Mark all peripheral connections as disconnected (they are now invalid)
|
||||
let peripheralStates = linkStateStore.peripheralStates
|
||||
let peerIDs: [PeerID] = peripheralStates.compactMap(\.peerID)
|
||||
for state in peripheralStates {
|
||||
central.cancelPeripheralConnection(state.peripheral)
|
||||
let peripheralID = state.peripheral.identifier.uuidString
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||
}
|
||||
noiseAuthenticatedLinkOwners.removeValue(
|
||||
forKey: .peripheral(peripheralID)
|
||||
)
|
||||
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||||
}
|
||||
_ = linkStateStore.clearPeripherals()
|
||||
// Notify UI of disconnections
|
||||
@@ -2975,7 +2982,6 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
case .unauthorized:
|
||||
// User denied Bluetooth permission
|
||||
SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session)
|
||||
central.stopScan()
|
||||
_ = linkStateStore.clearPeripherals()
|
||||
|
||||
case .unsupported:
|
||||
@@ -3867,8 +3873,19 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
case .poweredOff:
|
||||
// Bluetooth was turned off - clean up peripheral state
|
||||
SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session)
|
||||
peripheral.stopAdvertising()
|
||||
// Clear subscribed centrals (they are now invalid)
|
||||
let centralSnapshot = linkStateStore.subscribedCentralSnapshot
|
||||
for central in centralSnapshot.centrals {
|
||||
let centralID = central.identifier.uuidString
|
||||
noiseAuthenticatedLinkOwners.removeValue(
|
||||
forKey: .central(centralID)
|
||||
)
|
||||
noiseReconnectPolicy.endLinkEpoch(.central(centralID))
|
||||
}
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
pendingNotifications.removeAll()
|
||||
pendingWriteBuffers.removeAll()
|
||||
}
|
||||
let centralPeerIDs = linkStateStore.clearCentrals()
|
||||
subscriptionAnnounceLimiter.removeAll()
|
||||
characteristic = nil
|
||||
@@ -3882,7 +3899,6 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
case .unauthorized:
|
||||
// User denied Bluetooth permission
|
||||
SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session)
|
||||
peripheral.stopAdvertising()
|
||||
_ = linkStateStore.clearCentrals()
|
||||
subscriptionAnnounceLimiter.removeAll()
|
||||
characteristic = nil
|
||||
@@ -4659,13 +4675,71 @@ extension BLEService {
|
||||
)
|
||||
}
|
||||
}
|
||||
service.onRekeyHandshakeReady = { [weak self] peerID, message in
|
||||
self?.messageQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
service.onRekeyHandshakeReady = {
|
||||
[weak self, weak service] peerID, initiation in
|
||||
self?.messageQueue.async(flags: .barrier) {
|
||||
[weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service else {
|
||||
return
|
||||
}
|
||||
self.noteNoiseSessionCleared(for: peerID)
|
||||
guard let message = service.claimHandshakeInitiation(
|
||||
initiation,
|
||||
for: peerID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
self.broadcastNoiseHandshake(message, to: peerID)
|
||||
}
|
||||
}
|
||||
service.onHandshakeRecoveryRequired = {
|
||||
[weak self, weak service] request in
|
||||
guard let self, let service else { return }
|
||||
self.messageQueue.async(flags: .barrier) {
|
||||
[weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service else {
|
||||
return
|
||||
}
|
||||
let peerID = request.peerID
|
||||
guard self.isPeerReachable(peerID) else {
|
||||
service.cancelHandshakeRecovery(request)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
guard let preparation =
|
||||
try service.prepareHandshakeRecovery(request) else {
|
||||
return
|
||||
}
|
||||
switch preparation {
|
||||
case .ordinary(let initiation):
|
||||
self.noteNoiseSessionCleared(for: peerID)
|
||||
guard let handshakeData =
|
||||
service.claimHandshakeInitiation(
|
||||
initiation,
|
||||
for: peerID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
self.broadcastNoiseHandshake(
|
||||
handshakeData,
|
||||
to: peerID
|
||||
)
|
||||
case .transferred:
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error(
|
||||
"Failed to prepare handshake recovery with \(peerID.id.prefix(8))…: \(error)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation in
|
||||
guard let self, let service else { return }
|
||||
self.messageQueue.async { [weak self, weak service] in
|
||||
@@ -5941,12 +6015,27 @@ extension BLEService {
|
||||
}
|
||||
|
||||
private func initiateNoiseHandshake(with peerID: PeerID) {
|
||||
// Use NoiseEncryptionService for handshake
|
||||
guard !noiseService.hasSession(with: peerID) else { return }
|
||||
|
||||
let service = noiseService
|
||||
do {
|
||||
let handshakeData = try noiseService.initiateHandshake(with: peerID)
|
||||
broadcastNoiseHandshake(handshakeData, to: peerID)
|
||||
guard let initiation = try service.initiateHandshakeIfNeeded(
|
||||
with: peerID,
|
||||
retryOnTimeout: true
|
||||
) else {
|
||||
return
|
||||
}
|
||||
messageQueue.async(flags: .barrier) {
|
||||
[weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service,
|
||||
let handshakeData = service.claimHandshakeInitiation(
|
||||
initiation,
|
||||
for: peerID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
self.broadcastNoiseHandshake(handshakeData, to: peerID)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Failed to initiate handshake: \(error)")
|
||||
}
|
||||
@@ -5972,13 +6061,18 @@ extension BLEService {
|
||||
private func initiateNoiseReconnectHandshake(with peerID: PeerID) {
|
||||
let service = noiseService
|
||||
do {
|
||||
let initiation = try service.initiateReconnectHandshake(with: peerID)
|
||||
noteNoiseSessionCleared(for: peerID)
|
||||
let initiation = try service.initiateReconnectHandshake(
|
||||
with: peerID,
|
||||
retryOnTimeout: true
|
||||
)
|
||||
messageQueue.async(flags: .barrier) { [weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service,
|
||||
let handshakeData = service.claimHandshakeInitiation(
|
||||
self.noiseService === service else {
|
||||
return
|
||||
}
|
||||
self.noteNoiseSessionCleared(for: peerID)
|
||||
guard let handshakeData = service.claimHandshakeInitiation(
|
||||
initiation,
|
||||
for: peerID
|
||||
) else {
|
||||
|
||||
@@ -184,11 +184,13 @@ final class NoiseEncryptionService {
|
||||
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||
private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = []
|
||||
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||
/// Automatic rekey removed the old session and produced XX message 1.
|
||||
/// The transport must clear session-scoped state and put these exact bytes
|
||||
/// on the wire; merely reporting "handshake required" strands the partial
|
||||
/// initiator session because a second initiate call sees it already exists.
|
||||
var onRekeyHandshakeReady: ((_ peerID: PeerID, _ message: Data) -> Void)?
|
||||
/// Automatic rekey prepared XX message 1. The transport must claim the
|
||||
/// exact attempt at its actual BLE handoff; a crossed inbound initiation
|
||||
/// can invalidate the token before that point.
|
||||
var onRekeyHandshakeReady:
|
||||
((_ peerID: PeerID, _ initiation: NoiseHandshakeInitiation) -> Void)?
|
||||
var onHandshakeRecoveryRequired:
|
||||
((_ request: NoiseHandshakeRecoveryRequest) -> Void)?
|
||||
/// An unauthenticated reconnect attempt failed or timed out and the
|
||||
/// receive-only rollback session became the active transport again.
|
||||
/// Transport queues must be drained for this exact restored generation.
|
||||
@@ -225,8 +227,14 @@ final class NoiseEncryptionService {
|
||||
|
||||
init(
|
||||
keychain: KeychainManagerProtocol,
|
||||
ordinaryHandshakeTimeout: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryHandshakeTimeout,
|
||||
ordinaryResponderHandshakeTimeout: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
|
||||
recentInitiatorCompletionGracePeriod: TimeInterval =
|
||||
NoiseSecurityConstants.recentInitiatorCompletionGracePeriod,
|
||||
ordinaryReconnectRollbackCooldown: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryReconnectRollbackCooldown
|
||||
) {
|
||||
self.keychain = keychain
|
||||
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
|
||||
@@ -320,7 +328,13 @@ final class NoiseEncryptionService {
|
||||
self.sessionManager = NoiseSessionManager(
|
||||
localStaticKey: staticIdentityKey,
|
||||
keychain: keychain,
|
||||
ordinaryResponderHandshakeTimeout: ordinaryResponderHandshakeTimeout
|
||||
ordinaryHandshakeTimeout: ordinaryHandshakeTimeout,
|
||||
ordinaryResponderHandshakeTimeout:
|
||||
ordinaryResponderHandshakeTimeout,
|
||||
recentInitiatorCompletionGracePeriod:
|
||||
recentInitiatorCompletionGracePeriod,
|
||||
ordinaryReconnectRollbackCooldown:
|
||||
ordinaryReconnectRollbackCooldown
|
||||
)
|
||||
|
||||
// Set up session callbacks
|
||||
@@ -334,6 +348,9 @@ final class NoiseEncryptionService {
|
||||
sessionManager.onSessionRestored = { [weak self] peerID, generation in
|
||||
self?.onSessionRestoredWithGeneration?(peerID, generation)
|
||||
}
|
||||
sessionManager.onHandshakeRecoveryRequired = { [weak self] request in
|
||||
self?.onHandshakeRecoveryRequired?(request)
|
||||
}
|
||||
|
||||
// Start session maintenance timer
|
||||
startRekeyTimer()
|
||||
@@ -698,11 +715,41 @@ final class NoiseEncryptionService {
|
||||
return handshakeData
|
||||
}
|
||||
|
||||
/// Atomically admits and prepares one initial ordinary handshake. Returns
|
||||
/// nil when another discovery callback already created a session.
|
||||
func initiateHandshakeIfNeeded(
|
||||
with peerID: PeerID,
|
||||
retryOnTimeout: Bool = false
|
||||
) throws -> NoiseHandshakeInitiation? {
|
||||
guard peerID.isValid else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||
throw NoiseSecurityError.invalidPeerID
|
||||
}
|
||||
|
||||
guard let initiation = try sessionManager.initiateHandshakeIfAbsent(
|
||||
with: peerID,
|
||||
notifyOnTimeout: retryOnTimeout,
|
||||
authorize: { [rateLimiter] in
|
||||
guard rateLimiter.allowHandshake(from: peerID) else {
|
||||
SecureLogger.warning(
|
||||
.authenticationFailed(peerID: "Rate limited: \(peerID)")
|
||||
)
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
}
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
SecureLogger.info(.handshakeStarted(peerID: peerID.id))
|
||||
return initiation
|
||||
}
|
||||
|
||||
/// Atomically prepares an ordinary reconnect for a peer whose cached
|
||||
/// transport belongs to an earlier physical link. Failed authorization or
|
||||
/// handshake setup preserves the established session.
|
||||
func initiateReconnectHandshake(
|
||||
with peerID: PeerID
|
||||
with peerID: PeerID,
|
||||
retryOnTimeout: Bool = false
|
||||
) throws -> NoiseHandshakeInitiation {
|
||||
guard peerID.isValid else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||
@@ -711,6 +758,7 @@ final class NoiseEncryptionService {
|
||||
|
||||
return try sessionManager.initiateReconnectHandshake(
|
||||
with: peerID,
|
||||
notifyOnTimeout: retryOnTimeout,
|
||||
authorize: { [rateLimiter] in
|
||||
guard rateLimiter.allowHandshake(from: peerID) else {
|
||||
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(
|
||||
_ initiation: NoiseHandshakeInitiation,
|
||||
for peerID: PeerID
|
||||
@@ -991,9 +1061,9 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
|
||||
private func initiateAutomaticRekey(for peerID: PeerID) throws {
|
||||
let handshakeMessage = try sessionManager.initiateRekey(for: peerID)
|
||||
let initiation = try sessionManager.initiateRekey(for: peerID)
|
||||
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
|
||||
onRekeyHandshakeReady?(peerID, handshakeMessage)
|
||||
onRekeyHandshakeReady?(peerID, initiation)
|
||||
onHandshakeRequired?(peerID)
|
||||
}
|
||||
|
||||
|
||||
@@ -549,20 +549,26 @@ struct BLEServiceCoreTests {
|
||||
|
||||
// Preserve a working victim session while an unauthenticated
|
||||
// replacement candidate arrives on a newly bound physical link.
|
||||
let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID)
|
||||
// Establish BLE as responder so the replacement candidate below is
|
||||
// not coalesced by the initiator-completion grace path.
|
||||
let message1 = try victim.initiateHandshake(with: ble.myPeerID)
|
||||
let message2 = try #require(
|
||||
try victim.processHandshakeMessage(from: ble.myPeerID, message: message1)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try ble._test_noiseProcessHandshakeMessage(
|
||||
from: victimPeerID,
|
||||
message: message1
|
||||
)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try victim.processHandshakeMessage(
|
||||
from: ble.myPeerID,
|
||||
message: message2
|
||||
)
|
||||
)
|
||||
_ = try victim.processHandshakeMessage(
|
||||
from: ble.myPeerID,
|
||||
_ = try ble._test_noiseProcessHandshakeMessage(
|
||||
from: victimPeerID,
|
||||
message: message3
|
||||
)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
||||
|
||||
let centralUUID = "central-replacement-xx-message-one"
|
||||
|
||||
@@ -5,7 +5,7 @@ import BitFoundation
|
||||
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("Noise Coverage Tests")
|
||||
@Suite("Noise Coverage Tests", .serialized)
|
||||
struct NoiseCoverageTests {
|
||||
private let keychain = MockKeychain()
|
||||
private let aliceStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
@@ -633,8 +633,16 @@ struct NoiseCoverageTests {
|
||||
)
|
||||
let replacementSession = try #require(manager.getSession(for: alicePeerID))
|
||||
|
||||
let localPeerID = PeerID(
|
||||
publicKey: aliceStaticKey.publicKey.rawRepresentation
|
||||
)
|
||||
if localPeerID < alicePeerID {
|
||||
#expect(replacementResponse == nil)
|
||||
#expect(replacementSession === restartedSession)
|
||||
} else {
|
||||
#expect(replacementResponse != nil)
|
||||
#expect(replacementSession !== restartedSession)
|
||||
}
|
||||
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
||||
@@ -654,7 +662,13 @@ struct NoiseCoverageTests {
|
||||
try aliceManager.initiateHandshake(with: alicePeerID)
|
||||
}
|
||||
|
||||
let rekeyHandshake = try aliceManager.initiateRekey(for: alicePeerID)
|
||||
let rekeyInitiation = try aliceManager.initiateRekey(for: alicePeerID)
|
||||
let rekeyHandshake = try #require(
|
||||
aliceManager.claimHandshakeInitiation(
|
||||
rekeyInitiation,
|
||||
for: alicePeerID
|
||||
)
|
||||
)
|
||||
#expect(!rekeyHandshake.isEmpty)
|
||||
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
|
||||
|
||||
@@ -667,6 +681,7 @@ struct NoiseCoverageTests {
|
||||
let aliceManager = NoiseSessionManager(
|
||||
localStaticKey: aliceStaticKey,
|
||||
keychain: keychain,
|
||||
recentInitiatorCompletionGracePeriod: 0,
|
||||
sessionFactory: { peerID, role in
|
||||
BlockingDecryptNoiseSession(
|
||||
peerID: peerID,
|
||||
|
||||
@@ -198,6 +198,24 @@ struct BLENoisePacketHandlerTests {
|
||||
#expect(recorder.broadcastPackets.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func managedHandshakeFailureDoesNotStartASecondRecovery() {
|
||||
let recorder = Recorder()
|
||||
recorder.handshakeResult = .failure(
|
||||
NoiseManagedHandshakeFailure(underlying: TestError())
|
||||
)
|
||||
recorder.hasSession = false
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeHandshakePacket(
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
)
|
||||
|
||||
#expect(!handler.handleHandshake(packet, from: remotePeerID))
|
||||
#expect(recorder.hasSessionQueries.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
#expect(recorder.broadcastPackets.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: Encrypted
|
||||
|
||||
@Test
|
||||
|
||||
@@ -3,7 +3,7 @@ import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("NoiseEncryptionService Tests")
|
||||
@Suite("NoiseEncryptionService Tests", .serialized)
|
||||
struct NoiseEncryptionServiceTests {
|
||||
|
||||
@Test("Encryption status accessors cover all cases")
|
||||
@@ -267,10 +267,10 @@ struct NoiseEncryptionServiceTests {
|
||||
#expect(leaseRan)
|
||||
|
||||
var emittedPeerID: PeerID?
|
||||
var emittedMessage: Data?
|
||||
alice.onRekeyHandshakeReady = { peerID, message in
|
||||
var emittedInitiation: NoiseHandshakeInitiation?
|
||||
alice.onRekeyHandshakeReady = { peerID, initiation in
|
||||
emittedPeerID = peerID
|
||||
emittedMessage = message
|
||||
emittedInitiation = initiation
|
||||
}
|
||||
try alice._test_initiateAutomaticRekey(for: bobPeerID)
|
||||
|
||||
@@ -286,7 +286,10 @@ struct NoiseEncryptionServiceTests {
|
||||
}
|
||||
#expect(staleLease == nil)
|
||||
#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(alice.hasSession(with: bobPeerID))
|
||||
#expect(!alice.hasEstablishedSession(with: bobPeerID))
|
||||
@@ -353,6 +356,700 @@ struct NoiseEncryptionServiceTests {
|
||||
#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")
|
||||
func atomicReconnectQueuesUntilOrdinaryHandshakeCompletes() throws {
|
||||
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")
|
||||
func forgedReconnectRestoresQuarantinedTransport() throws {
|
||||
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 let lock = NSLock()
|
||||
private var entries: [(PeerID, String)] = []
|
||||
@@ -696,3 +1468,78 @@ private final class AuthenticationRecorder: @unchecked Sendable {
|
||||
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