Fix cached Noise reconnects atomically

This commit is contained in:
jack
2026-07-25 20:39:44 +02:00
parent 40ca914f2a
commit fb07d73d5c
11 changed files with 1010 additions and 113 deletions
@@ -36,6 +36,10 @@ enum NoiseSecurityConstants {
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
static let xxInitialMessageSize = 32
// 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
// Session timeout - sessions older than this should be renegotiated
static let sessionTimeout: TimeInterval = 86400 // 24 hours
+262 -81
View File
@@ -11,25 +11,50 @@ import CryptoKit
import Foundation
import BitFoundation
struct NoiseHandshakeInitiation: Equatable, Sendable {
let payload: Data
let attemptID: UUID
}
final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:]
/// Opaque identity for each exact entry in `sessions`. The generation is
/// created and removed under the same barrier as the session itself, so a
/// caller can never authenticate data with one session and lease another.
private var sessionGenerations: [PeerID: UUID] = [:]
/// A responder rehandshake must not evict a working transport session
/// before the candidate proves that its authenticated static key belongs
/// to the claimed wire ID. Candidates therefore live outside `sessions`
/// until the XX handshake completes and the binding is validated.
private var responderCandidates: [PeerID: NoiseSession] = [:]
/// 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 struct QuarantinedTransport {
let session: NoiseSession
let generation: UUID
/// Duplicate message 1 packets replace the incomplete responder but
/// never extend the original rollback window indefinitely.
let rollbackDeadline: DispatchTime
}
/// An unauthenticated inbound message 1 cannot keep old sending keys live,
/// but it also must not permanently destroy a victim session. The old
/// transport remains receive-only while the ordinary responder proves the
/// claimed static identity, then is discarded on success or restored on
/// bounded failure.
private var quarantinedTransports: [PeerID: QuarantinedTransport] = [:]
private var quarantinedResponderTimeouts: [PeerID: DispatchWorkItem] = [:]
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
private let ordinaryResponderHandshakeTimeout: 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)?
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
init(
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
keychain: KeychainManagerProtocol,
ordinaryResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
) {
self.ordinaryResponderHandshakeTimeout = ordinaryResponderHandshakeTimeout
self.sessionFactory = { peerID, role in
SecureNoiseSession(
peerID: peerID,
@@ -44,8 +69,11 @@ final class NoiseSessionManager {
init(
localStaticKey _: Curve25519.KeyAgreement.PrivateKey,
keychain _: KeychainManagerProtocol,
ordinaryResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
) {
self.ordinaryResponderHandshakeTimeout = ordinaryResponderHandshakeTimeout
self.sessionFactory = sessionFactory
}
#endif
@@ -60,27 +88,38 @@ final class NoiseSessionManager {
func removeSession(for peerID: PeerID) {
managerQueue.sync(flags: .barrier) {
if let session = sessions.removeValue(forKey: peerID) {
session.reset() // Clear sensitive data before removing
}
sessionGenerations.removeValue(forKey: peerID)
if let candidate = responderCandidates.removeValue(forKey: peerID) {
candidate.reset()
}
removeSessionLocked(for: peerID)
}
}
private func removeSessionLocked(for peerID: PeerID) {
if let session = sessions.removeValue(forKey: peerID) {
session.reset() // Clear sensitive data before removing
}
if let quarantined = quarantinedTransports.removeValue(forKey: peerID) {
quarantined.session.reset()
}
quarantinedResponderTimeouts.removeValue(forKey: peerID)?.cancel()
sessionGenerations.removeValue(forKey: peerID)
ordinaryInitiationIDs.removeValue(forKey: peerID)
}
func removeAllSessions() {
managerQueue.sync(flags: .barrier) {
for (_, session) in sessions {
session.reset()
}
for (_, candidate) in responderCandidates {
candidate.reset()
for (_, quarantined) in quarantinedTransports {
quarantined.session.reset()
}
for (_, timeout) in quarantinedResponderTimeouts {
timeout.cancel()
}
sessions.removeAll()
sessionGenerations.removeAll()
responderCandidates.removeAll()
ordinaryInitiationIDs.removeAll()
quarantinedTransports.removeAll()
quarantinedResponderTimeouts.removeAll()
}
}
@@ -98,6 +137,7 @@ final class NoiseSessionManager {
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
_ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
ordinaryInitiationIDs.removeValue(forKey: peerID)
existingSession.reset()
}
@@ -113,12 +153,73 @@ final class NoiseSessionManager {
// Clean up failed session
_ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
ordinaryInitiationIDs.removeValue(forKey: peerID)
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,
authorize: () throws -> Void
) throws -> NoiseHandshakeInitiation {
try managerQueue.sync(flags: .barrier) {
guard let established = sessions[peerID],
established.isEstablished() else {
throw NoiseSessionError.notEstablished
}
try authorize()
let next = sessionFactory(peerID, .initiator)
let payload: Data
do {
payload = try next.startHandshake()
} catch {
next.reset()
SecureLogger.error(
.handshakeFailed(
peerID: peerID.id,
error: error.localizedDescription
)
)
throw error
}
removeSessionLocked(for: peerID)
let attemptID = UUID()
sessions[peerID] = next
sessionGenerations[peerID] = UUID()
ordinaryInitiationIDs[peerID] = attemptID
return NoiseHandshakeInitiation(
payload: payload,
attemptID: attemptID
)
}
}
/// Claims a prepared message 1 exactly once. A crossed inbound initiation
/// that already made this peer a responder invalidates the token.
func claimHandshakeInitiation(
_ initiation: NoiseHandshakeInitiation,
for peerID: PeerID
) -> Data? {
managerQueue.sync(flags: .barrier) {
guard ordinaryInitiationIDs[peerID] == initiation.attemptID,
let session = sessions[peerID],
session.role == .initiator,
session.getState() == .handshaking else {
return nil
}
ordinaryInitiationIDs.removeValue(forKey: peerID)
return initiation.payload
}
}
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
// Process everything within the synchronized block to prevent race conditions.
@@ -133,52 +234,52 @@ final class NoiseSessionManager {
)?
) = try managerQueue.sync(flags: .barrier) {
let session: NoiseSession
let isReplacementCandidate: Bool
if let candidate = responderCandidates[peerID] {
// A fresh XX message 1 supersedes an incomplete candidate,
// but never the established session it is trying to replace.
if message.count == NoiseSecurityConstants.xxInitialMessageSize {
candidate.reset()
let replacement = sessionFactory(peerID, .responder)
responderCandidates[peerID] = replacement
session = replacement
} else {
session = candidate
}
isReplacementCandidate = true
} else if let existing = sessions[peerID] {
if existing.isEstablished() {
SecureLogger.info(
"Validating replacement handshake from \(peerID) while preserving the established session",
category: .session
)
let candidate = sessionFactory(peerID, .responder)
responderCandidates[peerID] = candidate
session = candidate
isReplacementCandidate = true
} else if existing.getState() == .handshaking,
message.count == NoiseSecurityConstants.xxInitialMessageSize {
// No established transport state exists to preserve. A
// fresh initiation replaces the incomplete handshake.
if let existing = sessions[peerID],
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.
_ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
existing.reset()
let replacement = sessionFactory(peerID, .responder)
sessions[peerID] = replacement
sessionGenerations[peerID] = UUID()
session = replacement
isReplacementCandidate = false
ordinaryInitiationIDs.removeValue(forKey: peerID)
if let prior = quarantinedTransports.updateValue(
QuarantinedTransport(
session: existing,
generation: generation,
rollbackDeadline: .now()
+ ordinaryResponderHandshakeTimeout
),
forKey: peerID
) {
prior.session.reset()
}
} else {
session = existing
isReplacementCandidate = false
_ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
ordinaryInitiationIDs.removeValue(forKey: peerID)
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] {
session = existing
} else {
let newSession = sessionFactory(peerID, .responder)
sessions[peerID] = newSession
sessionGenerations[peerID] = UUID()
session = newSession
isReplacementCandidate = false
}
// Process the handshake message within the synchronized block
@@ -196,14 +297,15 @@ final class NoiseSessionManager {
throw NoiseSessionError.peerIdentityMismatch
}
if isReplacementCandidate {
_ = responderCandidates.removeValue(forKey: peerID)
let previous = sessions.updateValue(session, forKey: peerID)
sessionGenerations[peerID] = UUID()
if let previous, previous !== session {
previous.reset()
}
if let quarantined = quarantinedTransports.removeValue(
forKey: peerID
) {
quarantinedResponderTimeouts.removeValue(
forKey: peerID
)?.cancel()
quarantined.session.reset()
}
ordinaryInitiationIDs.removeValue(forKey: peerID)
guard let generation = sessionGenerations[peerID] else {
throw NoiseEncryptionError.sessionNotEstablished
}
@@ -212,23 +314,28 @@ final class NoiseSessionManager {
return (response, establishedSession)
} catch {
// A failed candidate is discarded without touching the
// established session. Ordinary failed handshakes retain the
// historical cleanup behavior.
if isReplacementCandidate {
if let storedCandidate = responderCandidates[peerID],
storedCandidate === session {
_ = responderCandidates.removeValue(forKey: peerID)
}
} else if let storedSession = sessions[peerID],
storedSession === session {
if let storedSession = sessions[peerID],
storedSession === session {
_ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
}
ordinaryInitiationIDs.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
} else {
restoredGeneration = nil
}
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
if let restoredGeneration {
self?.onSessionRestored?(peerID, restoredGeneration)
}
self?.onSessionFailed?(peerID, error)
}
@@ -243,6 +350,47 @@ final class NoiseSessionManager {
return result.response
}
private func scheduleQuarantinedResponderTimeoutLocked(
_ responder: NoiseSession,
for peerID: PeerID
) {
quarantinedResponderTimeouts.removeValue(forKey: peerID)?.cancel()
let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak responder] in
guard let self,
let responder,
let current = self.sessions[peerID],
current === responder,
current.role == .responder,
current.getState() == .handshaking,
let quarantined = self.quarantinedTransports.removeValue(
forKey: peerID
) 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
SecureLogger.debug(
"Ordinary responder handshake with \(peerID) timed out; restored quarantined transport",
category: .session
)
DispatchQueue.global().async { [weak self] in
self?.onSessionRestored?(peerID, quarantined.generation)
}
}
quarantinedResponderTimeouts[peerID] = timeout
guard let deadline = quarantinedTransports[peerID]?.rollbackDeadline else {
quarantinedResponderTimeouts.removeValue(forKey: peerID)
return
}
managerQueue.asyncAfter(deadline: deadline, execute: timeout)
}
/// 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;
@@ -274,7 +422,7 @@ final class NoiseSessionManager {
/// Encrypts only if `expected` still names the current established entry.
/// A rekey between capability proof and media encryption therefore fails
/// closed instead of sending on an unproven replacement session.
/// closed instead of sending on an unproven reconnect session.
func encrypt(
_ plaintext: Data,
for peerID: PeerID,
@@ -309,19 +457,49 @@ final class NoiseSessionManager {
from peerID: PeerID
) throws -> (plaintext: Data, sessionGeneration: UUID) {
try managerQueue.sync {
guard let session = sessions[peerID] else {
if let session = sessions[peerID],
session.isEstablished(),
let generation = sessionGenerations[peerID] {
return (try session.decrypt(ciphertext), generation)
}
// Quarantine is receive-only: old keys cannot encrypt, advertise
// an established generation, or authorize outbound state, but
// legitimate in-flight ciphertext from the retained peer may
// still advance and later resume on rollback.
if let responder = sessions[peerID],
responder.role == .responder,
responder.getState() == .handshaking,
let quarantined = quarantinedTransports[peerID] {
return (
try quarantined.session.decrypt(ciphertext),
quarantined.generation
)
}
if sessions[peerID] == nil, quarantinedTransports[peerID] == nil {
throw NoiseSessionError.sessionNotFound
}
guard session.isEstablished(),
let generation = sessionGenerations[peerID] else {
throw NoiseEncryptionError.sessionNotEstablished
throw NoiseEncryptionError.sessionNotEstablished
}
}
func hasReceiveSession(for peerID: PeerID) -> Bool {
managerQueue.sync {
if sessions[peerID]?.isEstablished() == true {
return true
}
return (try session.decrypt(ciphertext), generation)
guard let responder = sessions[peerID],
responder.role == .responder,
responder.getState() == .handshaking else {
return false
}
return quarantinedTransports[peerID]?.session.isEstablished() == true
}
}
/// Runs a state commit under a read lease for the exact established
/// session. Rekey, replacement, and removal all need the same barrier.
/// session. Rekey, reconnect, and removal all need the same barrier.
func withCurrentSessionGeneration<Result>(
for peerID: PeerID,
expected: UUID,
@@ -359,10 +537,13 @@ final class NoiseSessionManager {
}
func initiateRekey(for peerID: PeerID) throws -> Data {
// Remove old session
removeSession(for: peerID)
// Initiate new handshake
return try initiateHandshake(with: peerID)
let initiation = try initiateReconnectHandshake(
with: peerID,
authorize: {}
)
guard let payload = claimHandshakeInitiation(initiation, for: peerID) else {
throw NoiseSessionError.invalidState
}
return payload
}
}
@@ -62,8 +62,8 @@ final class BLENoisePacketHandler {
}
/// Returns true when the handshake message was processed successfully.
/// Callers use this to distinguish an authenticated replacement completion
/// from a rejected candidate while an older session remains established.
/// Callers use this to distinguish an authenticated reconnect completion
/// from a rejected ordinary responder while rollback state is restored.
@discardableResult
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
let env = environment
@@ -90,7 +90,7 @@ final class BLENoisePacketHandler {
// which will send any pending messages at the right time
return true
} catch NoiseSessionError.peerIdentityMismatch {
// The candidate was already discarded by the session manager.
// The responder was already discarded by the session manager.
// Do not let a spoofed claimed ID trigger a fresh outbound
// handshake or recreate state for the attacker-selected ID.
SecureLogger.warning(
@@ -0,0 +1,40 @@
import Foundation
/// Bounds ordinary Noise revalidation to one attempt per physical-link epoch.
/// A live epoch may retry after the cooldown so a lost handshake cannot leave
/// the link permanently unauthenticated.
struct BLENoiseReconnectPolicy {
static let minimumRetryInterval: TimeInterval = 60
private var lastAttemptAt: [BLEIngressLinkID: Date] = [:]
mutating func shouldRevalidate(
on link: BLEIngressLinkID,
hasEstablishedSession: Bool,
isNoiseAuthenticatedLink: Bool,
hasAuthenticatedPeerLink: Bool,
now: Date
) -> Bool {
guard hasEstablishedSession,
!isNoiseAuthenticatedLink,
!hasAuthenticatedPeerLink else {
return false
}
if let previous = lastAttemptAt[link],
now.timeIntervalSince(previous) < Self.minimumRetryInterval {
return false
}
lastAttemptAt[link] = now
return true
}
/// Link identifiers can be stable across CoreBluetooth reconnects, so a
/// disconnect explicitly starts a new epoch and permits one fresh attempt.
mutating func endLinkEpoch(_ link: BLEIngressLinkID) {
lastAttemptAt.removeValue(forKey: link)
}
mutating func removeAll() {
lastAttemptAt.removeAll()
}
}
+134 -10
View File
@@ -241,6 +241,7 @@ final class BLEService: NSObject {
// that the session was established *on this current ingress link*, not
// merely that some session exists for the claimed ID. bleQueue-owned.
private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:]
private var noiseReconnectPolicy = BLENoiseReconnectPolicy()
// Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link
// store): entries older than the cooldown are pruned on insert.
@@ -311,6 +312,9 @@ final class BLEService: NSObject {
/// May block in tests to hold the serial message queue immediately before
/// the deferred private-media admission check.
var _test_beforePrivateMediaDeferredSend: ((String) -> Void)?
/// May block announce handling after verified-link rebind work is queued.
/// Tests use this boundary to prove rebind and reconnect are serialized.
var _test_afterVerifiedDirectRebindEnqueued: (() -> Void)?
#endif
private var selfBroadcastTracker = BLESelfBroadcastTracker()
private let meshTopology = MeshTopologyTracker()
@@ -764,6 +768,8 @@ final class BLEService: NSObject {
bleQueue.sync {
pendingWriteBuffers.removeAll()
noiseAuthenticatedLinkOwners.removeAll()
noiseReconnectPolicy.removeAll()
connectionScheduler.reset()
}
disconnectNotifyDebouncer.removeAll()
@@ -1061,6 +1067,7 @@ final class BLEService: NSObject {
bleQueue.sync {
linkStateStore.clearAll()
noiseAuthenticatedLinkOwners.removeAll()
noiseReconnectPolicy.removeAll()
connectionScheduler.reset()
subscriptionAnnounceLimiter.removeAll()
}
@@ -2617,6 +2624,7 @@ final class BLEService: NSObject {
}
for link in departedLinks {
noiseAuthenticatedLinkOwners.removeValue(forKey: link)
noiseReconnectPolicy.endLinkEpoch(link)
}
}
_ = collectionsQueue.sync(flags: .barrier) {
@@ -3097,6 +3105,7 @@ extension BLEService: CBCentralManagerDelegate {
pendingPeripheralWrites.discardAll(for: peripheralID)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
_ = linkStateStore.removePeripheral(peripheralID)
// A duplicate link can drop while the peer stays live on another
// (the dual-role central link, or a second bound link after a
@@ -3151,6 +3160,7 @@ extension BLEService: CBCentralManagerDelegate {
pendingPeripheralWrites.discardAll(for: peripheralID)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
_ = linkStateStore.removePeripheral(peripheralID)
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
@@ -3258,6 +3268,7 @@ extension BLEService {
self.pendingPeripheralWrites.discardAll(for: peripheralID)
}
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
_ = self.linkStateStore.removePeripheral(peripheralID)
self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date())
self.tryConnectFromQueue()
@@ -3966,6 +3977,7 @@ extension BLEService: CBPeripheralManagerDelegate {
pendingNotifications.removeTarget { $0.identifier.uuidString == centralID }
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID))
noiseReconnectPolicy.endLinkEpoch(.central(centralID))
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
// Ensure we're still advertising for other devices to find us
@@ -4583,6 +4595,40 @@ extension BLEService {
})
}
}
/// A peer-level session can outlive the physical link that established it.
/// Revalidate a fresh direct link with an ordinary XX exchange, retiring
/// cached sending keys atomically before message 1 can leave.
private func refreshNoiseSessionForVerifiedDirectLink(
_ packet: BitchatPacket,
peerID: PeerID
) {
guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else {
return
}
let hasEstablishedSession = noiseService.hasEstablishedSession(with: peerID)
let authenticatedPeerLinks = currentNoiseAuthenticatedLinks(to: peerID)
let shouldRevalidate = readLinkState { store in
guard boundPeerID(for: link, in: store) == peerID else {
return false
}
return noiseReconnectPolicy.shouldRevalidate(
on: link,
hasEstablishedSession: hasEstablishedSession,
isNoiseAuthenticatedLink: noiseAuthenticatedLinkOwners[link] == peerID,
hasAuthenticatedPeerLink: !authenticatedPeerLinks.isEmpty,
now: Date()
)
}
guard shouldRevalidate else { return }
SecureLogger.info(
"🔄 Revalidating cached Noise session on fresh direct link to \(peerID.id.prefix(8))",
category: .session
)
initiateNoiseReconnectHandshake(with: peerID)
}
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in
@@ -4602,6 +4648,29 @@ extension BLEService {
self.broadcastNoiseHandshake(message, to: peerID)
}
}
service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation in
guard let self, let service else { return }
self.messageQueue.async { [weak self, weak service] in
guard let self,
let service,
self.noiseService === service,
let fingerprint = service.getPeerFingerprint(peerID) else {
return
}
SecureLogger.debug(
"🔐 Restored quarantined Noise session with \(peerID.id.prefix(8))",
category: .session
)
// Re-enter the same generation-bound transition used after a
// successful handshake. This restores authenticated protocol
// state and drains both PM and typed-payload queues.
self.handleNoisePeerAuthenticated(
peerID: peerID,
fingerprint: fingerprint,
sessionGeneration: generation
)
}
}
}
private func handleNoisePeerAuthenticated(
@@ -4855,8 +4924,9 @@ extension BLEService {
}
return
}
guard noiseService.hasSession(with: peerID) else {
// No session yet - queue the payload SYNCHRONOUSLY before initiating handshake
guard noiseService.hasEstablishedSession(with: peerID) else {
// No established session yet - queue the payload synchronously
// before initiating a handshake
// to prevent race where fast handshake completion drains empty queue
collectionsQueue.sync(flags: .barrier) {
self.pendingNoiseSessionQueues.appendTypedPayload(typedPayload, for: peerID)
@@ -5786,6 +5856,7 @@ extension BLEService {
self.pendingPeripheralWrites.discardAll(for: peripheralID)
}
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
_ = self.linkStateStore.removePeripheral(peripheralID)
cancelled += 1
}
@@ -5874,6 +5945,37 @@ extension BLEService {
)
broadcastPacket(packet)
}
/// Starts a wire-compatible ordinary XX reconnect. The manager prepares
/// the initiator before atomically retiring the cached transport; the
/// one-shot claim prevents a crossed inbound message from making a stale
/// message 1 leave after this peer has already become responder.
private func initiateNoiseReconnectHandshake(with peerID: PeerID) {
let service = noiseService
do {
let initiation = try service.initiateReconnectHandshake(with: peerID)
noteNoiseSessionCleared(for: peerID)
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 NoiseSessionError.notEstablished {
initiateNoiseHandshake(with: peerID)
} catch {
SecureLogger.error(
"Failed to initiate ordinary reconnect: \(error)",
category: .session
)
}
}
private func sendPendingMessagesAfterHandshake(for peerID: PeerID) {
// Atomically take all pending messages to process (prevents concurrent modification)
@@ -6429,6 +6531,9 @@ extension BLEService {
// consolidate duplicate same-role connections onto that link.
if let result, result.isVerified, result.isDirectAnnounce {
rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID)
#if DEBUG
_test_afterVerifiedDirectRebindEnqueued?()
#endif
retireRedundantPeripheralLinks(packet, to: result.peerID)
}
@@ -6462,11 +6567,9 @@ extension BLEService {
deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
if result.isDirectAnnounce,
!hasCurrentNoiseAuthenticatedLink(to: result.peerID) {
if noiseService.hasEstablishedSession(with: result.peerID) {
// A session with no surviving authenticated link is stale;
// force the current link to prove possession again.
clearNoiseSession(for: result.peerID)
}
// A cached session may predate this physical link.
// rebindLinkAfterVerifiedDirectAnnounce performs its atomic
// ordinary reconnect after the binding is published.
if !noiseService.hasSession(with: result.peerID) {
initiateNoiseHandshake(with: result.peerID)
}
@@ -6495,7 +6598,14 @@ extension BLEService {
linkUUID = centralUUID
previousPeerID = self.linkStateStore.peerID(forCentralUUID: centralUUID)
}
guard let previousPeerID, previousPeerID != peerID else { return }
guard let previousPeerID else { return }
guard previousPeerID != peerID else {
self.refreshNoiseSessionForVerifiedDirectLink(
packet,
peerID: peerID
)
return
}
// The signature does not authenticate directness (TTL is excluded
// from signing because relays mutate it), so a "verified direct"
@@ -6522,12 +6632,20 @@ extension BLEService {
// it across an announce-driven rebind, whose direct TTL is
// replayable; the new owner must complete a fresh handshake.
self.noiseAuthenticatedLinkOwners.removeValue(forKey: link)
self.noiseReconnectPolicy.endLinkEpoch(link)
switch link {
case .peripheral(let peripheralUUID):
self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID)
case .central(let centralUUID):
self.linkStateStore.bindCentral(centralUUID, to: peerID)
}
// Keep the rebind and reconnect decision in one bleQueue critical
// section. No observer may see the new binding while a cached
// peer-level sender is still considered established.
self.refreshNoiseSessionForVerifiedDirectLink(
packet,
peerID: peerID
)
SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))", category: .session)
self.refreshLocalTopology()
// The announce that triggered this rebind was upserted as
@@ -6619,6 +6737,7 @@ extension BLEService {
pendingPeripheralWrites.discardAll(for: uuid)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid))
noiseReconnectPolicy.endLinkEpoch(.peripheral(uuid))
_ = linkStateStore.removePeripheral(uuid)
SecureLogger.info(
"🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")",
@@ -7004,9 +7123,14 @@ extension BLEService {
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
let processed = noisePacketHandler.handleHandshake(packet, from: peerID)
let isEstablished = noiseService.hasEstablishedSession(with: peerID)
if wasEstablished,
packet.payload.count == NoiseSecurityConstants.xxInitialMessageSize,
!isEstablished {
noteNoiseSessionCleared(for: peerID)
}
// XX message 1 is exactly the unauthenticated 32-byte ephemeral key.
// While replacing an existing session, do not authenticate its ingress
// link until a later message completes and validates the candidate.
// During an ordinary reconnect, do not authenticate its ingress link
// until a later message completes and proves the responder identity.
let completedAuthenticatedHandshake = !wasEstablished
|| packet.payload.count != NoiseSecurityConstants.xxInitialMessageSize
if processed, isEstablished, completedAuthenticatedHandshake {
+56 -5
View File
@@ -189,6 +189,10 @@ final class NoiseEncryptionService {
/// 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)?
/// An unauthenticated reconnect attempt failed or timed out and the
/// receive-only rollback session became the active transport again.
/// Transport queues must be drained for this exact restored generation.
var onSessionRestoredWithGeneration: ((_ peerID: PeerID, _ generation: UUID) -> Void)?
// Add a handler for peer authentication
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
@@ -219,7 +223,11 @@ final class NoiseEncryptionService {
}
}
init(keychain: KeychainManagerProtocol) {
init(
keychain: KeychainManagerProtocol,
ordinaryResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
) {
self.keychain = keychain
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
@@ -309,7 +317,11 @@ final class NoiseEncryptionService {
self.signingPublicKey = signingKey.publicKey
// Initialize session manager
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
self.sessionManager = NoiseSessionManager(
localStaticKey: staticIdentityKey,
keychain: keychain,
ordinaryResponderHandshakeTimeout: ordinaryResponderHandshakeTimeout
)
// Set up session callbacks
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in
@@ -319,6 +331,9 @@ final class NoiseEncryptionService {
sessionGeneration: generation
)
}
sessionManager.onSessionRestored = { [weak self] peerID, generation in
self?.onSessionRestoredWithGeneration?(peerID, generation)
}
// Start session maintenance timer
startRekeyTimer()
@@ -682,6 +697,37 @@ final class NoiseEncryptionService {
let handshakeData = try sessionManager.initiateHandshake(with: peerID)
return handshakeData
}
/// 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
) throws -> NoiseHandshakeInitiation {
guard peerID.isValid else {
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
throw NoiseSecurityError.invalidPeerID
}
return try sessionManager.initiateReconnectHandshake(
with: peerID,
authorize: { [rateLimiter] in
guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.warning(
.authenticationFailed(peerID: "Rate limited: \(peerID)")
)
throw NoiseSecurityError.rateLimitExceeded
}
}
)
}
func claimHandshakeInitiation(
_ initiation: NoiseHandshakeInitiation,
for peerID: PeerID
) -> Data? {
sessionManager.claimHandshakeInitiation(initiation, for: peerID)
}
/// Process an incoming handshake message
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
@@ -706,7 +752,10 @@ final class NoiseEncryptionService {
// For handshakes, we process the raw data directly without NoiseMessage wrapper
// The Noise protocol handles its own message format
let responsePayload = try sessionManager.handleIncomingHandshake(from: peerID, message: message)
let responsePayload = try sessionManager.handleIncomingHandshake(
from: peerID,
message: message
)
// Return raw response without wrapper
@@ -804,8 +853,10 @@ final class NoiseEncryptionService {
throw NoiseSecurityError.rateLimitExceeded
}
// Check if we have an established session
guard hasEstablishedSession(with: peerID) else {
// A quarantined transport is deliberately unavailable for outbound
// state, but remains receive-only until the responder proves identity
// or the bounded rollback restores it.
guard sessionManager.hasReceiveSession(for: peerID) else {
throw NoiseEncryptionError.sessionNotEstablished
}
+157 -4
View File
@@ -502,14 +502,26 @@ struct BLEServiceCoreTests {
)
let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce")
#expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink))
let rebindGate = VerifiedDirectRebindGate()
ble._test_afterVerifiedDirectRebindEnqueued = rebindGate.pause
defer {
rebindGate.release()
ble._test_afterVerifiedDirectRebindEnqueued = nil
}
ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false)
let rebound = await TestHelpers.waitUntil(
{ ble._test_centralBinding(attackerLink) == victimPeerID },
let announcePaused = await TestHelpers.waitUntil(
{ rebindGate.hasPaused },
timeout: TestConstants.longTimeout
)
#expect(rebound)
#expect(ble.canDeliverSecurely(to: victimPeerID))
try #require(announcePaused)
// Rebind and ordinary reconnect preparation are one bleQueue
// critical section. Once the binding is visible, stale sending keys
// must already be unavailable.
#expect(ble._test_centralBinding(attackerLink) == victimPeerID)
#expect(!ble.canDeliverSecurely(to: victimPeerID))
rebindGate.release()
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = { outbound.record($0) }
@@ -529,6 +541,113 @@ struct BLEServiceCoreTests {
#expect(outbound.count(ofType: .courierEnvelope) == 0)
}
@Test
func failedInboundReconnectRestoresAndDrainsTypedPayloadQueue() async throws {
let ble = makeService()
let alice = NoiseEncryptionService(keychain: MockKeychain())
let mallory = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
let message1 = try ble._test_noiseInitiateHandshake(with: alicePeerID)
let message2 = try #require(
try alice.processHandshakeMessage(from: ble.myPeerID, message: message1)
)
let message3 = try #require(
try ble._test_noiseProcessHandshakeMessage(from: alicePeerID, message: message2)
)
_ = try alice.processHandshakeMessage(from: ble.myPeerID, message: message3)
await ble._test_drainNoiseMessagePipeline()
#expect(ble.canDeliverSecurely(to: alicePeerID))
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record
let forgedMessage1 = try mallory.initiateHandshake(with: ble.myPeerID)
let firstPacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: Data(hexString: alicePeerID.id) ?? Data(),
recipientID: Data(hexString: ble.myPeerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000),
payload: forgedMessage1,
signature: nil,
ttl: 7
)
ble._test_handlePacket(firstPacket, fromPeerID: alicePeerID)
let responseReady = await TestHelpers.waitUntil(
{
outbound.snapshot().contains {
$0.type == MessageType.noiseHandshake.rawValue
}
},
timeout: TestConstants.longTimeout
)
try #require(responseReady)
let forgedMessage2 = try #require(
outbound.snapshot().first {
$0.type == MessageType.noiseHandshake.rawValue
}?.payload
)
#expect(!ble.canDeliverSecurely(to: alicePeerID))
// Typed control traffic must queue behind the ordinary responder,
// rather than attempting encryption and disappearing.
let privateMessageID = "quarantine-pm-\(UUID().uuidString)"
ble.sendPrivateMessage(
"queued private message",
to: alicePeerID,
recipientNickname: "Alice",
messageID: privateMessageID
)
ble.sendGroupInvite(Data("queued-during-quarantine".utf8), to: alicePeerID)
await ble._test_drainNoiseMessagePipeline()
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
let forgedMessage3 = try #require(
try mallory.processHandshakeMessage(
from: ble.myPeerID,
message: forgedMessage2
)
)
let thirdPacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: Data(hexString: alicePeerID.id) ?? Data(),
recipientID: Data(hexString: ble.myPeerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 1,
payload: forgedMessage3,
signature: nil,
ttl: 7
)
ble._test_handlePacket(thirdPacket, fromPeerID: alicePeerID)
// Restore re-enters the generation-bound authentication transition:
// authenticated state and both outbound queues drain exactly once.
let drained = await TestHelpers.waitUntil(
{ outbound.count(ofType: .noiseEncrypted) >= 3 },
timeout: TestConstants.longTimeout
)
try #require(drained)
await ble._test_drainNoiseMessagePipeline()
let plaintexts = try outbound.snapshot()
.filter { $0.type == MessageType.noiseEncrypted.rawValue }
.map { try alice.decrypt($0.payload, from: ble.myPeerID) }
#expect(plaintexts.count == 3)
#expect(
plaintexts.filter {
$0.first == NoisePayloadType.authenticatedPeerState.rawValue
}.count == 1
)
#expect(
plaintexts.filter {
$0.first == NoisePayloadType.privateMessage.rawValue
}.count == 1
)
#expect(
plaintexts.filter {
$0.first == NoisePayloadType.groupInvite.rawValue
}.count == 1
)
}
/// A legitimate rotation announce necessarily arrives on a link still
/// bound to the OLD ID, so its registry upsert stores the new peer
/// disconnected. The successful rebind must promote it: a healed
@@ -854,6 +973,40 @@ private final class OutboundPacketTap {
lock.lock(); defer { lock.unlock() }
return packets.filter { $0.type == type.rawValue }.count
}
func snapshot() -> [BitchatPacket] {
lock.lock(); defer { lock.unlock() }
return packets
}
}
private final class VerifiedDirectRebindGate: @unchecked Sendable {
private let condition = NSCondition()
private var paused = false
private var released = false
var hasPaused: Bool {
condition.lock()
defer { condition.unlock() }
return paused
}
func pause() {
condition.lock()
paused = true
condition.broadcast()
while !released {
condition.wait()
}
condition.unlock()
}
func release() {
condition.lock()
released = true
condition.broadcast()
condition.unlock()
}
}
private final class ReceivePacketHandoffGate: @unchecked Sendable {
@@ -0,0 +1,115 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("BLE Noise reconnect policy")
struct BLENoiseReconnectPolicyTests {
@Test("Revalidation requires a cached session and no authenticated link")
func revalidationPreconditions() {
var policy = BLENoiseReconnectPolicy()
let link = BLEIngressLinkID.peripheral("peripheral-a")
let now = Date(timeIntervalSince1970: 1_000)
let withoutSession = policy.shouldRevalidate(
on: link,
hasEstablishedSession: false,
isNoiseAuthenticatedLink: false,
hasAuthenticatedPeerLink: false,
now: now
)
#expect(!withoutSession)
let authenticated = policy.shouldRevalidate(
on: link,
hasEstablishedSession: true,
isNoiseAuthenticatedLink: true,
hasAuthenticatedPeerLink: true,
now: now
)
#expect(!authenticated)
let eligible = policy.shouldRevalidate(
on: link,
hasEstablishedSession: true,
isNoiseAuthenticatedLink: false,
hasAuthenticatedPeerLink: false,
now: now
)
#expect(eligible)
}
@Test("Revalidation is once per link epoch or after sixty seconds")
func revalidationIsBoundPerLinkEpoch() {
var policy = BLENoiseReconnectPolicy()
let link = BLEIngressLinkID.central("central-a")
let start = Date(timeIntervalSince1970: 2_000)
let initial = policy.shouldRevalidate(
on: link,
hasEstablishedSession: true,
isNoiseAuthenticatedLink: false,
hasAuthenticatedPeerLink: false,
now: start
)
#expect(initial)
let duringCooldown = policy.shouldRevalidate(
on: link,
hasEstablishedSession: true,
isNoiseAuthenticatedLink: false,
hasAuthenticatedPeerLink: false,
now: start.addingTimeInterval(59.999)
)
#expect(!duringCooldown)
let afterCooldown = policy.shouldRevalidate(
on: link,
hasEstablishedSession: true,
isNoiseAuthenticatedLink: false,
hasAuthenticatedPeerLink: false,
now: start.addingTimeInterval(60)
)
#expect(afterCooldown)
policy.endLinkEpoch(link)
let nextEpoch = policy.shouldRevalidate(
on: link,
hasEstablishedSession: true,
isNoiseAuthenticatedLink: false,
hasAuthenticatedPeerLink: false,
now: start.addingTimeInterval(60.001)
)
#expect(nextEpoch)
}
@Test("An authenticated sibling suppresses redundant reconnect")
func authenticatedSiblingSuppressesReconnect() {
var policy = BLENoiseReconnectPolicy()
let link = BLEIngressLinkID.peripheral("unproven-sibling")
let start = Date(timeIntervalSince1970: 3_000)
let suppressed = policy.shouldRevalidate(
on: link,
hasEstablishedSession: true,
isNoiseAuthenticatedLink: false,
hasAuthenticatedPeerLink: true,
now: start
)
#expect(!suppressed)
let eligible = policy.shouldRevalidate(
on: link,
hasEstablishedSession: true,
isNoiseAuthenticatedLink: false,
hasAuthenticatedPeerLink: false,
now: start
)
#expect(eligible)
}
@Test("Reserved replacement bit is not advertised")
func reservedReplacementBitIsNotAdvertised() {
#expect(
!PeerCapabilities.localSupported.contains(
.nonDestructiveNoiseReplacement
)
)
#expect(PeerCapabilities.localSupported.contains(.privateMedia))
}
}
@@ -171,8 +171,8 @@ struct NoiseEncryptionServiceTests {
#expect(!emittedAuthentication)
}
@Test("Failed forged replacement preserves the established peer session")
func forgedReplacementPreservesEstablishedSession() async throws {
@Test("Failed forged reconnect restores the established peer session")
func forgedReconnectRestoresEstablishedSession() async throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let receiver = NoiseEncryptionService(keychain: MockKeychain())
let mallory = NoiseEncryptionService(keychain: MockKeychain())
@@ -195,20 +195,20 @@ struct NoiseEncryptionServiceTests {
let forgedMessage2 = try #require(
try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage1)
)
// The replacement has not authenticated yet; the working Alice
// transport session must remain available throughout the candidate.
#expect(receiver.hasEstablishedSession(with: alicePeerID))
// Outbound/session-generation APIs fail closed while the old
// transport is retained solely for receive and bounded rollback.
#expect(!receiver.hasEstablishedSession(with: alicePeerID))
let forgedMessage3 = try #require(
try mallory.processHandshakeMessage(from: receiverPeerID, message: forgedMessage2)
)
do {
_ = try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage3)
Issue.record("Expected forged replacement to fail peer binding")
Issue.record("Expected forged reconnect to fail peer binding")
} catch let error as NoiseSessionError {
#expect(error == .peerIdentityMismatch)
} catch {
Issue.record("Unexpected replacement error: \(error)")
Issue.record("Unexpected reconnect error: \(error)")
}
#expect(receiver.hasEstablishedSession(with: alicePeerID))
@@ -221,8 +221,8 @@ struct NoiseEncryptionServiceTests {
#expect(!emittedReplacementAuthentication)
}
@Test("Valid rehandshake atomically replaces the established session")
func validRehandshakeReplacesEstablishedSession() throws {
@Test("Valid ordinary rehandshake atomically replaces the established session")
func validOrdinaryRehandshakeReplacesEstablishedSession() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let receiver = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
@@ -235,7 +235,7 @@ struct NoiseEncryptionServiceTests {
let message2 = try #require(
try receiver.processHandshakeMessage(from: alicePeerID, message: message1)
)
#expect(receiver.hasEstablishedSession(with: alicePeerID))
#expect(!receiver.hasEstablishedSession(with: alicePeerID))
let message3 = try #require(
try alice.processHandshakeMessage(from: receiverPeerID, message: message2)
)
@@ -353,6 +353,226 @@ struct NoiseEncryptionServiceTests {
#expect(decrypted == typedPayload)
}
@Test("Atomic reconnect retires old sending keys before message one")
func atomicReconnectQueuesUntilOrdinaryHandshakeCompletes() 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)
let oldCiphertext = try alice.encrypt(Data("old transport".utf8), for: bobPeerID)
#expect(try bob.decrypt(oldCiphertext, from: alicePeerID) == Data("old transport".utf8))
let initiation = try alice.initiateReconnectHandshake(with: bobPeerID)
#expect(alice.hasSession(with: bobPeerID))
#expect(!alice.hasEstablishedSession(with: bobPeerID))
do {
_ = try alice.encrypt(Data("must queue".utf8), for: bobPeerID)
Issue.record("Expected encryption to wait for the reconnect")
} catch NoiseEncryptionError.handshakeRequired {
// Expected: BLE queues behind the ordinary handshaking session.
} catch {
Issue.record("Unexpected in-window encryption error: \(error)")
}
let message1 = try #require(
alice.claimHandshakeInitiation(initiation, for: bobPeerID)
)
bob.clearSession(for: alicePeerID)
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 fresh = try alice.encrypt(Data("fresh transport".utf8), for: bobPeerID)
#expect(try bob.decrypt(fresh, from: alicePeerID) == Data("fresh transport".utf8))
}
@Test("Inbound reconnect quarantines old sending keys until identity proof")
func inboundReconnectQuarantinesOldTransport() throws {
let restarted = NoiseEncryptionService(keychain: MockKeychain())
let retained = NoiseEncryptionService(keychain: MockKeychain())
let restartedPeerID = PeerID(publicKey: restarted.getStaticPublicKeyData())
let retainedPeerID = PeerID(publicKey: retained.getStaticPublicKeyData())
try establishSessions(alice: restarted, bob: retained)
let inFlightOldCiphertext = try restarted.encrypt(
Data("old receive-only transport".utf8),
for: retainedPeerID
)
restarted.clearSession(for: retainedPeerID)
let message1 = try restarted.initiateHandshake(with: retainedPeerID)
let message2 = try #require(
try retained.processHandshakeMessage(
from: restartedPeerID,
message: message1
)
)
#expect(!retained.hasEstablishedSession(with: restartedPeerID))
#expect(
try retained.decrypt(
inFlightOldCiphertext,
from: restartedPeerID
) == Data("old receive-only transport".utf8)
)
do {
_ = try retained.encrypt(
Data("must queue at responder".utf8),
for: restartedPeerID
)
Issue.record("Expected quarantined responder encryption to wait")
} catch NoiseEncryptionError.handshakeRequired {
// Expected.
} catch {
Issue.record("Unexpected quarantine encryption error: \(error)")
}
let message3 = try #require(
try restarted.processHandshakeMessage(
from: retainedPeerID,
message: message2
)
)
_ = try retained.processHandshakeMessage(
from: restartedPeerID,
message: message3
)
let fresh = try retained.encrypt(
Data("identity proved".utf8),
for: restartedPeerID
)
#expect(
try restarted.decrypt(fresh, from: retainedPeerID)
== Data("identity proved".utf8)
)
}
@Test("Forged reconnect restores the quarantined transport")
func forgedReconnectRestoresQuarantinedTransport() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let mallory = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
try establishSessions(alice: alice, bob: bob)
let forgedMessage1 = try mallory.initiateHandshake(with: bobPeerID)
let forgedMessage2 = try #require(
try bob.processHandshakeMessage(
from: alicePeerID,
message: forgedMessage1
)
)
#expect(!bob.hasEstablishedSession(with: alicePeerID))
let forgedMessage3 = try #require(
try mallory.processHandshakeMessage(
from: bobPeerID,
message: forgedMessage2
)
)
do {
_ = try bob.processHandshakeMessage(
from: alicePeerID,
message: forgedMessage3
)
Issue.record("Expected forged static identity to be rejected")
} catch NoiseSessionError.peerIdentityMismatch {
// Expected; the manager restores the quarantined transport.
} catch {
Issue.record("Unexpected forged reconnect error: \(error)")
}
#expect(bob.hasEstablishedSession(with: alicePeerID))
let oldTransport = try alice.encrypt(
Data("rollback survived".utf8),
for: bobPeerID
)
#expect(
try bob.decrypt(oldTransport, from: alicePeerID)
== Data("rollback survived".utf8)
)
}
@Test("Lost reconnect completion restores the quarantined transport")
func timedOutReconnectRestoresQuarantinedTransport() async throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(
keychain: MockKeychain(),
ordinaryResponderHandshakeTimeout: 0.02
)
let mallory = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
try establishSessions(alice: alice, bob: bob)
let forgedMessage1 = try mallory.initiateHandshake(with: bobPeerID)
_ = try #require(
try bob.processHandshakeMessage(
from: alicePeerID,
message: forgedMessage1
)
)
#expect(!bob.hasEstablishedSession(with: alicePeerID))
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(bob.hasEstablishedSession(with: alicePeerID))
let oldTransport = try alice.encrypt(
Data("timeout rollback".utf8),
for: bobPeerID
)
#expect(
try bob.decrypt(oldTransport, from: alicePeerID)
== Data("timeout rollback".utf8)
)
}
@Test("Failed reconnect authorization preserves the established transport")
func failedReconnectAuthorizationPreservesSession() 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)
// The initiator exchange consumed two authorizations for this peer.
for _ in 2..<NoiseSecurityConstants.maxHandshakesPerMinute {
do {
_ = try alice.initiateHandshake(with: bobPeerID)
Issue.record("Expected the established-session guard")
} catch NoiseSessionError.alreadyEstablished {
// Expected.
}
}
do {
_ = try alice.initiateReconnectHandshake(with: bobPeerID)
Issue.record("Expected reconnect authorization to be rate limited")
} catch NoiseSecurityError.rateLimitExceeded {
// Expected before the working session moves.
} catch {
Issue.record("Unexpected reconnect authorization error: \(error)")
}
#expect(alice.hasEstablishedSession(with: bobPeerID))
let ciphertext = try alice.encrypt(
Data("still established".utf8),
for: bobPeerID
)
#expect(
try bob.decrypt(ciphertext, from: alicePeerID)
== Data("still established".utf8)
)
}
@Test("Encrypt without a session requests handshake and decrypt without session fails")
func handshakeRequiredAndSessionNotEstablishedErrors() throws {
let service = NoiseEncryptionService(keychain: MockKeychain())
@@ -28,6 +28,11 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
/// before outer BLE fragmentation. Peers that omit this bit require the
/// signed directed raw-file migration fallback.
public static let privateMedia = PeerCapabilities(rawValue: 1 << 8)
/// Reserved for test builds that briefly advertised non-destructive Noise
/// replacement. Current clients intentionally do not advertise or act on
/// this bit; keep it decodable so the wire assignment is never reused.
public static let nonDestructiveNoiseReplacement =
PeerCapabilities(rawValue: 1 << 10)
/// Minimal little-endian byte encoding; always at least one byte so an
/// empty set is distinguishable from an absent TLV.
@@ -20,6 +20,10 @@ struct PeerCapabilitiesTests {
let high = PeerCapabilities(rawValue: 1 << 9)
#expect(high.encoded() == Data([0x00, 0x02]))
#expect(
PeerCapabilities.nonDestructiveNoiseReplacement.encoded()
== Data([0x00, 0x04])
)
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia]
#expect(PeerCapabilities(encoded: all.encoded()) == all)