Bind Noise sessions to claimed peer identities

This commit is contained in:
jack
2026-07-25 23:00:27 +02:00
committed by jack
parent bfc50b65e7
commit 31d91bd7c3
9 changed files with 436 additions and 65 deletions
@@ -14,6 +14,9 @@ enum NoiseSecurityConstants {
// Maximum handshake message size
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
static let xxInitialMessageSize = 32
// Session timeout - sessions older than this should be renegotiated
static let sessionTimeout: TimeInterval = 86400 // 24 hours
+1
View File
@@ -11,4 +11,5 @@ enum NoiseSessionError: Error, Equatable {
case notEstablished
case sessionNotFound
case alreadyEstablished
case peerIdentityMismatch
}
+98 -33
View File
@@ -13,6 +13,11 @@ import BitFoundation
final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:]
/// 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] = [:]
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
@@ -54,6 +59,9 @@ final class NoiseSessionManager {
if let session = sessions.removeValue(forKey: peerID) {
session.reset() // Clear sensitive data before removing
}
if let candidate = responderCandidates.removeValue(forKey: peerID) {
candidate.reset()
}
}
}
@@ -62,7 +70,11 @@ final class NoiseSessionManager {
for (_, session) in sessions {
session.reset()
}
for (_, candidate) in responderCandidates {
candidate.reset()
}
sessions.removeAll()
responderCandidates.removeAll()
}
}
@@ -79,6 +91,7 @@ final class NoiseSessionManager {
// Remove any existing non-established session
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
_ = sessions.removeValue(forKey: peerID)
existingSession.reset()
}
// Create new initiator session
@@ -91,6 +104,7 @@ final class NoiseSessionManager {
} catch {
// Clean up failed session
_ = sessions.removeValue(forKey: peerID)
session.reset()
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
throw error
}
@@ -100,39 +114,50 @@ final class NoiseSessionManager {
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
// Process everything within the synchronized block to prevent race conditions
return try managerQueue.sync(flags: .barrier) {
var shouldCreateNew = false
var existingSession: NoiseSession? = nil
if let existing = sessions[peerID] {
// If we have an established session, the peer must have cleared their session
// for a good reason (e.g., decryption failure, restart, etc.)
// We should accept the new handshake to re-establish encryption
if existing.isEstablished() {
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
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 {
// If we're in the middle of a handshake and receive a new initiation,
// reset and start fresh (the other side may have restarted)
if existing.getState() == .handshaking && message.count == 32 {
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
} else {
existingSession = existing
}
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.
_ = sessions.removeValue(forKey: peerID)
existing.reset()
let replacement = sessionFactory(peerID, .responder)
sessions[peerID] = replacement
session = replacement
isReplacementCandidate = false
} else {
session = existing
isReplacementCandidate = false
}
} else {
shouldCreateNew = true
}
// Get or create session
let session: NoiseSession
if shouldCreateNew {
let newSession = sessionFactory(peerID, .responder)
sessions[peerID] = newSession
session = newSession
} else {
session = existingSession!
isReplacementCandidate = false
}
// Process the handshake message within the synchronized block
@@ -141,18 +166,40 @@ final class NoiseSessionManager {
// Check if session is established after processing
if session.isEstablished() {
if let remoteKey = session.getRemoteStaticPublicKey() {
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
self?.onSessionEstablished?(peerID, remoteKey)
guard let remoteKey = session.getRemoteStaticPublicKey(),
authenticatedRemoteKey(remoteKey, matches: peerID) else {
throw NoiseSessionError.peerIdentityMismatch
}
if isReplacementCandidate {
_ = responderCandidates.removeValue(forKey: peerID)
let previous = sessions.updateValue(session, forKey: peerID)
if let previous, previous !== session {
previous.reset()
}
}
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
self?.onSessionEstablished?(peerID, remoteKey)
}
}
return response
} catch {
// Reset the session on handshake failure so next attempt can start fresh
_ = sessions.removeValue(forKey: peerID)
// 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 {
_ = sessions.removeValue(forKey: peerID)
}
session.reset()
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
@@ -164,6 +211,24 @@ final class NoiseSessionManager {
}
}
}
/// 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;
/// BLE packet ingress always supplies a short hexadecimal ID.
private func authenticatedRemoteKey(
_ remoteKey: Curve25519.KeyAgreement.PublicKey,
matches claimedPeerID: PeerID
) -> Bool {
let rawKey = remoteKey.rawRepresentation
if claimedPeerID.isShort {
return PeerID(publicKey: rawKey) == claimedPeerID
}
if let claimedNoiseKey = claimedPeerID.noiseKey {
return claimedNoiseKey == rawKey
}
return true
}
// MARK: - Encryption/Decryption
@@ -49,7 +49,11 @@ final class BLENoisePacketHandler {
self.environment = environment
}
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
/// 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.
@discardableResult
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
let env = environment
// Use NoiseEncryptionService for handshake processing
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
@@ -72,14 +76,26 @@ final class BLENoisePacketHandler {
// Session establishment will trigger onPeerAuthenticated callback
// which will send any pending messages at the right time
return true
} catch NoiseSessionError.peerIdentityMismatch {
// The candidate 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(
"Rejected Noise handshake whose static key does not match \(peerID.id.prefix(8))",
category: .security
)
return false
} catch {
SecureLogger.error("Failed to process handshake: \(error)")
// Try initiating a new handshake
if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID)
}
return false
}
}
return false
}
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
+56 -4
View File
@@ -1792,7 +1792,44 @@ final class BLEService: NSObject {
}
}
private func handleLeave(_: BitchatPacket, from peerID: PeerID) {
/// Accept a leave only when the claimed sender proves possession of the
/// signing key bound by a verified announce. The persisted identity cache
/// keeps delayed/relayed leaves verifiable after the live registry entry
/// has aged out.
private func handleLeave(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
let registrySigningKey = collectionsQueue.sync {
peerRegistry.info(for: peerID)?.signingPublicKey
}
let verifiedViaRegistry = registrySigningKey.map {
noiseService.verifyPacketSignature(packet, publicKey: $0)
} ?? false
let verifiedViaPersistedIdentity = !verifiedViaRegistry
&& identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID).contains { identity in
PeerID(publicKey: identity.publicKey) == peerID
&& identity.signingPublicKey.map {
noiseService.verifyPacketSignature(packet, publicKey: $0)
} == true
}
guard verifiedViaRegistry || verifiedViaPersistedIdentity else {
SecureLogger.warning(
"🚫 Dropping leave with missing/invalid signature for claimed sender \(peerID.id.prefix(8))",
category: .security
)
return false
}
// A valid departure retires transport state too; otherwise
// canDeliverSecurely could remain true for a peer we just removed.
noiseService.clearSession(for: peerID)
readLinkState { _ in
let departedLinks = noiseAuthenticatedLinkOwners.compactMap { link, owner in
owner == peerID ? link : nil
}
for link in departedLinks {
noiseAuthenticatedLinkOwners.removeValue(forKey: link)
}
}
_ = collectionsQueue.sync(flags: .barrier) {
// Remove the peer when they leave
peerRegistry.remove(peerID)
@@ -1809,6 +1846,7 @@ final class BLEService: NSObject {
self.deliverTransportEvent(.peerDisconnected(peerID))
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
}
return true
}
private func sendAnnounce(forceSend: Bool = false) {
guard !isPanicSuspended else { return }
@@ -2553,6 +2591,12 @@ extension BLEService {
}
}
func _test_isNoiseAuthenticatedCentral(_ centralUUID: String, for peerID: PeerID) -> Bool {
bleQueue.sync {
noiseAuthenticatedLinkOwners[.central(centralUUID)] == peerID
}
}
func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) {
collectionsQueue.sync(flags: .barrier) {
peerRegistry.upsert(BLEPeerInfo(
@@ -5084,7 +5128,9 @@ extension BLEService {
handleMeshPong(packet, from: senderID)
case .leave:
handleLeave(packet, from: senderID)
// A forged leave must neither evict the claimed peer nor spread
// to downstream nodes.
guard handleLeave(packet, from: senderID) else { return }
case .none:
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
@@ -5725,8 +5771,14 @@ extension BLEService {
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
noisePacketHandler.handleHandshake(packet, from: peerID)
if !wasEstablished, noiseService.hasEstablishedSession(with: peerID) {
let processed = noisePacketHandler.handleHandshake(packet, from: peerID)
let isEstablished = noiseService.hasEstablishedSession(with: 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.
let completedAuthenticatedHandshake = !wasEstablished
|| packet.payload.count != NoiseSecurityConstants.xxInitialMessageSize
if processed, isEstablished, completedAuthenticatedHandshake {
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
}
}