Authenticate only completed Noise candidates

This commit is contained in:
jack
2026-07-25 23:00:27 +02:00
parent 31d91bd7c3
commit 1d4c143bdc
6 changed files with 213 additions and 23 deletions
+30 -3
View File
@@ -11,6 +11,11 @@ import CryptoKit
import Foundation import Foundation
import BitFoundation import BitFoundation
struct NoiseHandshakeProcessingResult {
let response: Data?
let didEstablishAuthenticatedSession: Bool
}
final class NoiseSessionManager { final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:] private var sessions: [PeerID: NoiseSession] = [:]
/// A responder rehandshake must not evict a working transport session /// A responder rehandshake must not evict a working transport session
@@ -112,6 +117,21 @@ final class NoiseSessionManager {
} }
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? { func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
try handleIncomingHandshakeWithResult(
from: peerID,
message: message
).response
}
/// Processes one exact handshake candidate and reports whether that
/// candidate completed authenticated establishment. The peer's retained
/// session may already be established while a replacement is only on
/// message one, so callers must not infer candidate completion from the
/// peer-level session table.
func handleIncomingHandshakeWithResult(
from peerID: PeerID,
message: Data
) throws -> NoiseHandshakeProcessingResult {
// Process everything within the synchronized block to prevent race conditions // Process everything within the synchronized block to prevent race conditions
return try managerQueue.sync(flags: .barrier) { return try managerQueue.sync(flags: .barrier) {
let session: NoiseSession let session: NoiseSession
@@ -164,8 +184,11 @@ final class NoiseSessionManager {
do { do {
let response = try session.processHandshakeMessage(message) let response = try session.processHandshakeMessage(message)
// Check if session is established after processing // Check the exact session that processed this message. A
if session.isEstablished() { // preserved peer-level session can remain established while a
// replacement candidate is still unauthenticated.
let didEstablishAuthenticatedSession = session.isEstablished()
if didEstablishAuthenticatedSession {
guard let remoteKey = session.getRemoteStaticPublicKey(), guard let remoteKey = session.getRemoteStaticPublicKey(),
authenticatedRemoteKey(remoteKey, matches: peerID) else { authenticatedRemoteKey(remoteKey, matches: peerID) else {
throw NoiseSessionError.peerIdentityMismatch throw NoiseSessionError.peerIdentityMismatch
@@ -185,7 +208,11 @@ final class NoiseSessionManager {
} }
} }
return response return NoiseHandshakeProcessingResult(
response: response,
didEstablishAuthenticatedSession:
didEstablishAuthenticatedSession
)
} catch { } catch {
// A failed candidate is discarded without touching the // A failed candidate is discarded without touching the
// established session. Ordinary failed handshakes retain the // established session. Ordinary failed handshakes retain the
@@ -2,6 +2,11 @@ import BitFoundation
import BitLogger import BitLogger
import Foundation import Foundation
struct BLENoiseHandshakeHandlingResult {
let processed: Bool
let didEstablishAuthenticatedSession: Bool
}
/// Narrow environment for `BLENoisePacketHandler`. /// Narrow environment for `BLENoisePacketHandler`.
/// ///
/// All queue hops (collections barrier writes, main-actor UI notification) /// All queue hops (collections barrier writes, main-actor UI notification)
@@ -16,8 +21,11 @@ struct BLENoisePacketHandlerEnvironment {
let messageTTL: UInt8 let messageTTL: UInt8
/// Current time source. /// Current time source.
let now: () -> Date let now: () -> Date
/// Processes an inbound handshake message, returning an optional response payload (crypto). /// Processes an inbound handshake message, returning its optional response
let processHandshakeMessage: (_ peerID: PeerID, _ message: Data) throws -> Data? /// and whether that exact candidate authenticated (crypto).
let processHandshakeMessage:
(_ peerID: PeerID, _ message: Data) throws
-> NoiseHandshakeProcessingResult
/// Whether any Noise session (established or pending) exists for the peer (crypto). /// Whether any Noise session (established or pending) exists for the peer (crypto).
let hasNoiseSession: (PeerID) -> Bool let hasNoiseSession: (PeerID) -> Bool
/// Initiates a fresh Noise handshake with the peer (crypto + send). /// Initiates a fresh Noise handshake with the peer (crypto + send).
@@ -54,12 +62,23 @@ final class BLENoisePacketHandler {
/// from a rejected candidate while an older session remains established. /// from a rejected candidate while an older session remains established.
@discardableResult @discardableResult
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
handleHandshakeWithResult(packet, from: peerID).processed
}
func handleHandshakeWithResult(
_ packet: BitchatPacket,
from peerID: PeerID
) -> BLENoiseHandshakeHandlingResult {
let env = environment let env = environment
// Use NoiseEncryptionService for handshake processing // Use NoiseEncryptionService for handshake processing
if PeerID(hexData: packet.recipientID) == env.localPeerID() { if PeerID(hexData: packet.recipientID) == env.localPeerID() {
// Handshake is for us // Handshake is for us
do { do {
if let response = try env.processHandshakeMessage(peerID, packet.payload) { let result = try env.processHandshakeMessage(
peerID,
packet.payload
)
if let response = result.response {
// Send response // Send response
let responsePacket = BitchatPacket( let responsePacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue, type: MessageType.noiseHandshake.rawValue,
@@ -76,7 +95,11 @@ final class BLENoisePacketHandler {
// Session establishment will trigger onPeerAuthenticated callback // Session establishment will trigger onPeerAuthenticated callback
// which will send any pending messages at the right time // which will send any pending messages at the right time
return true return BLENoiseHandshakeHandlingResult(
processed: true,
didEstablishAuthenticatedSession:
result.didEstablishAuthenticatedSession
)
} catch NoiseSessionError.peerIdentityMismatch { } catch NoiseSessionError.peerIdentityMismatch {
// The candidate was already discarded by the session manager. // The candidate was already discarded by the session manager.
// Do not let a spoofed claimed ID trigger a fresh outbound // Do not let a spoofed claimed ID trigger a fresh outbound
@@ -85,17 +108,26 @@ final class BLENoisePacketHandler {
"Rejected Noise handshake whose static key does not match \(peerID.id.prefix(8))", "Rejected Noise handshake whose static key does not match \(peerID.id.prefix(8))",
category: .security category: .security
) )
return false return BLENoiseHandshakeHandlingResult(
processed: false,
didEstablishAuthenticatedSession: false
)
} catch { } catch {
SecureLogger.error("Failed to process handshake: \(error)") SecureLogger.error("Failed to process handshake: \(error)")
// Try initiating a new handshake // Try initiating a new handshake
if !env.hasNoiseSession(peerID) { if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID) env.initiateHandshake(peerID)
} }
return false return BLENoiseHandshakeHandlingResult(
processed: false,
didEstablishAuthenticatedSession: false
)
} }
} }
return false return BLENoiseHandshakeHandlingResult(
processed: false,
didEstablishAuthenticatedSession: false
)
} }
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) { func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
+15 -10
View File
@@ -5770,15 +5770,11 @@ extension BLEService {
} }
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) { private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
let wasEstablished = noiseService.hasEstablishedSession(with: peerID) let result = noisePacketHandler.handleHandshakeWithResult(
let processed = noisePacketHandler.handleHandshake(packet, from: peerID) packet,
let isEstablished = noiseService.hasEstablishedSession(with: peerID) from: peerID
// XX message 1 is exactly the unauthenticated 32-byte ephemeral key. )
// While replacing an existing session, do not authenticate its ingress if result.didEstablishAuthenticatedSession {
// link until a later message completes and validates the candidate.
let completedAuthenticatedHandshake = !wasEstablished
|| packet.payload.count != NoiseSecurityConstants.xxInitialMessageSize
if processed, isEstablished, completedAuthenticatedHandshake {
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID) markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
} }
} }
@@ -5801,7 +5797,16 @@ extension BLEService {
messageTTL: messageTTL, messageTTL: messageTTL,
now: { Date() }, now: { Date() },
processHandshakeMessage: { [weak self] peerID, message in processHandshakeMessage: { [weak self] peerID, message in
try self?.noiseService.processHandshakeMessage(from: peerID, message: message) guard let self else {
return NoiseHandshakeProcessingResult(
response: nil,
didEstablishAuthenticatedSession: false
)
}
return try self.noiseService.processHandshakeMessageWithResult(
from: peerID,
message: message
)
}, },
hasNoiseSession: { [weak self] peerID in hasNoiseSession: { [weak self] peerID in
self?.noiseService.hasSession(with: peerID) ?? false self?.noiseService.hasSession(with: peerID) ?? false
+17 -2
View File
@@ -664,6 +664,18 @@ final class NoiseEncryptionService {
/// Process an incoming handshake message /// Process an incoming handshake message
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? { func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
try processHandshakeMessageWithResult(
from: peerID,
message: message
).response
}
/// Process an incoming handshake message and report whether the exact
/// session that consumed it completed authenticated establishment.
func processHandshakeMessageWithResult(
from peerID: PeerID,
message: Data
) throws -> NoiseHandshakeProcessingResult {
// Validate peer ID // Validate peer ID
guard peerID.isValid else { guard peerID.isValid else {
@@ -685,11 +697,14 @@ final class NoiseEncryptionService {
// For handshakes, we process the raw data directly without NoiseMessage wrapper // For handshakes, we process the raw data directly without NoiseMessage wrapper
// The Noise protocol handles its own message format // The Noise protocol handles its own message format
let responsePayload = try sessionManager.handleIncomingHandshake(from: peerID, message: message) let result = try sessionManager.handleIncomingHandshakeWithResult(
from: peerID,
message: message
)
// Return raw response without wrapper // Return raw response without wrapper
return responsePayload return result
} }
/// Check if we have an established session with a peer /// Check if we have an established session with a peer
+88
View File
@@ -529,6 +529,94 @@ struct BLEServiceCoreTests {
#expect(outbound.count(ofType: .courierEnvelope) == 0) #expect(outbound.count(ofType: .courierEnvelope) == 0)
} }
@Test
func replacementXXMessageOneWithPayloadCannotAuthenticateIngressLink() async throws {
let ble = makeService()
let victim = NoiseEncryptionService(keychain: MockKeychain())
let victimPeerID = PeerID(publicKey: victim.getStaticPublicKeyData())
// 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)
let message2 = try #require(
try victim.processHandshakeMessage(from: ble.myPeerID, message: message1)
)
let message3 = try #require(
try ble._test_noiseProcessHandshakeMessage(
from: victimPeerID,
message: message2
)
)
_ = try victim.processHandshakeMessage(
from: ble.myPeerID,
message: message3
)
#expect(ble.canDeliverSecurely(to: victimPeerID))
let centralUUID = "central-replacement-xx-message-one"
ble._test_bindCentral(centralUUID, to: victimPeerID)
#expect(
!ble._test_isNoiseAuthenticatedCentral(
centralUUID,
for: victimPeerID
)
)
// XX message one may legally carry a payload, so its length is not a
// reliable signal that the replacement handshake completed.
let unauthenticatedInitiator = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: MockKeychain()
)
let replacementMessage1 = try unauthenticatedInitiator.writeMessage(
payload: Data([0xA5])
)
#expect(
replacementMessage1.count
> NoiseSecurityConstants.xxInitialMessageSize
)
let packet = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: Data(hexString: victimPeerID.id) ?? Data(),
recipientID: Data(hexString: ble.myPeerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: replacementMessage1,
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
#expect(
ble._test_recordIngressIfNew(
packet: packet,
linkID: centralUUID
)
)
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record
ble._test_handlePacket(
packet,
fromPeerID: victimPeerID,
preseedPeer: false
)
// Waiting for the responder's message two proves the candidate was
// processed before checking its exact authentication result.
let candidateProcessed = await TestHelpers.waitUntil(
{ outbound.count(ofType: .noiseHandshake) == 1 },
timeout: TestConstants.longTimeout
)
#expect(candidateProcessed)
#expect(
!ble._test_isNoiseAuthenticatedCentral(
centralUUID,
for: victimPeerID
)
)
#expect(ble.canDeliverSecurely(to: victimPeerID))
}
/// A legitimate rotation announce necessarily arrives on a link still /// A legitimate rotation announce necessarily arrives on a link still
/// bound to the OLD ID, so its registry upsert stores the new peer /// bound to the OLD ID, so its registry upsert stores the new peer
/// disconnected. The successful rebind must promote it: a healed /// disconnected. The successful rebind must promote it: a healed
@@ -8,6 +8,7 @@ struct BLENoisePacketHandlerTests {
private final class Recorder { private final class Recorder {
var handshakeResult: Result<Data?, Error> = .success(nil) var handshakeResult: Result<Data?, Error> = .success(nil)
var handshakeAuthenticated = false
var hasSession = false var hasSession = false
var decryptResult: Result<Data, Error> = .success(Data()) var decryptResult: Result<Data, Error> = .success(Data())
@@ -38,7 +39,11 @@ struct BLENoisePacketHandlerTests {
now: { now }, now: { now },
processHandshakeMessage: { peerID, message in processHandshakeMessage: { peerID, message in
recorder.processedHandshakes.append((peerID, message)) recorder.processedHandshakes.append((peerID, message))
return try recorder.handshakeResult.get() return NoiseHandshakeProcessingResult(
response: try recorder.handshakeResult.get(),
didEstablishAuthenticatedSession:
recorder.handshakeAuthenticated
)
}, },
hasNoiseSession: { peerID in hasNoiseSession: { peerID in
recorder.hasSessionQueries.append(peerID) recorder.hasSessionQueries.append(peerID)
@@ -110,6 +115,24 @@ struct BLENoisePacketHandlerTests {
#expect(recorder.initiatedHandshakes.isEmpty) #expect(recorder.initiatedHandshakes.isEmpty)
} }
@Test
func handshakeResultPreservesExactCandidateAuthentication() {
let recorder = Recorder()
recorder.handshakeAuthenticated = true
let handler = makeHandler(recorder: recorder)
let packet = makeHandshakePacket(
recipientID: Data(hexString: localPeerID.id)
)
let result = handler.handleHandshakeWithResult(
packet,
from: remotePeerID
)
#expect(result.processed)
#expect(result.didEstablishAuthenticatedSession)
}
@Test @Test
func handshakeForAnotherPeerIsIgnored() { func handshakeForAnotherPeerIsIgnored() {
let recorder = Recorder() let recorder = Recorder()