diff --git a/bitchat/Identity/IdentityModels.swift b/bitchat/Identity/IdentityModels.swift index 00eff84e..8b20aec6 100644 --- a/bitchat/Identity/IdentityModels.swift +++ b/bitchat/Identity/IdentityModels.swift @@ -106,6 +106,8 @@ enum HandshakeState { struct CryptographicIdentity: Codable { let fingerprint: String // SHA256 of public key let publicKey: Data // Noise static public key + // Optional Ed25519 signing public key (used to authenticate public messages) + var signingPublicKey: Data? = nil let firstSeen: Date let lastHandshake: Date? } diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index 2a836826..dcedf5e3 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -229,6 +229,91 @@ class SecureIdentityStateManager { return cache.socialIdentities[fingerprint] } } + + // MARK: - Cryptographic Identities + + /// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname. + /// - Parameters: + /// - fingerprint: SHA-256 hex of the Noise static public key + /// - noisePublicKey: Noise static public key data + /// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages + /// - claimedNickname: Optional latest claimed nickname to persist into social identity + func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) { + queue.async(flags: .barrier) { + let now = Date() + if var existing = self.cryptographicIdentities[fingerprint] { + // Update keys if changed + if existing.publicKey != noisePublicKey { + existing = CryptographicIdentity( + fingerprint: fingerprint, + publicKey: noisePublicKey, + signingPublicKey: signingPublicKey ?? existing.signingPublicKey, + firstSeen: existing.firstSeen, + lastHandshake: now + ) + self.cryptographicIdentities[fingerprint] = existing + } else { + // Update signing key and lastHandshake + existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey + let updated = CryptographicIdentity( + fingerprint: existing.fingerprint, + publicKey: existing.publicKey, + signingPublicKey: existing.signingPublicKey, + firstSeen: existing.firstSeen, + lastHandshake: now + ) + self.cryptographicIdentities[fingerprint] = updated + } + // Persist updated state (already assigned in branches above) + } else { + // New entry + let entry = CryptographicIdentity( + fingerprint: fingerprint, + publicKey: noisePublicKey, + signingPublicKey: signingPublicKey, + firstSeen: now, + lastHandshake: now + ) + self.cryptographicIdentities[fingerprint] = entry + } + + // Optionally persist claimed nickname into social identity + if let claimed = claimedNickname { + var identity = self.cache.socialIdentities[fingerprint] ?? SocialIdentity( + fingerprint: fingerprint, + localPetname: nil, + claimedNickname: claimed, + trustLevel: .unknown, + isFavorite: false, + isBlocked: false, + notes: nil + ) + // Update claimed nickname if changed + if identity.claimedNickname != claimed { + identity.claimedNickname = claimed + self.cache.socialIdentities[fingerprint] = identity + } else if self.cache.socialIdentities[fingerprint] == nil { + self.cache.socialIdentities[fingerprint] = identity + } + } + + self.saveIdentityCache() + } + } + + /// Retrieve cryptographic identity by fingerprint + func getCryptographicIdentity(for fingerprint: String) -> CryptographicIdentity? { + queue.sync { cryptographicIdentities[fingerprint] } + } + + /// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID + func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] { + queue.sync { + // Defensive: ensure hex and correct length + guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] } + return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) } + } + } func getAllSocialIdentities() -> [SocialIdentity] { queue.sync { diff --git a/bitchat/Services/BLEService.swift b/bitchat/Services/BLEService.swift index 9a86ec56..d7f190df 100644 --- a/bitchat/Services/BLEService.swift +++ b/bitchat/Services/BLEService.swift @@ -697,19 +697,26 @@ final class BLEService: NSObject { self.sendPrivateMessage(content, to: recipientID, messageID: finalMessageID) } else { // Public broadcast - // Public message - logged at relay point for mesh debugging - let packet = BitchatPacket( + // Create packet with explicit fields so we can sign it + let basePacket = BitchatPacket( type: MessageType.message.rawValue, - ttl: self.messageTTL, - senderID: self.myPeerID, - payload: Data(content.utf8) + senderID: Data(hexString: self.myPeerID) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data(content.utf8), + signature: nil, + ttl: self.messageTTL ) + guard let signedPacket = self.noiseService.signPacket(basePacket) else { + SecureLogger.log("❌ Failed to sign public message", category: SecureLogger.security, level: .error) + return + } // Pre-mark our own broadcast as processed to avoid handling relayed self copy - let senderHex = packet.senderID.hexEncodedString() - let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)" + let senderHex = signedPacket.senderID.hexEncodedString() + let dedupID = "\(senderHex)-\(signedPacket.timestamp)-\(signedPacket.type)" self.messageDeduplicator.markProcessed(dedupID) // Call synchronously since we're already on background queue - self.broadcastPacket(packet) + self.broadcastPacket(signedPacket) } } } @@ -1222,10 +1229,14 @@ final class BLEService: NSObject { SecureLogger.log("⚠️ Duplicate packet ignored: \(messageID)", category: SecureLogger.session, level: .debug) } - // Cancel any pending relay for this message (arrived via another neighbor) - collectionsQueue.async(flags: .barrier) { [weak self] in - if let task = self?.scheduledRelays.removeValue(forKey: messageID) { - task.cancel() + // In sparse graphs (<=2 neighbors), keep the pending relay to ensure bridging. + // In denser graphs, cancel the pending relay to reduce redundant floods. + let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count } + if connectedCount > 2 { + collectionsQueue.async(flags: .barrier) { [weak self] in + if let task = self?.scheduledRelays.removeValue(forKey: messageID) { + task.cancel() + } } } return // Duplicate ignored @@ -1404,7 +1415,20 @@ final class BLEService: NSObject { SecureLogger.log("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: SecureLogger.session, level: .debug) } } - + + // Persist cryptographic identity and signing key for robust offline verification + do { + // Derive fingerprint from Noise public key + let hash = SHA256.hash(data: announcement.noisePublicKey) + let fingerprint = hash.map { String(format: "%02x", $0) }.joined() + SecureIdentityStateManager.shared.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + claimedNickname: announcement.nickname + ) + } + // Notify UI on main thread notifyUI { [weak self] in guard let self = self else { return } @@ -1440,9 +1464,41 @@ final class BLEService: NSObject { private func handleMessage(_ packet: BitchatPacket, from peerID: String) { // Ignore self-origin public messages that may be seen again via relay if peerID == myPeerID { return } - - // Enforce: only accept public messages from verified peers we know - guard let info = peers[peerID], info.isVerifiedNickname else { + + var accepted = false + var senderNickname: String = "" + + if let info = peers[peerID], info.isVerifiedNickname { + // Known verified peer path + accepted = true + senderNickname = info.nickname + // Handle nickname collisions + let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.id != peerID } || (myNickname == info.nickname) + if hasCollision { + senderNickname += "#" + String(peerID.prefix(4)) + } + } else { + // Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix + if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() { + // Find candidate identities by peerID prefix (16 hex) + let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(peerID) + for candidate in candidates { + if let signingKey = candidate.signingPublicKey, + noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) { + accepted = true + // Prefer persisted social petname or claimed nickname + if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: candidate.fingerprint) { + senderNickname = social.localPetname ?? social.claimedNickname + } else { + senderNickname = "anon" + String(peerID.prefix(4)) + } + break + } + } + } + } + + guard accepted else { SecureLogger.log("🚫 Dropping public message from unverified or unknown peer \(peerID.prefix(8))…", category: SecureLogger.security, level: .warning) return } @@ -1451,17 +1507,9 @@ final class BLEService: NSObject { SecureLogger.log("❌ Failed to decode message payload as UTF-8", category: SecureLogger.session, level: .error) return } - - // Resolve display nickname; if collisions exist, append short peerID suffix - var senderNickname = info.nickname - // Treat a collision if another connected peer shares the nickname OR our own nickname matches - let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.id != peerID } || (myNickname == info.nickname) - if hasCollision { - senderNickname += "#" + String(peerID.prefix(4)) - } SecureLogger.log("💬 [\(senderNickname)] TTL:\(packet.ttl): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .debug) - + let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) notifyUI { [weak self] in self?.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts) @@ -2137,6 +2185,11 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph SecureLogger.log("📱 Disconnect: \(peerID ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: SecureLogger.session, level: .debug) + + // If disconnect carried an error (often timeout), apply short backoff to avoid thrash + if error != nil { + recentConnectTimeouts[peripheralID] = Date() + } // Clean up references peripherals.removeValue(forKey: peripheralID) diff --git a/bitchat/Services/RelayController.swift b/bitchat/Services/RelayController.swift index ef3087ca..661772a6 100644 --- a/bitchat/Services/RelayController.swift +++ b/bitchat/Services/RelayController.swift @@ -48,9 +48,10 @@ struct RelayController { let newTTL = clamped &- 1 // Wider jitter window to allow duplicate suppression to win more often + // For sparse graphs (<=2), relay quickly to avoid cancellation races let delayMs: Int switch degree { - case 0...2: delayMs = Int.random(in: 40...100) + case 0...2: delayMs = Int.random(in: 10...40) case 3...5: delayMs = Int.random(in: 60...150) case 6...9: delayMs = Int.random(in: 80...180) default: delayMs = Int.random(in: 100...220)