mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 03:25:20 +00:00
Authenticate only completed Noise candidates
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user