mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 15:25:19 +00:00
Implement handshake request notifications for pending messages (#321)
- Add handshakeRequest packet type (0x25) to notify recipients about queued messages - Create HandshakeRequest struct with binary encoding for efficient transmission - Send handshake requests when messages are queued due to missing session - Display notifications when someone wants to send messages - Fix verification persistence bug by adding forceSave on app termination - Update UI to handle optional encryption icons (hide when no handshake attempted) Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -371,4 +371,10 @@ class SecureIdentityStateManager {
|
||||
return cache.verifiedFingerprints.contains(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func getVerifiedFingerprints() -> Set<String> {
|
||||
queue.sync {
|
||||
return cache.verifiedFingerprints
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,7 @@ enum MessageType: UInt8 {
|
||||
case protocolAck = 0x22 // Generic protocol acknowledgment
|
||||
case protocolNack = 0x23 // Negative acknowledgment (failure)
|
||||
case systemValidation = 0x24 // Session validation ping
|
||||
case handshakeRequest = 0x25 // Request handshake for pending messages
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
@@ -121,10 +122,20 @@ enum MessageType: UInt8 {
|
||||
case .protocolAck: return "protocolAck"
|
||||
case .protocolNack: return "protocolNack"
|
||||
case .systemValidation: return "systemValidation"
|
||||
case .handshakeRequest: return "handshakeRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy handshake state tracking
|
||||
enum LazyHandshakeState {
|
||||
case none // No session, no handshake attempted
|
||||
case handshakeQueued // User action requires handshake
|
||||
case handshaking // Currently in handshake process
|
||||
case established // Session ready for use
|
||||
case failed(Error) // Handshake failed
|
||||
}
|
||||
|
||||
// Special recipient ID for broadcast messages
|
||||
struct SpecialRecipients {
|
||||
static let broadcast = Data(repeating: 0xFF, count: 8) // All 0xFF = broadcast
|
||||
@@ -360,6 +371,106 @@ struct ReadReceipt: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
// Handshake request for pending messages
|
||||
struct HandshakeRequest: Codable {
|
||||
let requestID: String
|
||||
let requesterID: String // Who needs the handshake
|
||||
let requesterNickname: String // Nickname of requester
|
||||
let targetID: String // Who should initiate handshake
|
||||
let pendingMessageCount: UInt8 // Number of messages queued
|
||||
let timestamp: Date
|
||||
|
||||
init(requesterID: String, requesterNickname: String, targetID: String, pendingMessageCount: UInt8) {
|
||||
self.requestID = UUID().uuidString
|
||||
self.requesterID = requesterID
|
||||
self.requesterNickname = requesterNickname
|
||||
self.targetID = targetID
|
||||
self.pendingMessageCount = pendingMessageCount
|
||||
self.timestamp = Date()
|
||||
}
|
||||
|
||||
// For binary decoding
|
||||
private init(requestID: String, requesterID: String, requesterNickname: String, targetID: String, pendingMessageCount: UInt8, timestamp: Date) {
|
||||
self.requestID = requestID
|
||||
self.requesterID = requesterID
|
||||
self.requesterNickname = requesterNickname
|
||||
self.targetID = targetID
|
||||
self.pendingMessageCount = pendingMessageCount
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
|
||||
// MARK: - Binary Encoding
|
||||
|
||||
func toBinaryData() -> Data {
|
||||
var data = Data()
|
||||
data.appendUUID(requestID)
|
||||
|
||||
// RequesterID as 8-byte hex string
|
||||
var requesterData = Data()
|
||||
var tempID = requesterID
|
||||
while tempID.count >= 2 && requesterData.count < 8 {
|
||||
let hexByte = String(tempID.prefix(2))
|
||||
if let byte = UInt8(hexByte, radix: 16) {
|
||||
requesterData.append(byte)
|
||||
}
|
||||
tempID = String(tempID.dropFirst(2))
|
||||
}
|
||||
while requesterData.count < 8 {
|
||||
requesterData.append(0)
|
||||
}
|
||||
data.append(requesterData)
|
||||
|
||||
// TargetID as 8-byte hex string
|
||||
var targetData = Data()
|
||||
tempID = targetID
|
||||
while tempID.count >= 2 && targetData.count < 8 {
|
||||
let hexByte = String(tempID.prefix(2))
|
||||
if let byte = UInt8(hexByte, radix: 16) {
|
||||
targetData.append(byte)
|
||||
}
|
||||
tempID = String(tempID.dropFirst(2))
|
||||
}
|
||||
while targetData.count < 8 {
|
||||
targetData.append(0)
|
||||
}
|
||||
data.append(targetData)
|
||||
|
||||
data.appendUInt8(pendingMessageCount)
|
||||
data.appendDate(timestamp)
|
||||
data.appendString(requesterNickname)
|
||||
return data
|
||||
}
|
||||
|
||||
static func fromBinaryData(_ data: Data) -> HandshakeRequest? {
|
||||
// Create defensive copy
|
||||
let dataCopy = Data(data)
|
||||
|
||||
// Minimum size: UUID (16) + requesterID (8) + targetID (8) + count (1) + timestamp (8) + min nickname
|
||||
guard dataCopy.count >= 42 else { return nil }
|
||||
|
||||
var offset = 0
|
||||
|
||||
guard let requestID = dataCopy.readUUID(at: &offset) else { return nil }
|
||||
|
||||
guard let requesterIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||
let requesterID = requesterIDData.hexEncodedString()
|
||||
|
||||
guard let targetIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||
let targetID = targetIDData.hexEncodedString()
|
||||
|
||||
guard let pendingMessageCount = dataCopy.readUInt8(at: &offset),
|
||||
let timestamp = dataCopy.readDate(at: &offset),
|
||||
let requesterNickname = dataCopy.readString(at: &offset) else { return nil }
|
||||
|
||||
return HandshakeRequest(requestID: requestID,
|
||||
requesterID: requesterID,
|
||||
requesterNickname: requesterNickname,
|
||||
targetID: targetID,
|
||||
pendingMessageCount: pendingMessageCount,
|
||||
timestamp: timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Protocol Acknowledgments
|
||||
|
||||
// Protocol-level acknowledgment for reliable delivery
|
||||
|
||||
@@ -400,6 +400,9 @@ class BluetoothMeshService: NSObject {
|
||||
// Pending private messages waiting for handshake
|
||||
private var pendingPrivateMessages: [String: [(content: String, recipientNickname: String, messageID: String)]] = [:]
|
||||
|
||||
// Noise session state tracking for lazy handshakes
|
||||
private var noiseSessionStates: [String: LazyHandshakeState] = [:]
|
||||
|
||||
// Cover traffic for privacy
|
||||
private var coverTrafficTimer: Timer?
|
||||
private let coverTrafficPrefix = "☂DUMMY☂" // Prefix to identify dummy messages after decryption
|
||||
@@ -758,10 +761,8 @@ class BluetoothMeshService: NSObject {
|
||||
|
||||
self.notifyPeerIDChange(oldPeerID: oldID, newPeerID: newPeerID, fingerprint: fingerprint)
|
||||
|
||||
// Trigger handshake after a short delay to allow the peer to settle
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.initiateNoiseHandshake(with: newPeerID)
|
||||
}
|
||||
// Lazy handshake: No longer initiate handshake on peer ID rotation
|
||||
// Handshake will be initiated when first private message is sent
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1443,28 +1444,9 @@ class BluetoothMeshService: NSObject {
|
||||
SecureLogger.logError(error, context: "Failed to encrypt delivery ACK via Noise for \(recipientID)", category: SecureLogger.encryption)
|
||||
}
|
||||
} else {
|
||||
// Fall back to legacy encryption
|
||||
let encryptedPayload: Data
|
||||
do {
|
||||
encryptedPayload = try self.noiseService.encrypt(ackData, for: recipientID)
|
||||
} catch {
|
||||
SecureLogger.logError(error, context: "Failed to encrypt delivery ACK for \(recipientID)", category: SecureLogger.encryption)
|
||||
return
|
||||
}
|
||||
|
||||
// Create ACK packet with direct routing to original sender
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.deliveryAck.rawValue,
|
||||
senderID: Data(hexString: self.myPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: recipientID) ?? Data(),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encryptedPayload,
|
||||
signature: nil, // ACKs don't need signatures
|
||||
ttl: 3 // Limited TTL for ACKs
|
||||
)
|
||||
|
||||
// Send immediately without delay (ACKs should be fast)
|
||||
self.broadcastPacket(packet)
|
||||
// Lazy handshake: No session available, drop the ACK
|
||||
SecureLogger.log("No Noise session with \(recipientID) for delivery ACK - dropping (lazy handshake mode)",
|
||||
category: SecureLogger.noise, level: .info)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1549,31 +1531,9 @@ class BluetoothMeshService: NSObject {
|
||||
SecureLogger.logError(error, context: "Failed to encrypt read receipt via Noise for \(recipientID)", category: SecureLogger.encryption)
|
||||
}
|
||||
} else {
|
||||
// No session - initiate handshake and queue the read receipt
|
||||
SecureLogger.log("No Noise session with \(recipientID) for read receipt, initiating handshake", category: SecureLogger.noise, level: .info)
|
||||
|
||||
// Initiate handshake regardless of our role if we need to send data
|
||||
self.initiateNoiseHandshake(with: recipientID)
|
||||
|
||||
// Queue the read receipt as a pending message
|
||||
// Create a synthetic message ID for the read receipt
|
||||
let readReceiptMessageID = "READ_RECEIPT_\(receipt.originalMessageID)"
|
||||
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
if self.pendingPrivateMessages[recipientID] == nil {
|
||||
self.pendingPrivateMessages[recipientID] = []
|
||||
}
|
||||
|
||||
// Store the read receipt data as a pending "message"
|
||||
self.pendingPrivateMessages[recipientID]?.append((
|
||||
content: "READ_RECEIPT:\(receipt.originalMessageID)",
|
||||
recipientNickname: receipt.readerNickname,
|
||||
messageID: readReceiptMessageID
|
||||
))
|
||||
|
||||
let count = self.pendingPrivateMessages[recipientID]?.count ?? 0
|
||||
SecureLogger.log("Queued read receipt for \(recipientID), pending messages: \(count)", category: SecureLogger.noise, level: .info)
|
||||
}
|
||||
// Lazy handshake: No session available, drop the read receipt
|
||||
SecureLogger.log("No Noise session with \(recipientID) for read receipt - dropping (lazy handshake mode)",
|
||||
category: SecureLogger.noise, level: .info)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1631,6 +1591,49 @@ class BluetoothMeshService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
// Get Noise session state for UI display
|
||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState {
|
||||
return collectionsQueue.sync {
|
||||
// First check our tracked state
|
||||
if let state = noiseSessionStates[peerID] {
|
||||
return state
|
||||
}
|
||||
|
||||
// If no tracked state, check if we have an established session
|
||||
if noiseService.hasEstablishedSession(with: peerID) {
|
||||
return .established
|
||||
}
|
||||
|
||||
// Default to none
|
||||
return .none
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger handshake with a peer (for UI)
|
||||
func triggerHandshake(with peerID: String) {
|
||||
SecureLogger.log("UI triggered handshake with \(peerID)", category: SecureLogger.noise, level: .info)
|
||||
|
||||
// Check if we already have a session
|
||||
if noiseService.hasEstablishedSession(with: peerID) {
|
||||
SecureLogger.log("Already have session with \(peerID), skipping handshake", category: SecureLogger.noise, level: .debug)
|
||||
return
|
||||
}
|
||||
|
||||
// Update state to handshakeQueued
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
noiseSessionStates[peerID] = .handshakeQueued
|
||||
}
|
||||
|
||||
// Apply tie-breaker logic for handshake initiation
|
||||
if myPeerID < peerID {
|
||||
// We have lower ID, initiate handshake
|
||||
initiateNoiseHandshake(with: peerID)
|
||||
} else {
|
||||
// We have higher ID, send targeted identity announce to prompt them to initiate
|
||||
sendNoiseIdentityAnnounce(to: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func getPeerRSSI() -> [String: NSNumber] {
|
||||
var rssiValues = peerRSSI
|
||||
|
||||
@@ -2295,6 +2298,8 @@ class BluetoothMeshService: NSObject {
|
||||
messageTypeName = "VERSION_ACK"
|
||||
case .systemValidation:
|
||||
messageTypeName = "SYSTEM_VALIDATION"
|
||||
case .handshakeRequest:
|
||||
messageTypeName = "HANDSHAKE_REQUEST"
|
||||
default:
|
||||
messageTypeName = "UNKNOWN(\(packet.type))"
|
||||
}
|
||||
@@ -3146,45 +3151,15 @@ class BluetoothMeshService: NSObject {
|
||||
(self?.delegate as? ChatViewModel)?.registerPeerPublicKey(peerID: announcement.peerID, publicKeyData: announcement.publicKey)
|
||||
}
|
||||
|
||||
// If we don't have a session yet, check if we should initiate
|
||||
// Lazy handshake: No longer initiate handshake on identity announcement
|
||||
// Just respond with our own identity announcement
|
||||
if !noiseService.hasEstablishedSession(with: announcement.peerID) {
|
||||
// Lock rotation during handshake
|
||||
lockRotation()
|
||||
|
||||
// Use lexicographic comparison as tie-breaker to prevent simultaneous handshakes
|
||||
// Only the peer with the "lower" ID initiates
|
||||
// Use coordinator to determine if we should initiate
|
||||
let shouldInitiate = handshakeCoordinator.determineHandshakeRole(
|
||||
myPeerID: myPeerID,
|
||||
remotePeerID: announcement.peerID
|
||||
) == .initiator
|
||||
|
||||
if shouldInitiate {
|
||||
// Add small delay on fresh startup to let connections stabilize
|
||||
let lastConnection = lastConnectionTime[announcement.peerID] ?? Date.distantPast
|
||||
let timeSinceConnection = Date().timeIntervalSince(lastConnection)
|
||||
|
||||
if timeSinceConnection > 60.0 { // Fresh connection
|
||||
// Delay handshake initiation slightly for connection stability
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
SecureLogger.log("Initiating handshake after identity announce from \(announcement.peerID)",
|
||||
category: SecureLogger.noise, level: .info)
|
||||
self?.attemptHandshakeIfNeeded(with: announcement.peerID, forceIfStale: true)
|
||||
}
|
||||
} else {
|
||||
// Quick reconnection, initiate immediately
|
||||
SecureLogger.log("Quick reconnection - initiating handshake with \(announcement.peerID)",
|
||||
category: SecureLogger.noise, level: .info)
|
||||
attemptHandshakeIfNeeded(with: announcement.peerID, forceIfStale: true)
|
||||
}
|
||||
} else {
|
||||
// Send our identity back so they know we're ready
|
||||
SecureLogger.log("Responding to identity announce from \(announcement.peerID) with our own",
|
||||
category: SecureLogger.noise, level: .info)
|
||||
sendNoiseIdentityAnnounce(to: announcement.peerID)
|
||||
}
|
||||
// Send our identity back so they know we're here
|
||||
SecureLogger.log("Responding to identity announce from \(announcement.peerID) with our own (lazy handshake mode)",
|
||||
category: SecureLogger.noise, level: .info)
|
||||
sendNoiseIdentityAnnounce(to: announcement.peerID)
|
||||
} else {
|
||||
// We already have a session, but ensure ChatViewModel knows about the fingerprint
|
||||
// We already have a session, ensure ChatViewModel knows about the fingerprint
|
||||
// This handles the case where handshake completed before identity announcement
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if let publicKeyData = self?.noiseService.getPeerPublicKeyData(announcement.peerID) {
|
||||
@@ -3396,6 +3371,13 @@ class BluetoothMeshService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
case .handshakeRequest:
|
||||
// Handle handshake request for pending messages
|
||||
let senderID = packet.senderID.hexEncodedString()
|
||||
if !isPeerIDOurs(senderID) {
|
||||
handleHandshakeRequest(from: senderID, data: packet.payload)
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -4430,7 +4412,8 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
private func isHighPriorityMessage(type: UInt8) -> Bool {
|
||||
switch MessageType(rawValue: type) {
|
||||
case .noiseHandshakeInit, .noiseHandshakeResp, .protocolAck,
|
||||
.versionHello, .versionAck, .deliveryAck, .systemValidation:
|
||||
.versionHello, .versionAck, .deliveryAck, .systemValidation,
|
||||
.handshakeRequest:
|
||||
return true
|
||||
case .message, .announce, .leave, .readReceipt, .deliveryStatusRequest,
|
||||
.fragmentStart, .fragmentContinue, .fragmentEnd,
|
||||
@@ -4985,6 +4968,11 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
handshakeAttemptTimes.removeValue(forKey: peerID)
|
||||
handshakeCoordinator.recordHandshakeSuccess(peerID: peerID)
|
||||
|
||||
// Update session state to established
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
self.noiseSessionStates[peerID] = .established
|
||||
}
|
||||
|
||||
// Update connection state to authenticated
|
||||
updatePeerConnectionState(peerID, state: .authenticated)
|
||||
|
||||
@@ -4996,6 +4984,11 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
// Update state to handshaking
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
self.noiseSessionStates[peerID] = .handshaking
|
||||
}
|
||||
|
||||
// Check if we have pending messages
|
||||
let hasPendingMessages = collectionsQueue.sync {
|
||||
return pendingPrivateMessages[peerID]?.isEmpty == false
|
||||
@@ -5178,6 +5171,11 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
// Session established successfully
|
||||
handshakeCoordinator.recordHandshakeSuccess(peerID: peerID)
|
||||
|
||||
// Update session state to established
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
self.noiseSessionStates[peerID] = .established
|
||||
}
|
||||
|
||||
// Update connection state to authenticated
|
||||
updatePeerConnectionState(peerID, state: .authenticated)
|
||||
|
||||
@@ -5211,12 +5209,22 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
// Session already established, ignore handshake
|
||||
SecureLogger.log("Handshake already established with \(peerID)", category: SecureLogger.noise, level: .info)
|
||||
handshakeCoordinator.recordHandshakeSuccess(peerID: peerID)
|
||||
|
||||
// Update session state to established
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
self.noiseSessionStates[peerID] = .established
|
||||
}
|
||||
} catch {
|
||||
// Handshake failed
|
||||
handshakeCoordinator.recordHandshakeFailure(peerID: peerID, reason: error.localizedDescription)
|
||||
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
||||
SecureLogger.log("Handshake failed with \(peerID): \(error)", category: SecureLogger.noise, level: .error)
|
||||
|
||||
// Update session state to failed
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
self.noiseSessionStates[peerID] = .failed(error)
|
||||
}
|
||||
|
||||
// If handshake failed due to authentication error, clear the session to allow retry
|
||||
if case NoiseError.authenticationFailure = error {
|
||||
SecureLogger.log("Handshake failed with \(peerID): authenticationFailure - clearing session", category: SecureLogger.noise, level: .warning)
|
||||
@@ -5428,25 +5436,16 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
|
||||
sendVersionAck(ack, to: peerID)
|
||||
|
||||
// Proceed with Noise handshake after successful version negotiation
|
||||
// Lazy handshake: No longer initiate handshake after version negotiation
|
||||
// Just announce our identity
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// First announce our identity
|
||||
// Just announce our identity
|
||||
self.sendNoiseIdentityAnnounce()
|
||||
|
||||
// Then check if we should initiate handshake
|
||||
// If we already have a valid session, skip handshake
|
||||
if self.noiseService.hasEstablishedSession(with: peerID) {
|
||||
SecureLogger.log("Already have session with \(peerID) after version negotiation, skipping handshake",
|
||||
category: SecureLogger.handshake, level: .info)
|
||||
|
||||
// Force a session validation by sending a small encrypted ping
|
||||
self.validateNoiseSession(with: peerID)
|
||||
} else {
|
||||
// Use attemptHandshakeIfNeeded to coordinate properly
|
||||
self.attemptHandshakeIfNeeded(with: peerID)
|
||||
}
|
||||
SecureLogger.log("Version negotiation complete with \(peerID) - lazy handshake mode",
|
||||
category: SecureLogger.handshake, level: .info)
|
||||
}
|
||||
} else {
|
||||
// No compatible version
|
||||
@@ -5692,6 +5691,50 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHandshakeRequest(from peerID: String, data: Data) {
|
||||
guard let request = HandshakeRequest.fromBinaryData(data) else {
|
||||
SecureLogger.log("Failed to decode handshake request from \(peerID)", category: SecureLogger.noise, level: .error)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify this request is for us
|
||||
guard request.targetID == myPeerID else {
|
||||
// This request is not for us, might need to relay
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.log("Received handshake request from \(request.requesterID) (\(request.requesterNickname)) with \(request.pendingMessageCount) pending messages",
|
||||
category: SecureLogger.noise, level: .info)
|
||||
|
||||
// Notify UI about pending messages
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if let chatVM = self?.delegate as? ChatViewModel {
|
||||
// Notify the UI that someone wants to send messages
|
||||
chatVM.handleHandshakeRequest(from: request.requesterID,
|
||||
nickname: request.requesterNickname,
|
||||
pendingCount: request.pendingMessageCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we already have a session
|
||||
if noiseService.hasEstablishedSession(with: peerID) {
|
||||
// We already have a session, no action needed
|
||||
SecureLogger.log("Already have session with \(peerID), ignoring handshake request", category: SecureLogger.noise, level: .debug)
|
||||
return
|
||||
}
|
||||
|
||||
// Apply tie-breaker logic for handshake initiation
|
||||
if myPeerID < peerID {
|
||||
// We have lower ID, initiate handshake
|
||||
SecureLogger.log("Initiating handshake with \(peerID) in response to handshake request", category: SecureLogger.noise, level: .info)
|
||||
initiateNoiseHandshake(with: peerID)
|
||||
} else {
|
||||
// We have higher ID, send identity announce to prompt them
|
||||
SecureLogger.log("Sending identity announce to \(peerID) in response to handshake request", category: SecureLogger.noise, level: .info)
|
||||
sendNoiseIdentityAnnounce(to: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// Send protocol ACK for important packets
|
||||
private func sendProtocolAck(for packet: BitchatPacket, to peerID: String, hopCount: UInt8 = 0) {
|
||||
// Generate packet ID from packet content hash
|
||||
@@ -6088,7 +6131,35 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
// Clear stale session first
|
||||
cleanupPeerCryptoState(recipientPeerID)
|
||||
}
|
||||
SecureLogger.log("No valid Noise session with \(recipientPeerID), initiating handshake", category: SecureLogger.noise, level: .info)
|
||||
|
||||
// Update state to handshakeQueued
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
self.noiseSessionStates[recipientPeerID] = .handshakeQueued
|
||||
}
|
||||
|
||||
SecureLogger.log("No valid Noise session with \(recipientPeerID), initiating handshake (lazy mode)", category: SecureLogger.noise, level: .info)
|
||||
|
||||
// Notify UI that we're establishing encryption
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if let chatVM = self?.delegate as? ChatViewModel {
|
||||
// This will update the UI to show "establishing encryption" state
|
||||
chatVM.updateEncryptionStatusForPeer(recipientPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
// Queue message for sending after handshake completes
|
||||
collectionsQueue.sync(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.pendingPrivateMessages[recipientPeerID] == nil {
|
||||
self.pendingPrivateMessages[recipientPeerID] = []
|
||||
}
|
||||
self.pendingPrivateMessages[recipientPeerID]?.append((content, recipientNickname, messageID ?? UUID().uuidString))
|
||||
let count = self.pendingPrivateMessages[recipientPeerID]?.count ?? 0
|
||||
SecureLogger.log("Queued private message for \(recipientPeerID), \(count) messages pending", category: SecureLogger.noise, level: .info)
|
||||
}
|
||||
|
||||
// Send handshake request to notify recipient of pending messages
|
||||
sendHandshakeRequest(to: recipientPeerID, pendingCount: UInt8(pendingPrivateMessages[recipientPeerID]?.count ?? 1))
|
||||
|
||||
// Apply tie-breaker logic for handshake initiation
|
||||
if myPeerID < recipientPeerID {
|
||||
@@ -6099,16 +6170,6 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
sendNoiseIdentityAnnounce(to: recipientPeerID)
|
||||
}
|
||||
|
||||
// Queue message for sending after handshake completes
|
||||
messageQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.pendingPrivateMessages[recipientPeerID] == nil {
|
||||
self.pendingPrivateMessages[recipientPeerID] = []
|
||||
}
|
||||
self.pendingPrivateMessages[recipientPeerID]?.append((content, recipientNickname, messageID ?? UUID().uuidString))
|
||||
let count = self.pendingPrivateMessages[recipientPeerID]?.count ?? 0
|
||||
SecureLogger.log("Queued private message for \(recipientPeerID), \(count) messages pending", category: SecureLogger.noise, level: .info)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6233,6 +6294,31 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
return false
|
||||
}
|
||||
|
||||
private func sendHandshakeRequest(to targetPeerID: String, pendingCount: UInt8) {
|
||||
// Create handshake request
|
||||
let request = HandshakeRequest(requesterID: myPeerID,
|
||||
requesterNickname: (delegate as? ChatViewModel)?.nickname ?? myPeerID,
|
||||
targetID: targetPeerID,
|
||||
pendingMessageCount: pendingCount)
|
||||
|
||||
let requestData = request.toBinaryData()
|
||||
|
||||
// Create packet for handshake request
|
||||
let packet = BitchatPacket(type: MessageType.handshakeRequest.rawValue,
|
||||
ttl: 6,
|
||||
senderID: myPeerID,
|
||||
payload: requestData)
|
||||
|
||||
// Try direct delivery first
|
||||
if sendDirectToRecipient(packet, recipientPeerID: targetPeerID) {
|
||||
SecureLogger.log("Sent handshake request directly to \(targetPeerID)", category: SecureLogger.noise, level: .info)
|
||||
} else {
|
||||
// Use selective relay if direct delivery fails
|
||||
sendViaSelectiveRelay(packet, recipientPeerID: targetPeerID)
|
||||
SecureLogger.log("Sent handshake request via relay to \(targetPeerID)", category: SecureLogger.noise, level: .info)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectBestRelayPeers(excluding: String, maxPeers: Int = 3) -> [String] {
|
||||
// Select peers with best RSSI for relay, excluding the target recipient
|
||||
var candidates: [(peerID: String, rssi: Int)] = []
|
||||
|
||||
@@ -13,15 +13,18 @@ import os.log
|
||||
// MARK: - Encryption Status
|
||||
|
||||
enum EncryptionStatus: Equatable {
|
||||
case none
|
||||
case noiseHandshaking
|
||||
case noiseSecured
|
||||
case noiseVerified
|
||||
case none // Failed or incompatible
|
||||
case noHandshake // No handshake attempted yet
|
||||
case noiseHandshaking // Currently establishing
|
||||
case noiseSecured // Established but not verified
|
||||
case noiseVerified // Established and verified
|
||||
|
||||
var icon: String {
|
||||
var icon: String? { // Made optional to hide icon when no handshake
|
||||
switch self {
|
||||
case .none:
|
||||
return "lock.slash"
|
||||
return "lock.slash" // Failed handshake
|
||||
case .noHandshake:
|
||||
return nil // No icon when no handshake attempted
|
||||
case .noiseHandshaking:
|
||||
return "lock.rotation"
|
||||
case .noiseSecured:
|
||||
@@ -34,6 +37,8 @@ enum EncryptionStatus: Equatable {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return "Encryption failed"
|
||||
case .noHandshake:
|
||||
return "Not encrypted"
|
||||
case .noiseHandshaking:
|
||||
return "Establishing encryption..."
|
||||
|
||||
@@ -480,6 +480,16 @@ class ChatViewModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger handshake if we don't have a session yet
|
||||
let sessionState = meshService.getNoiseSessionState(for: peerID)
|
||||
switch sessionState {
|
||||
case .none, .failed:
|
||||
// Initiate handshake when opening PM
|
||||
meshService.triggerHandshake(with: peerID)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
selectedPrivateChatPeer = peerID
|
||||
// Also track by fingerprint for persistence across reconnections
|
||||
selectedPrivateChatFingerprint = peerIDToPublicKeyFingerprint[peerID]
|
||||
@@ -648,6 +658,9 @@ class ChatViewModel: ObservableObject {
|
||||
// Flush any pending messages immediately
|
||||
flushMessageBatchImmediately()
|
||||
|
||||
// Force save any pending identity changes (verifications, favorites, etc)
|
||||
SecureIdentityStateManager.shared.forceSave()
|
||||
|
||||
// Verify identity key is still there
|
||||
_ = KeychainManager.shared.verifyIdentityKeyExists()
|
||||
|
||||
@@ -1300,14 +1313,14 @@ class ChatViewModel: ObservableObject {
|
||||
// This must be a pure function - no state mutations allowed
|
||||
// to avoid SwiftUI update loops
|
||||
|
||||
let hasEstablished = meshService.getNoiseService().hasEstablishedSession(with: peerID)
|
||||
let hasSession = meshService.getNoiseService().hasSession(with: peerID)
|
||||
let sessionState = meshService.getNoiseSessionState(for: peerID)
|
||||
let storedStatus = peerEncryptionStatus[peerID]
|
||||
|
||||
let status: EncryptionStatus
|
||||
|
||||
// First check if we have an established session
|
||||
if hasEstablished {
|
||||
// Determine status based on session state
|
||||
switch sessionState {
|
||||
case .established:
|
||||
// We have encryption, now check if it's verified
|
||||
if let fingerprint = getFingerprint(for: peerID) {
|
||||
if verifiedFingerprints.contains(fingerprint) {
|
||||
@@ -1319,12 +1332,15 @@ class ChatViewModel: ObservableObject {
|
||||
// We have a session but no fingerprint yet - still secured
|
||||
status = .noiseSecured
|
||||
}
|
||||
} else if hasSession {
|
||||
// Check if handshaking
|
||||
case .handshaking, .handshakeQueued:
|
||||
// Currently establishing encryption
|
||||
status = .noiseHandshaking
|
||||
} else {
|
||||
// Fall back to stored status
|
||||
status = storedStatus ?? .none
|
||||
case .none:
|
||||
// No handshake attempted
|
||||
status = .noHandshake
|
||||
case .failed:
|
||||
// Handshake failed - show broken lock
|
||||
status = .none
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
@@ -1332,7 +1348,7 @@ class ChatViewModel: ObservableObject {
|
||||
|
||||
// Only log occasionally to avoid spam
|
||||
if Int.random(in: 0..<100) == 0 {
|
||||
SecureLogger.log("getEncryptionStatus for \(peerID): hasEstablished=\(hasEstablished), hasSession=\(hasSession), stored=\(String(describing: storedStatus)), final=\(status)", category: SecureLogger.security, level: .debug)
|
||||
SecureLogger.log("getEncryptionStatus for \(peerID): sessionState=\(sessionState), stored=\(String(describing: storedStatus)), final=\(status)", category: SecureLogger.security, level: .debug)
|
||||
}
|
||||
|
||||
return status
|
||||
@@ -1562,9 +1578,8 @@ class ChatViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func loadVerifiedFingerprints() {
|
||||
// Load verified fingerprints from secure storage
|
||||
let allIdentities = SecureIdentityStateManager.shared.getAllSocialIdentities()
|
||||
verifiedFingerprints = Set(allIdentities.filter { $0.trustLevel == .verified }.map { $0.fingerprint })
|
||||
// Load verified fingerprints directly from secure storage
|
||||
verifiedFingerprints = SecureIdentityStateManager.shared.getVerifiedFingerprints()
|
||||
}
|
||||
|
||||
private func setupNoiseCallbacks() {
|
||||
@@ -1608,6 +1623,44 @@ class ChatViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleHandshakeRequest(from peerID: String, nickname: String, pendingCount: UInt8) {
|
||||
// Create a notification message
|
||||
let notificationMessage = BitchatMessage(
|
||||
sender: "system",
|
||||
content: "📨 \(nickname) wants to send you \(pendingCount) message\(pendingCount == 1 ? "" : "s"). Open the conversation to receive.",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: "system",
|
||||
mentions: nil
|
||||
)
|
||||
|
||||
// Add to messages
|
||||
messages.append(notificationMessage)
|
||||
trimMessagesIfNeeded()
|
||||
|
||||
// Show system notification
|
||||
if let fingerprint = getFingerprint(for: peerID) {
|
||||
let isFavorite = favoritePeers.contains(fingerprint)
|
||||
if isFavorite {
|
||||
// Send favorite notification
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: nickname,
|
||||
message: "\(pendingCount) message\(pendingCount == 1 ? "" : "s") pending",
|
||||
peerID: peerID
|
||||
)
|
||||
} else {
|
||||
// Send regular notification
|
||||
NotificationService.shared.sendMentionNotification(
|
||||
from: nickname,
|
||||
message: "\(pendingCount) message\(pendingCount == 1 ? "" : "s") pending. Open conversation to receive."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ChatViewModel: BitchatDelegate {
|
||||
|
||||
@@ -669,13 +669,15 @@ struct ContentView: View {
|
||||
.foregroundColor(peerNicknames[peer.id] != nil ? textColor : secondaryTextColor)
|
||||
|
||||
// Encryption status icon (after peer name)
|
||||
Image(systemName: peer.encryptionStatus.icon)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(peer.encryptionStatus == .noiseVerified ? Color.green :
|
||||
peer.encryptionStatus == .noiseSecured ? textColor :
|
||||
peer.encryptionStatus == .noiseHandshaking ? Color.orange :
|
||||
Color.red)
|
||||
.accessibilityLabel("Encryption: \(peer.encryptionStatus == .noiseVerified ? "verified" : peer.encryptionStatus == .noiseSecured ? "secured" : peer.encryptionStatus == .noiseHandshaking ? "establishing" : "none")")
|
||||
if let icon = peer.encryptionStatus.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(peer.encryptionStatus == .noiseVerified ? Color.green :
|
||||
peer.encryptionStatus == .noiseSecured ? textColor :
|
||||
peer.encryptionStatus == .noiseHandshaking ? Color.orange :
|
||||
Color.red)
|
||||
.accessibilityLabel("Encryption: \(peer.encryptionStatus == .noiseVerified ? "verified" : peer.encryptionStatus == .noiseSecured ? "secured" : peer.encryptionStatus == .noiseHandshaking ? "establishing" : "none")")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -897,12 +899,14 @@ struct ContentView: View {
|
||||
.foregroundColor(Color.orange)
|
||||
// Dynamic encryption status icon
|
||||
let encryptionStatus = viewModel.getEncryptionStatus(for: privatePeerID)
|
||||
Image(systemName: encryptionStatus.icon)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green :
|
||||
encryptionStatus == .noiseSecured ? Color.orange :
|
||||
Color.red)
|
||||
.accessibilityLabel("Encryption status: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : "not encrypted")")
|
||||
if let icon = encryptionStatus.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green :
|
||||
encryptionStatus == .noiseSecured ? Color.orange :
|
||||
Color.red)
|
||||
.accessibilityLabel("Encryption status: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : "not encrypted")")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityLabel("Private chat with \(privatePeerNick)")
|
||||
|
||||
@@ -45,9 +45,11 @@ struct FingerprintView: View {
|
||||
let encryptionStatus = viewModel.getEncryptionStatus(for: peerID)
|
||||
|
||||
HStack {
|
||||
Image(systemName: encryptionStatus.icon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor)
|
||||
if let icon = encryptionStatus.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(peerNickname)
|
||||
|
||||
Reference in New Issue
Block a user