From 8d553adc0cf6ce377d5a93e06490def5835ad481 Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 5 Jul 2025 10:41:22 +0200 Subject: [PATCH] Implement password-protected rooms with encryption - Room creators can set/remove passwords for their rooms - Passwords are used to derive encryption keys via PBKDF2 - All messages in password-protected rooms are encrypted with AES-GCM - Room protection status is announced to all peers - Password and room data persists across app launches - Visual lock indicators for password-protected rooms - Password prompts when joining protected rooms - Only room creators can modify password settings - Encrypted messages show placeholder text if password is unknown --- bitchat/Protocols/BinaryProtocol.swift | 34 +++- bitchat/Protocols/BitchatProtocol.swift | 18 +- bitchat/Services/BluetoothMeshService.swift | 124 ++++++++++++- bitchat/ViewModels/ChatViewModel.swift | 183 +++++++++++++++++++- bitchat/Views/ContentView.swift | 101 +++++++++-- 5 files changed, 434 insertions(+), 26 deletions(-) diff --git a/bitchat/Protocols/BinaryProtocol.swift b/bitchat/Protocols/BinaryProtocol.swift index 756486e3..8f9be2e8 100644 --- a/bitchat/Protocols/BinaryProtocol.swift +++ b/bitchat/Protocols/BinaryProtocol.swift @@ -180,14 +180,14 @@ extension BitchatMessage { var data = Data() // Message format: - // - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions, bit 6: hasVoiceNote) + // - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions, bit 6: hasRoom, bit 7: isEncrypted) // - Timestamp: 8 bytes (seconds since epoch) // - ID length: 1 byte // - ID: variable // - Sender length: 1 byte // - Sender: variable // - Content length: 2 bytes - // - Content: variable + // - Content: variable (or encrypted content if isEncrypted) // Optional fields based on flags: // - Original sender length + data // - Recipient nickname length + data @@ -203,6 +203,7 @@ extension BitchatMessage { if senderPeerID != nil { flags |= 0x10 } if mentions != nil && !mentions!.isEmpty { flags |= 0x20 } if room != nil { flags |= 0x40 } + if isEncrypted { flags |= 0x80 } data.append(flags) @@ -229,8 +230,14 @@ extension BitchatMessage { data.append(0) } - // Content - if let contentData = content.data(using: .utf8) { + // Content or encrypted content + if isEncrypted, let encryptedContent = encryptedContent { + let length = UInt16(min(encryptedContent.count, 65535)) + // Encode length as 2 bytes, big-endian + data.append(UInt8((length >> 8) & 0xFF)) + data.append(UInt8(length & 0xFF)) + data.append(encryptedContent.prefix(Int(length))) + } else if let contentData = content.data(using: .utf8) { let length = UInt16(min(contentData.count, 65535)) // Encode length as 2 bytes, big-endian data.append(UInt8((length >> 8) & 0xFF)) @@ -301,6 +308,7 @@ extension BitchatMessage { let hasSenderPeerID = (flags & 0x10) != 0 let hasMentions = (flags & 0x20) != 0 let hasRoom = (flags & 0x40) != 0 + let isEncrypted = (flags & 0x80) != 0 // Timestamp guard offset + 8 <= dataCopy.count else { @@ -347,7 +355,19 @@ extension BitchatMessage { guard offset + contentLength <= dataCopy.count else { return nil } - let content = String(data: dataCopy[offset.. String? // Optional method to check if a fingerprint belongs to a favorite peer func isFavorite(fingerprint: String) -> Bool @@ -170,4 +177,13 @@ extension BitchatDelegate { func didReceiveRoomLeave(_ room: String, from peerID: String) { // Default empty implementation } + + func didReceivePasswordProtectedRoomAnnouncement(_ room: String, isProtected: Bool, creatorID: String?) { + // Default empty implementation + } + + func decryptRoomMessage(_ encryptedContent: Data, room: String) -> String? { + // Default returns nil (unable to decrypt) + return nil + } } \ No newline at end of file diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index cc7f643c..5d4c73df 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -685,6 +685,88 @@ class BluetoothMeshService: NSObject { } } + func announcePasswordProtectedRoom(_ room: String, isProtected: Bool = true, creatorID: String? = nil) { + messageQueue.async { [weak self] in + guard let self = self else { return } + + // Payload format: room|isProtected|creatorID + let protectedFlag = isProtected ? "1" : "0" + let creator = creatorID ?? self.myPeerID + let payload = "\(room)|\(protectedFlag)|\(creator)" + + let packet = BitchatPacket( + type: MessageType.roomAnnounce.rawValue, + senderID: Data(self.myPeerID.utf8), + recipientID: SpecialRecipients.broadcast, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data(payload.utf8), + signature: nil, + ttl: 5 // Allow wider propagation for room announcements + ) + + bitchatLog("Announcing room \(room) protection status: \(isProtected)", category: "room") + self.broadcastPacket(packet) + } + } + + func sendEncryptedRoomMessage(_ content: String, mentions: [String], room: String, roomKey: SymmetricKey) { + messageQueue.async { [weak self] in + guard let self = self else { return } + + let nickname = self.delegate as? ChatViewModel + let senderNick = nickname?.nickname ?? self.myPeerID + + // Encrypt the content + guard let contentData = content.data(using: .utf8) else { return } + + do { + let sealedBox = try AES.GCM.seal(contentData, using: roomKey) + let encryptedData = sealedBox.combined! + + // Create message with encrypted content + let message = BitchatMessage( + sender: senderNick, + content: "", // Empty placeholder since actual content is encrypted + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: self.myPeerID, + mentions: mentions.isEmpty ? nil : mentions, + room: room, + encryptedContent: encryptedData, + isEncrypted: true + ) + + if let messageData = message.toBinaryPayload() { + // Sign the message payload + let signature: Data? + do { + signature = try self.encryptionService.sign(messageData) + } catch { + signature = nil + } + + let packet = BitchatPacket( + type: MessageType.message.rawValue, + senderID: Data(self.myPeerID.utf8), + recipientID: SpecialRecipients.broadcast, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: messageData, + signature: signature, + ttl: self.adaptiveTTL + ) + + self.broadcastPacket(packet) + bitchatLog("Sent encrypted message to room \(room)", category: "room") + } + } catch { + bitchatLog("Failed to encrypt room message: \(error)", category: "crypto") + } + } + } + private func sendAnnouncementToPeer(_ peerID: String) { guard let vm = delegate as? ChatViewModel else { return } @@ -1224,9 +1306,21 @@ class BluetoothMeshService: NSObject { peerNicknames[senderID] = message.sender peerNicknamesLock.unlock() + // Handle encrypted room messages + var finalContent = message.content + if message.isEncrypted, let room = message.room, let encryptedData = message.encryptedContent { + // Try to decrypt the content + if let decryptedContent = self.delegate?.decryptRoomMessage(encryptedData, room: room) { + finalContent = decryptedContent + } else { + // Unable to decrypt - show placeholder + finalContent = "[Encrypted message - password required]" + } + } + let messageWithPeerID = BitchatMessage( sender: message.sender, - content: message.content, + content: finalContent, timestamp: message.timestamp, isRelay: message.isRelay, originalSender: message.originalSender, @@ -1234,7 +1328,9 @@ class BluetoothMeshService: NSObject { recipientNickname: nil, senderPeerID: senderID, mentions: message.mentions, - room: message.room + room: message.room, + encryptedContent: message.encryptedContent, + isEncrypted: message.isEncrypted ) DispatchQueue.main.async { @@ -1664,6 +1760,30 @@ class BluetoothMeshService: NSObject { self.broadcastPacket(relayPacket) } + case .roomAnnounce: + if let payloadStr = String(data: packet.payload, encoding: .utf8) { + // Parse payload: room|isProtected|creatorID + let components = payloadStr.split(separator: "|").map(String.init) + if components.count >= 3 { + let room = components[0] + let isProtected = components[1] == "1" + let creatorID = components[2] + + bitchatLog("Received room announcement: \(room) is \(isProtected ? "protected" : "public") by \(creatorID)", category: "room") + + DispatchQueue.main.async { + self.delegate?.didReceivePasswordProtectedRoomAnnouncement(room, isProtected: isProtected, creatorID: creatorID) + } + + // Relay announcement + if packet.ttl > 1 { + var relayPacket = packet + relayPacket.ttl -= 1 + self.broadcastPacket(relayPacket) + } + } + } + default: break } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 6a944fce..9050b0fa 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -10,6 +10,7 @@ import Foundation import SwiftUI import Combine import CryptoKit +import CommonCrypto #if os(iOS) import UIKit #endif @@ -40,12 +41,21 @@ class ChatViewModel: ObservableObject { @Published var roomMessages: [String: [BitchatMessage]] = [:] // room -> messages @Published var unreadRoomMessages: [String: Int] = [:] // room -> unread count @Published var roomMembers: [String: Set] = [:] // room -> set of peer IDs who have sent messages + @Published var roomPasswords: [String: String] = [:] // room -> password (stored locally only) + @Published var roomKeys: [String: SymmetricKey] = [:] // room -> derived encryption key + @Published var passwordProtectedRooms: Set = [] // Set of rooms that require passwords + @Published var roomCreators: [String: String] = [:] // room -> creator peerID + @Published var showPasswordPrompt: Bool = false + @Published var passwordPromptRoom: String? = nil let meshService = BluetoothMeshService() private let userDefaults = UserDefaults.standard private let nicknameKey = "bitchat.nickname" private let favoritesKey = "bitchat.favorites" private let joinedRoomsKey = "bitchat.joinedRooms" + private let passwordProtectedRoomsKey = "bitchat.passwordProtectedRooms" + private let roomCreatorsKey = "bitchat.roomCreators" + private let roomPasswordsKey = "bitchat.roomPasswords" private var nicknameSaveTimer: Timer? @Published var favoritePeers: Set = [] // Now stores public key fingerprints instead of peer IDs @@ -57,6 +67,7 @@ class ChatViewModel: ObservableObject { loadNickname() loadFavorites() loadJoinedRooms() + loadRoomData() meshService.delegate = self // Log startup info @@ -109,9 +120,53 @@ class ChatViewModel: ObservableObject { userDefaults.synchronize() } - func joinRoom(_ room: String) { + private func loadRoomData() { + // Load password protected rooms + if let savedProtectedRooms = userDefaults.stringArray(forKey: passwordProtectedRoomsKey) { + passwordProtectedRooms = Set(savedProtectedRooms) + } + + // Load room creators + if let savedCreators = userDefaults.dictionary(forKey: roomCreatorsKey) as? [String: String] { + roomCreators = savedCreators + } + + // Load room passwords (encrypted would be better, but keeping simple for now) + if let savedPasswords = userDefaults.dictionary(forKey: roomPasswordsKey) as? [String: String] { + roomPasswords = savedPasswords + // Derive keys for all saved passwords + for (room, password) in savedPasswords { + roomKeys[room] = deriveRoomKey(from: password, roomName: room) + } + } + } + + private func saveRoomData() { + userDefaults.set(Array(passwordProtectedRooms), forKey: passwordProtectedRoomsKey) + userDefaults.set(roomCreators, forKey: roomCreatorsKey) + userDefaults.set(roomPasswords, forKey: roomPasswordsKey) + userDefaults.synchronize() + } + + func joinRoom(_ room: String, password: String? = nil) { // Ensure room starts with # let roomTag = room.hasPrefix("#") ? room : "#\(room)" + + // If room is password protected and we don't have the key yet + if passwordProtectedRooms.contains(roomTag) && roomKeys[roomTag] == nil { + if let password = password { + // Derive key from password + let key = deriveRoomKey(from: password, roomName: roomTag) + roomKeys[roomTag] = key + roomPasswords[roomTag] = password // Store for convenience (local only) + } else { + // Show password prompt + passwordPromptRoom = roomTag + showPasswordPrompt = true + return + } + } + joinedRooms.insert(roomTag) saveJoinedRooms() @@ -144,6 +199,87 @@ class ChatViewModel: ObservableObject { unreadRoomMessages.removeValue(forKey: room) roomMessages.removeValue(forKey: room) roomMembers.removeValue(forKey: room) + roomKeys.removeValue(forKey: room) + roomPasswords.removeValue(forKey: room) + } + + // Password management + func setRoomPassword(_ password: String, for room: String) { + guard joinedRooms.contains(room) else { return } + + // If room has no creator yet, make current user the creator + if roomCreators[room] == nil { + roomCreators[room] = meshService.myPeerID + } + + // Only room creator can set password + guard roomCreators[room] == meshService.myPeerID else { + bitchatLog("Only room creator can set password", category: "room") + return + } + + // Derive encryption key from password + let key = deriveRoomKey(from: password, roomName: room) + roomKeys[room] = key + roomPasswords[room] = password + passwordProtectedRooms.insert(room) + + // Save room data + saveRoomData() + + // Announce that this room is now password protected + meshService.announcePasswordProtectedRoom(room, creatorID: meshService.myPeerID) + + bitchatLog("Set password for room \(room)", category: "room") + } + + func removeRoomPassword(for room: String) { + // Only room creator can remove password + guard roomCreators[room] == meshService.myPeerID else { + bitchatLog("Only room creator can remove password", category: "room") + return + } + + roomKeys.removeValue(forKey: room) + roomPasswords.removeValue(forKey: room) + passwordProtectedRooms.remove(room) + + // Save room data + saveRoomData() + + // Announce that this room is no longer password protected + meshService.announcePasswordProtectedRoom(room, isProtected: false, creatorID: meshService.myPeerID) + + bitchatLog("Removed password for room \(room)", category: "room") + } + + private func deriveRoomKey(from password: String, roomName: String) -> SymmetricKey { + // Use PBKDF2 to derive a key from the password + let salt = roomName.data(using: .utf8)! // Use room name as salt for consistency + let keyData = pbkdf2(password: password, salt: salt, iterations: 100000, keyLength: 32) + return SymmetricKey(data: keyData) + } + + private func pbkdf2(password: String, salt: Data, iterations: Int, keyLength: Int) -> Data { + var derivedKey = Data(count: keyLength) + let passwordData = password.data(using: .utf8)! + + derivedKey.withUnsafeMutableBytes { derivedKeyBytes in + salt.withUnsafeBytes { saltBytes in + passwordData.withUnsafeBytes { passwordBytes in + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passwordBytes.baseAddress, passwordData.count, + saltBytes.baseAddress, salt.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), + UInt32(iterations), + derivedKeyBytes.baseAddress, keyLength + ) + } + } + } + + return derivedKey } func switchToRoom(_ room: String?) { @@ -271,8 +407,14 @@ class ChatViewModel: ObservableObject { } } - // Send via mesh with mentions and room - meshService.sendMessage(content, mentions: mentions, room: messageRoom) + // Check if room is password protected and encrypt if needed + if let room = messageRoom, roomKeys[room] != nil { + // Send encrypted room message + meshService.sendEncryptedRoomMessage(content, mentions: mentions, room: room, roomKey: roomKeys[room]!) + } else { + // Send via mesh with mentions and room (unencrypted) + meshService.sendMessage(content, mentions: mentions, room: messageRoom) + } } } @@ -644,6 +786,41 @@ extension ChatViewModel: BitchatDelegate { } } + func didReceivePasswordProtectedRoomAnnouncement(_ room: String, isProtected: Bool, creatorID: String?) { + if isProtected { + passwordProtectedRooms.insert(room) + if let creator = creatorID { + roomCreators[room] = creator + } + } else { + passwordProtectedRooms.remove(room) + // If we're in this room and it's no longer protected, clear the key + roomKeys.removeValue(forKey: room) + roomPasswords.removeValue(forKey: room) + } + + // Save updated room data + saveRoomData() + + bitchatLog("Room \(room) is now \(isProtected ? "password protected" : "public")", category: "room") + } + + func decryptRoomMessage(_ encryptedContent: Data, room: String) -> String? { + guard let roomKey = roomKeys[room] else { + bitchatLog("No key available for encrypted room \(room)", category: "room") + return nil + } + + do { + let sealedBox = try AES.GCM.SealedBox(combined: encryptedContent) + let decryptedData = try AES.GCM.open(sealedBox, using: roomKey) + return String(data: decryptedData, encoding: .utf8) + } catch { + bitchatLog("Failed to decrypt room message: \(error)", category: "crypto") + return nil + } + } + func didReceiveMessage(_ message: BitchatMessage) { bitchatLog("Received message from \(message.sender), room: \(message.room ?? "nil"), senderPeerID: \(message.senderPeerID ?? "nil")", category: "message") diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index a1b7f588..a3435235 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -18,6 +18,11 @@ struct ContentView: View { @State private var showSidebar = false @State private var sidebarDragOffset: CGFloat = 0 @State private var showAppInfo = false + @State private var showPasswordInput = false + @State private var passwordInputRoom: String? = nil + @State private var passwordInput = "" + @State private var showPasswordPrompt = false + @State private var passwordPromptInput = "" private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white @@ -160,6 +165,40 @@ struct ContentView: View { .sheet(isPresented: $showAppInfo) { AppInfoView() } + .alert("Set Room Password", isPresented: $showPasswordInput) { + SecureField("Password", text: $passwordInput) + Button("Cancel", role: .cancel) { + passwordInput = "" + passwordInputRoom = nil + } + Button("Set Password") { + if let room = passwordInputRoom, !passwordInput.isEmpty { + viewModel.setRoomPassword(passwordInput, for: room) + passwordInput = "" + passwordInputRoom = nil + } + } + } message: { + Text("Enter a password to protect \(passwordInputRoom ?? "room"). Others will need this password to read messages.") + } + .alert("Enter Room Password", isPresented: Binding( + get: { viewModel.showPasswordPrompt }, + set: { viewModel.showPasswordPrompt = $0 } + )) { + SecureField("Password", text: $passwordPromptInput) + Button("Cancel", role: .cancel) { + passwordPromptInput = "" + viewModel.passwordPromptRoom = nil + } + Button("Join") { + if let room = viewModel.passwordPromptRoom, !passwordPromptInput.isEmpty { + viewModel.joinRoom(room, password: passwordPromptInput) + passwordPromptInput = "" + } + } + } message: { + Text("This room is password protected. Enter the password to join.") + } } private var headerView: some View { @@ -517,6 +556,13 @@ struct ContentView: View { } }) { HStack { + // Lock icon for password protected rooms + if viewModel.passwordProtectedRooms.contains(room) { + Image(systemName: "lock.fill") + .font(.system(size: 10)) + .foregroundColor(secondaryTextColor) + } + Text(room) .font(.system(size: 14, design: .monospaced)) .foregroundColor(viewModel.currentRoom == room ? Color.blue : textColor) @@ -534,22 +580,49 @@ struct ContentView: View { .clipShape(Capsule()) } - // Leave button + // Room controls if viewModel.currentRoom == room { - Button(action: { - viewModel.leaveRoom(room) - }) { - Text("leave") - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(secondaryTextColor) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(secondaryTextColor.opacity(0.5), lineWidth: 1) - ) + HStack(spacing: 4) { + // Password button for room creator + if viewModel.roomCreators[room] == viewModel.meshService.myPeerID { + Button(action: { + // Toggle password protection + if viewModel.passwordProtectedRooms.contains(room) { + viewModel.removeRoomPassword(for: room) + } else { + // Show password input + showPasswordInput = true + passwordInputRoom = room + } + }) { + Image(systemName: viewModel.passwordProtectedRooms.contains(room) ? "lock.open" : "lock") + .font(.system(size: 10)) + .foregroundColor(secondaryTextColor) + .padding(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(secondaryTextColor.opacity(0.5), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + // Leave button + Button(action: { + viewModel.leaveRoom(room) + }) { + Text("leave") + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(secondaryTextColor) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(secondaryTextColor.opacity(0.5), lineWidth: 1) + ) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } } .padding(.horizontal, 12)