diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index 461449cc..f8633e0d 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -193,7 +193,9 @@ final class ConversationUIModel: ObservableObject { private func refreshComputedState() { if let selectedPeerID = privateConversationModel.selectedPeerID { - canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat) + // Media transfer is not wired for groups in v1; keep it off so the + // composer can't strand a media placeholder that never sends. + canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat || selectedPeerID.isGroup) return } diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index 1846b487..b96c2e72 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -29,11 +29,22 @@ struct GeohashPersonRow: Identifiable, Equatable { let isBlocked: Bool } +struct GroupChatRow: Identifiable, Equatable { + let peerID: PeerID + let name: String + let memberCount: Int + let isCreator: Bool + let hasUnread: Bool + + var id: String { peerID.id } +} + @MainActor final class PeerListModel: ObservableObject { @Published private(set) var allPeers: [BitchatPeer] = [] @Published private(set) var meshRows: [MeshPeerRow] = [] @Published private(set) var geohashPeople: [GeohashPersonRow] = [] + @Published private(set) var groupRows: [GroupChatRow] = [] @Published private(set) var reachableMeshPeerCount = 0 @Published private(set) var connectedMeshPeerCount = 0 @Published private(set) var visibleGeohashPeerCount = 0 @@ -132,6 +143,13 @@ final class PeerListModel: ObservableObject { } .store(in: &cancellables) + chatViewModel.groupStore.$groups + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refresh() + } + .store(in: &cancellables) + peerIdentityStore.$encryptionStatuses .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -220,22 +238,40 @@ final class PeerListModel: ObservableObject { } let geohashPeople = buildGeohashPeople() + let groupRows = buildGroupRows() self.meshRows = meshRows reachableMeshPeerCount = meshCounts.reachable connectedMeshPeerCount = meshCounts.connected self.geohashPeople = geohashPeople visibleGeohashPeerCount = geohashPeople.count + self.groupRows = groupRows renderID = ( meshRows.map { "\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)" } + geohashPeople.map { "geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)" + } + + groupRows.map { + "group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)" } ).joined(separator: "|") } + private func buildGroupRows() -> [GroupChatRow] { + let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint() + return chatViewModel.groupStore.groups.map { group in + GroupChatRow( + peerID: group.peerID, + name: group.name, + memberCount: group.members.count, + isCreator: group.creatorFingerprint == myFingerprint, + hasUnread: chatViewModel.hasUnreadMessages(for: group.peerID) + ) + } + } + private func buildGeohashPeople() -> [GeohashPersonRow] { let myHex = currentGeohashIdentityHex() let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() }) diff --git a/bitchat/App/PrivateConversationModels.swift b/bitchat/App/PrivateConversationModels.swift index 2a465948..d9920646 100644 --- a/bitchat/App/PrivateConversationModels.swift +++ b/bitchat/App/PrivateConversationModels.swift @@ -108,7 +108,13 @@ struct PrivateConversationHeaderState: Equatable { let encryptionStatus: EncryptionStatus? var supportsFavoriteToggle: Bool { - !conversationPeerID.isGeoDM + !conversationPeerID.isGeoDM && !conversationPeerID.isGroup + } + + /// Group chats have no single peer identity behind the header: no + /// fingerprint screen, no per-peer encryption badge. + var isGroupConversation: Bool { + conversationPeerID.isGroup } } @@ -206,6 +212,13 @@ final class PrivateConversationModel: ObservableObject { } .store(in: &cancellables) + chatViewModel.groupStore.$groups + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshSelectedConversation() + } + .store(in: &cancellables) + NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated")) .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -229,6 +242,26 @@ final class PrivateConversationModel: ObservableObject { } private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState { + // Group chats: the "peer" is the whole crew. Name + member count in + // the header; availability reads as mesh since group traffic floods + // the local mesh, and the per-peer encryption badge does not apply. + if conversationPeerID.isGroup { + let displayName: String + if let group = chatViewModel.groupStore.group(for: conversationPeerID) { + displayName = "#\(group.name) (\(group.members.count))" + } else { + displayName = String(localized: "common.unknown", comment: "Fallback label for unknown peer") + } + return PrivateConversationHeaderState( + conversationPeerID: conversationPeerID, + headerPeerID: conversationPeerID, + displayName: displayName, + availability: .meshReachable, + isFavorite: false, + encryptionStatus: nil + ) + } + let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID) let peer = chatViewModel.getPeer(byID: headerPeerID) let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 49e5487e..f7979b8d 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -9408,6 +9408,17 @@ } } }, + "content.accessibility.group_chat" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group chat" + } + } + } + }, "content.accessibility.jump_to_latest" : { "comment" : "Accessibility label for the jump to latest messages button", "extractionState" : "manual", @@ -15115,6 +15126,17 @@ } } }, + "content.commands.group" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "create or manage private groups" + } + } + } + }, "content.commands.help" : { "comment" : "Description of the /help command in the suggestions panel", "extractionState" : "manual", @@ -18338,6 +18360,17 @@ } } }, + "content.input.group_placeholder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "create|invite|leave|list" + } + } + } + }, "content.input.placeholder.location" : { "comment" : "Composer placeholder for a public geohash channel, naming it", "extractionState" : "manual", @@ -20069,6 +20102,17 @@ } } }, + "content.private.caption_group" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "encrypted group · members only" + } + } + } + }, "encryption.accessibility.establishing" : { "extractionState" : "manual", "localizations" : { @@ -24626,6 +24670,50 @@ } } }, + "groups.accessibility.open_group_hint" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opens the group chat" + } + } + } + }, + "groups.member_count %@" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "(%@)" + } + } + } + }, + "groups.section.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "groups" + } + } + } + }, + "groups.state.creator" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creator" + } + } + } + }, "location_channels.accessibility.add_bookmark" : { "comment" : "Accessibility action name for bookmarking a channel", "extractionState" : "manual", @@ -34155,6 +34243,303 @@ } } }, + "system.group.already_member" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ is already a member" + } + } + } + }, + "system.group.cannot_remove_creator" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "the creator cannot be removed" + } + } + } + }, + "system.group.create_failed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not create the group" + } + } + } + }, + "system.group.created" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "created group '%@' — use /group invite @name to add people" + } + } + } + }, + "system.group.creator_only" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "only the group creator can do that" + } + } + } + }, + "system.group.full" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "group is full (max %@ members)" + } + } + } + }, + "system.group.identity_unavailable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "your identity keys are not ready yet" + } + } + } + }, + "system.group.invite_failed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not build the group invite" + } + } + } + }, + "system.group.invited" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "invited %1$@ to '%2$@'" + } + } + } + }, + "system.group.joined" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you were added to group '%1$@' by %2$@ — it now appears in your people list" + } + } + } + }, + "system.group.left" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "left group '%@'" + } + } + } + }, + "system.group.list_header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "your groups:" + } + } + } + }, + "system.group.member_not_found" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' is not a member of this group" + } + } + } + }, + "system.group.name_too_long" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "group names are limited to 40 characters" + } + } + } + }, + "system.group.none" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you are not in any groups — /group create to start one" + } + } + } + }, + "system.group.not_in_group" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "open a group chat first" + } + } + } + }, + "system.group.peer_identity_unknown" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cannot verify %@'s identity yet — wait for their announce" + } + } + } + }, + "system.group.peer_not_connected" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ must be connected over mesh to be invited" + } + } + } + }, + "system.group.peer_not_found" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' not found" + } + } + } + }, + "system.group.removed_from" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you were removed from group '%@'" + } + } + } + }, + "system.group.removed_member" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "removed %@ and rotated the group key" + } + } + } + }, + "system.group.rotate_failed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not rotate the group key" + } + } + } + }, + "system.group.send_failed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not encrypt the message" + } + } + } + }, + "system.group.unknown" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you are no longer in this group" + } + } + } + }, + "system.group.usage_create" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /group create " + } + } + } + }, + "system.group.usage_invite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /group invite @name" + } + } + } + }, + "system.group.usage_remove" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /group remove @name" + } + } + } + }, "system.location.not_in_channel" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/Models/CommandInfo.swift b/bitchat/Models/CommandInfo.swift index bb0a0a65..b4f309d9 100644 --- a/bitchat/Models/CommandInfo.swift +++ b/bitchat/Models/CommandInfo.swift @@ -16,6 +16,7 @@ enum CommandInfo: String, Identifiable { // suggesting a spelling the processor rejects teaches users dead ends. case block case clear + case group case help case hug case message = "msg" @@ -36,6 +37,8 @@ enum CommandInfo: String, Identifiable { switch self { case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace: return "<" + String(localized: "content.input.nickname_placeholder") + ">" + case .group: + return "<" + String(localized: "content.input.group_placeholder") + ">" case .pay: return "<" + String(localized: "content.input.token_placeholder") + ">" case .clear, .help, .who: @@ -47,6 +50,7 @@ enum CommandInfo: String, Identifiable { switch self { case .block: String(localized: "content.commands.block") case .clear: String(localized: "content.commands.clear") + case .group: String(localized: "content.commands.group") case .help: String(localized: "content.commands.help") case .hug: String(localized: "content.commands.hug") case .message: String(localized: "content.commands.message") @@ -70,11 +74,11 @@ enum CommandInfo: String, Identifiable { if !isGeoPublic { commands.append(.pay) } - // The processor rejects favorites and mesh diagnostics in geohash - // contexts, so only suggest them where they actually work: mesh. + // The processor rejects favorites, groups, and mesh diagnostics in + // geohash contexts, so only suggest them where they work: mesh. if isGeoPublic || isGeoDM { return commands } - return commands + [.favorite, .unfavorite, .ping, .trace] + return commands + [.favorite, .unfavorite, .ping, .trace, .group] } } diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index c7497051..476ae876 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -74,6 +74,9 @@ enum NoisePayloadType: UInt8 { case privateMessage = 0x01 // Private chat message case readReceipt = 0x02 // Message was read case delivered = 0x03 // Message was delivered + // Private groups (0x04/0x05 reserved by other features) + case groupInvite = 0x06 // Creator-signed group state (invite) + case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update) // Verification (QR-based OOB binding) case verifyChallenge = 0x10 // Verification challenge case verifyResponse = 0x11 // Verification response @@ -85,6 +88,8 @@ enum NoisePayloadType: UInt8 { case .privateMessage: return "privateMessage" case .readReceipt: return "readReceipt" case .delivered: return "delivered" + case .groupInvite: return "groupInvite" + case .groupKeyUpdate: return "groupKeyUpdate" case .verifyChallenge: return "verifyChallenge" case .verifyResponse: return "verifyResponse" case .vouch: return "vouch" @@ -119,6 +124,9 @@ protocol BitchatDelegate: AnyObject { // Low-level events for better separation of concerns func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) + // Encrypted group broadcast (opaque envelope; decrypted by the group coordinator) + func didReceiveGroupMessage(payload: Data, timestamp: Date) + // Bluetooth state updates for user notifications func didUpdateBluetoothState(_ state: CBManagerState) func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) @@ -138,6 +146,10 @@ extension BitchatDelegate { // Default empty implementation } + func didReceiveGroupMessage(payload: Data, timestamp: Date) { + // Default empty implementation + } + func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { // Default empty implementation } diff --git a/bitchat/Protocols/PeerCapabilities+Local.swift b/bitchat/Protocols/PeerCapabilities+Local.swift index b5c36f1a..819464b9 100644 --- a/bitchat/Protocols/PeerCapabilities+Local.swift +++ b/bitchat/Protocols/PeerCapabilities+Local.swift @@ -3,5 +3,5 @@ import BitFoundation extension PeerCapabilities { /// Capabilities this build advertises in its announce packets. /// Each feature adds its bit here when it ships. - static let localSupported: PeerCapabilities = [.vouch, .prekeys] + static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups] } diff --git a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift index 2bec6292..64cfa73e 100644 --- a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift +++ b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift @@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy { switch MessageType(rawValue: packetType) { case .noiseEncrypted, .noiseHandshake: return true - case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle: + case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage: return false } } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 3e6c5da7..8d9234af 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -1394,6 +1394,48 @@ final class BLEService: NSObject { // MARK: QR Verification over Noise + // MARK: Private Groups + + /// Sends creator-signed group state (invite) 1:1 over the Noise session, + /// queueing behind a handshake when none is established yet. + func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) { + sendNoisePayload(NoisePayload(type: .groupInvite, data: statePayload).encode(), to: peerID) + } + + /// Sends creator-signed group state (key rotation / roster update) 1:1 + /// over the Noise session. + func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) { + sendNoisePayload(NoisePayload(type: .groupKeyUpdate, data: statePayload).encode(), to: peerID) + } + + /// Broadcasts a sealed group message (MessageType 0x25) like a public + /// message: fire-and-flood with gossip-sync backfill. The outer packet is + /// intentionally unsigned — receivers authenticate the sender's Ed25519 + /// signature inside the ciphertext, which still verifies for backfilled + /// copies long after the sender's announce has expired. + func broadcastGroupMessage(_ envelope: Data) { + guard !envelope.isEmpty else { return } + messageQueue.async { [weak self] in + guard let self else { return } + let packet = BitchatPacket( + type: MessageType.groupMessage.rawValue, + senderID: Data(hexString: self.myPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: envelope, + signature: nil, + ttl: self.messageTTL + ) + // Pre-mark our own broadcast as processed to avoid handling a + // relayed self copy. + let dedupID = BLESelfBroadcastTracker.dedupID(for: packet) + self.messageDeduplicator.markProcessed(dedupID) + self.broadcastPacket(packet) + // Track our own broadcast for gossip sync + self.gossipSyncManager?.onPublicPacketSeen(packet) + } + } + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA) sendNoisePayload(payload, to: peerID) @@ -3758,6 +3800,9 @@ extension BLEService { case .courierEnvelope: handleCourierEnvelope(packet, from: peerID) + case .groupMessage: + handleGroupMessage(packet, from: senderID) + case .prekeyBundle: handlePrekeyBundle(packet, from: senderID) @@ -4095,6 +4140,26 @@ extension BLEService { ) } + /// Group broadcasts are opaque ciphertext to this layer: track them for + /// gossip backfill and hand the payload to the UI layer, where the group + /// coordinator decrypts and authenticates against the roster. Non-members + /// still relay (generic broadcast relay path) but never decode. + private func handleGroupMessage(_ packet: BitchatPacket, from peerID: PeerID) { + let isBroadcastRecipient: Bool = { + guard let recipient = packet.recipientID else { return true } + return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF } + }() + guard isBroadcastRecipient, !packet.payload.isEmpty else { return } + + gossipSyncManager?.onPublicPacketSeen(packet) + + let payload = packet.payload + let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000) + notifyUI { [weak self] in + self?.deliverTransportEvent(.groupMessageReceived(payload: payload, timestamp: timestamp)) + } + } + private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) { noisePacketHandler.handleHandshake(packet, from: peerID) } diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index 9555609a..b8a4785e 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -74,6 +74,15 @@ protocol CommandContextProvider: AnyObject { /// Toggles the favorite via the unified peer flow, which persists by the /// real noise key and notifies the peer over mesh or Nostr. func toggleFavorite(peerID: PeerID) + + // MARK: - Groups + // Group logic lives in `ChatGroupCoordinator`; these forward the parsed + // /group subcommands. + func groupCreate(named name: String) -> CommandResult + func groupInvite(nickname: String) -> CommandResult + func groupRemove(nickname: String) -> CommandResult + func groupLeave() -> CommandResult + func groupList() -> CommandResult } /// Processes chat commands in a focused, efficient way @@ -120,6 +129,9 @@ final class CommandProcessor { return handleBlock(args) case "/unblock": return handleUnblock(args) + case "/group": + if inGeoPublic || inGeoDM { return .error(message: "groups are only for mesh peers in #mesh") } + return handleGroup(args) case "/fav": if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } return handleFavorite(args, add: true) @@ -153,6 +165,9 @@ final class CommandProcessor { /slap @name — slap with a large trout /block @name · /unblock @name /fav @name · /unfav @name — favorites (mesh only) + /group create — start an encrypted group + /group invite @name · /group remove @name — manage members (creator) + /group leave · /group list — leave or list your groups /ping @name — measure round-trip time (mesh only) /trace @name — estimated mesh path (mesh only) /pay — send a cashu ecash token in this chat @@ -362,6 +377,32 @@ final class CommandProcessor { return .error(message: "cannot unblock \(nickname): not found") } + private static let groupUsage = "usage: /group create · invite @name · remove @name · leave · list" + + private func handleGroup(_ args: String) -> CommandResult { + let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) + guard let subcommand = parts.first else { + return .error(message: Self.groupUsage) + } + let rest = parts.count > 1 ? String(parts[1]) : "" + guard let provider = contextProvider else { return .handled } + + switch subcommand { + case "create": + return provider.groupCreate(named: rest) + case "invite": + return provider.groupInvite(nickname: rest) + case "remove": + return provider.groupRemove(nickname: rest) + case "leave": + return provider.groupLeave() + case "list": + return provider.groupList() + default: + return .error(message: Self.groupUsage) + } + } + // MARK: - Mesh Diagnostics private enum MeshPeerResolution { diff --git a/bitchat/Services/Groups/GroupProtocol.swift b/bitchat/Services/Groups/GroupProtocol.swift new file mode 100644 index 00000000..dc4a0abf --- /dev/null +++ b/bitchat/Services/Groups/GroupProtocol.swift @@ -0,0 +1,569 @@ +// +// GroupProtocol.swift +// bitchat +// +// Wire formats and crypto for private groups: creator-signed group state +// (invites and key updates over Noise) and ChaCha20-Poly1305 group messages +// broadcast as MessageType.groupMessage (0x25). +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import CryptoKit +import Foundation + +// MARK: - Models + +/// A member of a private group as pinned in the creator-signed roster. +struct GroupMember: Codable, Equatable { + /// SHA-256 fingerprint (64 hex chars) of the member's Noise static key. + let fingerprint: String + /// The member's Ed25519 signing public key (32 bytes, from their announce). + let signingKey: Data + /// Nickname at invite time; display fallback when the peer is offline. + var nickname: String +} + +/// Creator-managed encrypted group. Metadata only — the symmetric key lives +/// in the keychain (see `GroupStore`). +struct BitchatGroup: Codable, Equatable { + static let maxMembers = 16 + static let groupIDLength = 16 + static let keyLength = 32 + + /// 16 random bytes; travels in cleartext on group message packets so + /// relays can dedup/filter without membership. + let groupID: Data + var name: String + /// Bumps on every key rotation; messages are bound to the epoch they + /// were sealed under. + var epoch: UInt32 + var members: [GroupMember] + /// Fingerprint of the creator — the only identity allowed to sign group + /// state (invites, key updates) in v1. + let creatorFingerprint: String + + /// Virtual conversation ID this group's chat is keyed under. + var peerID: PeerID { PeerID(groupID: groupID) } + + var creator: GroupMember? { + members.first { $0.fingerprint == creatorFingerprint } + } + + func isMember(fingerprint: String) -> Bool { + members.contains { $0.fingerprint == fingerprint } + } + + func member(withSigningKey signingKey: Data) -> GroupMember? { + members.first { $0.signingKey == signingKey } + } +} + +// MARK: - TLV helpers + +enum GroupTLVError: Error, Equatable { + /// A TLV value exceeded the 16-bit length field. Encoding fails instead + /// of silently truncating (which would ship a value the receiver drops). + case valueTooLong +} + +private enum GroupTLV { + /// Appends a (type, 16-bit length, value) triple. Throws rather than + /// truncating when `value` does not fit the 16-bit length field, so an + /// oversize field surfaces a send failure instead of a silently truncated + /// blob the recipient rejects during decrypt/verify. + static func put(_ type: UInt8, _ value: Data, into out: inout Data) throws { + guard value.count <= Int(UInt16.max) else { throw GroupTLVError.valueTooLong } + out.append(type) + let length = UInt16(value.count) + out.append(UInt8((length >> 8) & 0xFF)) + out.append(UInt8(length & 0xFF)) + out.append(value) + } + + /// Iterates (type, value) pairs; returns nil on malformed framing. + static func parse(_ data: Data) -> [(type: UInt8, value: Data)]? { + var fields: [(UInt8, Data)] = [] + var offset = data.startIndex + while offset < data.endIndex { + guard data.distance(from: offset, to: data.endIndex) >= 3 else { return nil } + let type = data[offset] + let high = Int(data[data.index(offset, offsetBy: 1)]) + let low = Int(data[data.index(offset, offsetBy: 2)]) + let length = (high << 8) | low + let valueStart = data.index(offset, offsetBy: 3) + guard data.distance(from: valueStart, to: data.endIndex) >= length else { return nil } + let valueEnd = data.index(valueStart, offsetBy: length) + fields.append((type, Data(data[valueStart.. Data { + var bigEndian = epoch.bigEndian + return withUnsafeBytes(of: &bigEndian) { Data($0) } + } + + static func epoch(from data: Data) -> UInt32? { + guard data.count == 4 else { return nil } + return data.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } + } + + static func timestampData(_ timestampMs: UInt64) -> Data { + var bigEndian = timestampMs.bigEndian + return withUnsafeBytes(of: &bigEndian) { Data($0) } + } + + static func timestamp(from data: Data) -> UInt64? { + guard data.count == 8 else { return nil } + return data.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) } + } +} + +// MARK: - Roster wire form + +enum GroupRosterCoding { + private static let fingerprintLength = 32 + private static let signingKeyLength = 32 + private static let maxNicknameBytes = 64 + + /// Deterministic roster blob: count byte, then per member the raw 32-byte + /// fingerprint, 32-byte signing key, and length-prefixed UTF-8 nickname. + /// The creator signature covers the SHA-256 of these exact bytes. + static func encode(_ members: [GroupMember]) -> Data? { + guard members.count <= BitchatGroup.maxMembers else { return nil } + var out = Data([UInt8(members.count)]) + for member in members { + guard let fingerprintData = Data(hexString: member.fingerprint), + fingerprintData.count == fingerprintLength, + member.signingKey.count == signingKeyLength else { return nil } + out.append(fingerprintData) + out.append(member.signingKey) + // Truncate on a Character boundary so the byte prefix is always + // valid UTF-8; a raw byte-prefix could split a multi-byte scalar + // and make the whole signed roster undecodable on the recipient. + let nickname = truncatedNicknameBytes(member.nickname) + out.append(UInt8(nickname.count)) + out.append(nickname) + } + return out + } + + static func decode(_ data: Data) -> [GroupMember]? { + guard let count = data.first, count <= UInt8(BitchatGroup.maxMembers) else { return nil } + var members: [GroupMember] = [] + var offset = data.index(after: data.startIndex) + for _ in 0..= fixed else { return nil } + let fingerprintEnd = data.index(offset, offsetBy: fingerprintLength) + let fingerprint = Data(data[offset..= nickLength else { return nil } + let nickEnd = data.index(nickStart, offsetBy: nickLength) + guard let nickname = String(data: Data(data[nickStart.. Data { + var candidate = nickname + while Data(candidate.utf8).count > maxNicknameBytes { + candidate.removeLast() + } + return Data(candidate.utf8) + } +} + +// MARK: - Group state payload (groupInvite / groupKeyUpdate over Noise) + +/// Creator-signed group state. The same wire form serves invites (0x06) and +/// key updates (0x07); receivers verify the creator signature — computed over +/// "bitchat-group-v1" | groupID | epoch | SHA256(key) | SHA256(roster) — +/// against the creator's signing key pinned in the roster, and require the +/// Noise session peer to BE the creator before accepting any state. +struct GroupStatePayload: Equatable { + let groupID: Data + let name: String + /// Symmetric ChaCha20-Poly1305 group key (32 bytes) for `epoch`. + let key: Data + let epoch: UInt32 + let members: [GroupMember] + let creatorFingerprint: String + /// Ed25519 signature by the creator. + let signature: Data + + private enum FieldType: UInt8 { + case groupID = 0x01 + case name = 0x02 + case key = 0x03 + case epoch = 0x04 + case roster = 0x05 + case creatorFingerprint = 0x06 + case signature = 0x07 + } + + static let signingDomain = Data("bitchat-group-v1".utf8) + + /// The bytes the creator signs. Binding the key, roster, and name by hash + /// keeps the signed content fixed-size. The name is covered so a relay + /// that caches/replays a signed state (e.g. store-and-forward) cannot swap + /// the display name while keeping a valid creator signature. + static func signingContent(groupID: Data, epoch: UInt32, key: Data, rosterBlob: Data, name: String) -> Data { + var content = signingDomain + content.append(groupID) + content.append(GroupTLV.epochData(epoch)) + content.append(key.sha256Hash()) + content.append(rosterBlob.sha256Hash()) + content.append(Data(name.utf8).sha256Hash()) + return content + } + + /// Builds a signed state payload. Returns nil when the roster cannot be + /// encoded (over cap, malformed member) or signing fails. + static func makeSigned( + group: BitchatGroup, + key: Data, + sign: (Data) -> Data? + ) -> GroupStatePayload? { + guard let rosterBlob = GroupRosterCoding.encode(group.members) else { return nil } + let content = signingContent(groupID: group.groupID, epoch: group.epoch, key: key, rosterBlob: rosterBlob, name: group.name) + guard let signature = sign(content) else { return nil } + return GroupStatePayload( + groupID: group.groupID, + name: group.name, + key: key, + epoch: group.epoch, + members: group.members, + creatorFingerprint: group.creatorFingerprint, + signature: signature + ) + } + + func encode() -> Data? { + guard let rosterBlob = GroupRosterCoding.encode(members), + let fingerprintData = Data(hexString: creatorFingerprint), + fingerprintData.count == 32 else { return nil } + var out = Data() + do { + try GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out) + try GroupTLV.put(FieldType.name.rawValue, Data(name.utf8), into: &out) + try GroupTLV.put(FieldType.key.rawValue, key, into: &out) + try GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out) + try GroupTLV.put(FieldType.roster.rawValue, rosterBlob, into: &out) + try GroupTLV.put(FieldType.creatorFingerprint.rawValue, fingerprintData, into: &out) + try GroupTLV.put(FieldType.signature.rawValue, signature, into: &out) + } catch { + return nil + } + return out + } + + static func decode(_ data: Data) -> GroupStatePayload? { + guard let fields = GroupTLV.parse(data) else { return nil } + var groupID: Data? + var name: String? + var key: Data? + var epoch: UInt32? + var rosterBlob: Data? + var members: [GroupMember]? + var creatorFingerprint: String? + var signature: Data? + + for (type, value) in fields { + switch FieldType(rawValue: type) { + case .groupID where value.count == BitchatGroup.groupIDLength: + groupID = value + case .name: + name = String(data: value, encoding: .utf8) + case .key where value.count == BitchatGroup.keyLength: + key = value + case .epoch: + epoch = GroupTLV.epoch(from: value) + case .roster: + rosterBlob = value + members = GroupRosterCoding.decode(value) + case .creatorFingerprint where value.count == 32: + creatorFingerprint = value.hexEncodedString() + case .signature where value.count == 64: + signature = value + default: + break // forward compatible; ignore unknown TLVs + } + } + + guard let groupID, let name, let key, let epoch, + rosterBlob != nil, let members, !members.isEmpty, + let creatorFingerprint, let signature else { return nil } + return GroupStatePayload( + groupID: groupID, + name: name, + key: key, + epoch: epoch, + members: members, + creatorFingerprint: creatorFingerprint, + signature: signature + ) + } + + /// Verifies the creator signature against the creator's signing key + /// pinned in the roster, and that the creator is actually in the roster. + func verifyCreatorSignature() -> Bool { + guard members.count <= BitchatGroup.maxMembers, + let creator = members.first(where: { $0.fingerprint == creatorFingerprint }), + let rosterBlob = GroupRosterCoding.encode(members) else { return false } + let content = GroupStatePayload.signingContent(groupID: groupID, epoch: epoch, key: key, rosterBlob: rosterBlob, name: name) + return GroupCrypto.verify(signature: signature, for: content, publicKey: creator.signingKey) + } + + var asGroup: BitchatGroup { + BitchatGroup( + groupID: groupID, + name: name, + epoch: epoch, + members: members, + creatorFingerprint: creatorFingerprint + ) + } +} + +// MARK: - Group message envelope (MessageType 0x25 payload) + +/// Cleartext framing of a group message broadcast. Only the group ID, epoch, +/// and nonce are visible to relays; everything about the message — sender, +/// content, timestamps — is inside the ChaCha20-Poly1305 ciphertext. +struct GroupMessageEnvelope: Equatable { + let groupID: Data + let epoch: UInt32 + let nonce: Data + /// ChaChaPoly ciphertext || 16-byte tag. + let ciphertext: Data + + private enum FieldType: UInt8 { + case groupID = 0x01 + case epoch = 0x02 + case nonce = 0x03 + case ciphertext = 0x04 + } + + func encode() throws -> Data { + var out = Data() + try GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out) + try GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out) + try GroupTLV.put(FieldType.nonce.rawValue, nonce, into: &out) + try GroupTLV.put(FieldType.ciphertext.rawValue, ciphertext, into: &out) + return out + } + + static func decode(_ data: Data) -> GroupMessageEnvelope? { + guard let fields = GroupTLV.parse(data) else { return nil } + var groupID: Data? + var epoch: UInt32? + var nonce: Data? + var ciphertext: Data? + for (type, value) in fields { + switch FieldType(rawValue: type) { + case .groupID where value.count == BitchatGroup.groupIDLength: + groupID = value + case .epoch: + epoch = GroupTLV.epoch(from: value) + case .nonce where value.count == 12: + nonce = value + case .ciphertext where !value.isEmpty: + ciphertext = value + default: + break + } + } + guard let groupID, let epoch, let nonce, let ciphertext else { return nil } + return GroupMessageEnvelope(groupID: groupID, epoch: epoch, nonce: nonce, ciphertext: ciphertext) + } +} + +/// Decrypted, signature-verified inner content of a group message. +struct GroupMessagePlaintext: Equatable { + let messageID: String + let senderSigningKey: Data + let senderNickname: String + let timestampMs: UInt64 + let content: String +} + +// MARK: - Crypto + +enum GroupCryptoError: Error, Equatable { + case malformedPayload + case signingFailed + case sealFailed + case wrongEpoch + case decryptionFailed + case badSenderSignature +} + +enum GroupCrypto { + static let messageSigningDomain = Data("bitchat-group-msg-v1".utf8) + + private enum InnerField: UInt8 { + case messageID = 0x01 + case senderSigningKey = 0x02 + case senderNickname = 0x03 + case timestamp = 0x04 + case content = 0x05 + case signature = 0x06 + } + + /// Bytes the sender signs: domain | groupID | epoch | messageID | timestamp | content. + /// Covering the epoch stops a current member from re-sealing another + /// member's decrypted inner bytes under a later epoch key (the signature + /// would no longer verify at the new epoch). + static func messageSigningContent(groupID: Data, epoch: UInt32, messageID: String, timestampMs: UInt64, content: String) -> Data { + var data = messageSigningDomain + data.append(groupID) + data.append(GroupTLV.epochData(epoch)) + data.append(Data(messageID.utf8)) + data.append(GroupTLV.timestampData(timestampMs)) + data.append(Data(content.utf8)) + return data + } + + static func verify(signature: Data, for data: Data, publicKey: Data) -> Bool { + guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else { return false } + return key.isValidSignature(signature, for: data) + } + + /// Seals a group message: builds the signed inner TLV and encrypts it with + /// the epoch key. The cleartext group ID and epoch are bound into the AEAD + /// as additional data so ciphertext cannot be replayed across groups or + /// epochs. Returns the encoded 0x25 packet payload. + static func sealMessage( + content: String, + messageID: String, + senderNickname: String, + senderSigningKey: Data, + timestampMs: UInt64, + groupID: Data, + epoch: UInt32, + key: Data, + sign: (Data) -> Data? + ) throws -> Data { + let signingContent = messageSigningContent( + groupID: groupID, + epoch: epoch, + messageID: messageID, + timestampMs: timestampMs, + content: content + ) + guard let signature = sign(signingContent), signature.count == 64 else { + throw GroupCryptoError.signingFailed + } + + var inner = Data() + try GroupTLV.put(InnerField.messageID.rawValue, Data(messageID.utf8), into: &inner) + try GroupTLV.put(InnerField.senderSigningKey.rawValue, senderSigningKey, into: &inner) + try GroupTLV.put(InnerField.senderNickname.rawValue, Data(senderNickname.utf8), into: &inner) + try GroupTLV.put(InnerField.timestamp.rawValue, GroupTLV.timestampData(timestampMs), into: &inner) + try GroupTLV.put(InnerField.content.rawValue, Data(content.utf8), into: &inner) + try GroupTLV.put(InnerField.signature.rawValue, signature, into: &inner) + + do { + let symmetricKey = SymmetricKey(data: key) + var aad = groupID + aad.append(GroupTLV.epochData(epoch)) + let sealed = try ChaChaPoly.seal(inner, using: symmetricKey, authenticating: aad) + var ciphertext = sealed.ciphertext + ciphertext.append(sealed.tag) + let envelope = GroupMessageEnvelope( + groupID: groupID, + epoch: epoch, + nonce: Data(sealed.nonce), + ciphertext: ciphertext + ) + return try envelope.encode() + } catch { + throw GroupCryptoError.sealFailed + } + } + + /// Opens a group message envelope with the epoch key: decrypts, parses the + /// inner TLV, and verifies the sender's Ed25519 signature. Roster + /// membership of the sender is the CALLER's check — this function only + /// proves the payload was authored by `senderSigningKey`. + static func openMessage(_ envelope: GroupMessageEnvelope, key: Data) throws -> GroupMessagePlaintext { + let inner: Data + do { + let symmetricKey = SymmetricKey(data: key) + var aad = envelope.groupID + aad.append(GroupTLV.epochData(envelope.epoch)) + let nonce = try ChaChaPoly.Nonce(data: envelope.nonce) + guard envelope.ciphertext.count > 16 else { throw GroupCryptoError.decryptionFailed } + let tag = envelope.ciphertext.suffix(16) + let body = envelope.ciphertext.prefix(envelope.ciphertext.count - 16) + let sealedBox = try ChaChaPoly.SealedBox(nonce: nonce, ciphertext: body, tag: tag) + inner = try ChaChaPoly.open(sealedBox, using: symmetricKey, authenticating: aad) + } catch { + throw GroupCryptoError.decryptionFailed + } + + guard let fields = GroupTLV.parse(inner) else { throw GroupCryptoError.malformedPayload } + var messageID: String? + var senderSigningKey: Data? + var senderNickname: String? + var timestampMs: UInt64? + var content: String? + var signature: Data? + for (type, value) in fields { + switch InnerField(rawValue: type) { + case .messageID: + messageID = String(data: value, encoding: .utf8) + case .senderSigningKey where value.count == 32: + senderSigningKey = value + case .senderNickname: + senderNickname = String(data: value, encoding: .utf8) + case .timestamp: + timestampMs = GroupTLV.timestamp(from: value) + case .content: + content = String(data: value, encoding: .utf8) + case .signature where value.count == 64: + signature = value + default: + break + } + } + guard let messageID, !messageID.isEmpty, + let senderSigningKey, + let senderNickname, + let timestampMs, + let content, + let signature else { throw GroupCryptoError.malformedPayload } + + let signingContent = messageSigningContent( + groupID: envelope.groupID, + epoch: envelope.epoch, + messageID: messageID, + timestampMs: timestampMs, + content: content + ) + guard verify(signature: signature, for: signingContent, publicKey: senderSigningKey) else { + throw GroupCryptoError.badSenderSignature + } + + return GroupMessagePlaintext( + messageID: messageID, + senderSigningKey: senderSigningKey, + senderNickname: senderNickname, + timestampMs: timestampMs, + content: content + ) + } +} diff --git a/bitchat/Services/Groups/GroupStore.swift b/bitchat/Services/Groups/GroupStore.swift new file mode 100644 index 00000000..3a45dcb7 --- /dev/null +++ b/bitchat/Services/Groups/GroupStore.swift @@ -0,0 +1,194 @@ +// +// GroupStore.swift +// bitchat +// +// Persistence for private groups: symmetric keys in the keychain, metadata +// (roster, name, epoch) as protected JSON in Application Support. Both are +// dropped by the panic wipe. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import Combine +import Foundation +import Security + +@MainActor +final class GroupStore: ObservableObject { + /// All groups this device is a member of, in creation/join order. + @Published private(set) var groups: [BitchatGroup] = [] + + private let keychain: KeychainManagerProtocol + private let fileURL: URL? + + /// - Parameter fileURL: Overrides the on-disk location (tests). Ignored + /// when `persistsToDisk` is false. + init(keychain: KeychainManagerProtocol, persistsToDisk: Bool = true, fileURL: URL? = nil) { + self.keychain = keychain + self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil + loadFromDisk() + } + + // MARK: - Reads + + func group(withID groupID: Data) -> BitchatGroup? { + groups.first { $0.groupID == groupID } + } + + func group(for peerID: PeerID) -> BitchatGroup? { + guard let groupID = peerID.groupIDData else { return nil } + return group(withID: groupID) + } + + /// Current-epoch symmetric key for the group, from the keychain. + func key(forGroupID groupID: Data) -> Data? { + keychain.getIdentityKey(forKey: Self.keychainKey(for: groupID)) + } + + // MARK: - Mutations + + /// Creates a new group with a random 16-byte ID and 32-byte key at + /// epoch 1, with the creator as sole member. Returns nil when key + /// generation or persistence fails. + func createGroup(named name: String, creator: GroupMember) -> BitchatGroup? { + guard let groupID = Self.randomBytes(BitchatGroup.groupIDLength), + let key = Self.randomBytes(BitchatGroup.keyLength) else { return nil } + let group = BitchatGroup( + groupID: groupID, + name: name, + epoch: 1, + members: [creator], + creatorFingerprint: creator.fingerprint + ) + guard upsert(group, key: key) else { return nil } + return group + } + + /// Inserts or replaces a group and its current key. Rejects rosters over + /// the hard cap or groups whose creator is missing from the roster. + @discardableResult + func upsert(_ group: BitchatGroup, key: Data) -> Bool { + guard group.groupID.count == BitchatGroup.groupIDLength, + key.count == BitchatGroup.keyLength, + !group.members.isEmpty, + group.members.count <= BitchatGroup.maxMembers, + group.creator != nil else { return false } + guard keychain.saveIdentityKey(key, forKey: Self.keychainKey(for: group.groupID)) else { + SecureLogger.error("Failed to store group key in keychain", category: .security) + return false + } + if let index = groups.firstIndex(where: { $0.groupID == group.groupID }) { + groups[index] = group + } else { + groups.append(group) + } + persist() + return true + } + + /// Updates the roster of an existing group without changing key or epoch + /// (creator-side invite). Enforces the member cap. + @discardableResult + func updateRoster(groupID: Data, members: [GroupMember]) -> BitchatGroup? { + guard let index = groups.firstIndex(where: { $0.groupID == groupID }), + !members.isEmpty, + members.count <= BitchatGroup.maxMembers, + members.contains(where: { $0.fingerprint == groups[index].creatorFingerprint }) else { return nil } + groups[index].members = members + persist() + return groups[index] + } + + /// Rotates the group key (creator-side removal/rotation): new random key, + /// epoch + 1, and the given roster. Returns the updated group and new key. + func rotateKey(groupID: Data, members: [GroupMember]) -> (group: BitchatGroup, key: Data)? { + guard let existing = group(withID: groupID), + let newKey = Self.randomBytes(BitchatGroup.keyLength) else { return nil } + var rotated = existing + rotated.epoch = existing.epoch &+ 1 + rotated.members = members + guard upsert(rotated, key: newKey) else { return nil } + return (rotated, newKey) + } + + func removeGroup(withID groupID: Data) { + groups.removeAll { $0.groupID == groupID } + _ = keychain.deleteIdentityKey(forKey: Self.keychainKey(for: groupID)) + persist() + } + + /// Panic wipe: drop all group keys and metadata from memory and disk. + /// (The panic flow also nukes the whole keychain; deleting per-group keys + /// here keeps the store safe to wipe on its own.) + func wipe() { + for group in groups { + _ = keychain.deleteIdentityKey(forKey: Self.keychainKey(for: group.groupID)) + } + groups.removeAll() + if let fileURL { + try? FileManager.default.removeItem(at: fileURL) + } + } + + // MARK: - Internals + + private static func keychainKey(for groupID: Data) -> String { + "groupKey-\(groupID.hexEncodedString())" + } + + private static func randomBytes(_ count: Int) -> Data? { + var bytes = Data(count: count) + let status = bytes.withUnsafeMutableBytes { buffer -> OSStatus in + guard let baseAddress = buffer.baseAddress else { return errSecParam } + return SecRandomCopyBytes(kSecRandomDefault, count, baseAddress) + } + return status == errSecSuccess ? bytes : nil + } + + private func persist() { + guard let fileURL else { return } + do { + if groups.isEmpty { + try? FileManager.default.removeItem(at: fileURL) + return + } + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(groups) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtection) + #endif + try data.write(to: fileURL, options: options) + } catch { + SecureLogger.error("Failed to persist group store: \(error)", category: .session) + } + } + + private func loadFromDisk() { + guard let fileURL, + let data = try? Data(contentsOf: fileURL), + let stored = try? JSONDecoder().decode([BitchatGroup].self, from: data) else { + return + } + // Only groups whose key survived in the keychain are usable. + groups = stored.filter { key(forGroupID: $0.groupID) != nil } + } + + private static func defaultFileURL() -> URL? { + guard let base = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + return base + .appendingPathComponent("groups", isDirectory: true) + .appendingPathComponent("groups.json") + } +} diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 45aed91e..d6bdef33 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -69,6 +69,9 @@ enum TransportEvent: @unchecked Sendable { case messageReceived(BitchatMessage) case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) case noisePayloadReceived(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) + /// Encrypted group broadcast (MessageType 0x25). Opaque here — the group + /// coordinator decrypts and authenticates against the roster. + case groupMessageReceived(payload: Data, timestamp: Date) case peerConnected(PeerID) case peerDisconnected(PeerID) case peerListUpdated([PeerID]) @@ -158,6 +161,12 @@ protocol Transport: AnyObject { // transport cannot courier (no connected courier, or unsupported). func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool + // Private groups (mesh transports only): creator-signed state travels + // 1:1 over Noise sessions; group messages flood like public broadcasts. + func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) + func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) + func broadcastGroupMessage(_ envelope: Data) + // Bulletin board (mesh transports only): broadcast a pre-signed board // payload (post or tombstone) so it spreads over relay and gossip sync. func sendBoardPayload(_ payload: Data) @@ -214,6 +223,9 @@ extension Transport { func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} + func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {} + func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {} + func broadcastGroupMessage(_ envelope: Data) {} func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] } func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {} func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {} @@ -259,6 +271,8 @@ extension BitchatDelegate { ) case let .noisePayloadReceived(peerID, type, payload, timestamp): didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp) + case let .groupMessageReceived(payload, timestamp): + didReceiveGroupMessage(payload: payload, timestamp: timestamp) case .peerConnected(let peerID): didConnectToPeer(peerID) case .peerDisconnected(let peerID): diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index b0d72e47..3dee21e0 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -73,6 +73,7 @@ final class GossipSyncManager { var stalePeerTimeoutSeconds: TimeInterval = 60.0 var fragmentCapacity: Int = 600 var fileTransferCapacity: Int = 200 + var groupMessageCapacity: Int = 200 var fragmentSyncIntervalSeconds: TimeInterval = 30.0 var fileTransferSyncIntervalSeconds: TimeInterval = 60.0 var messageSyncIntervalSeconds: TimeInterval = 15.0 @@ -106,6 +107,7 @@ final class GossipSyncManager { private var messages = PacketStore() private var fragments = PacketStore() private var fileTransfers = PacketStore() + private var groupMessages = PacketStore() private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:] // Latest verified prekey bundle per owner. Unlike announces, bundles are // NOT dropped on leave/stale peer: their whole purpose is reaching a @@ -131,7 +133,13 @@ final class GossipSyncManager { ) var schedules: [SyncSchedule] = [] if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 { - schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast)) + // Group messages ride the public-message cadence; old clients + // ignore the extended bit and answer with announces/messages only. + var messageTypes: SyncTypeFlags = .publicMessages + if config.groupMessageCapacity > 0 { + messageTypes.formUnion(.groupMessage) + } + schedules.append(SyncSchedule(types: messageTypes, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast)) } if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 { schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast)) @@ -175,6 +183,9 @@ final class GossipSyncManager { guard let self = self else { return } var types: SyncTypeFlags = .publicMessages + if self.config.groupMessageCapacity > 0 { + types.formUnion(.groupMessage) + } if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 { types.formUnion(.fragment) } @@ -201,9 +212,11 @@ final class GossipSyncManager { // messages get the long town-crier window; fragments, file transfers and // announces keep the short one. private func isPacketFresh(_ packet: BitchatPacket) -> Bool { + // Group messages share the whole-message window: members off the mesh + // for a while should backfill their crew's history like public chat. let maxAgeSeconds: TimeInterval switch packet.type { - case MessageType.message.rawValue: + case MessageType.message.rawValue, MessageType.groupMessage.rawValue: maxAgeSeconds = config.publicMessageMaxAgeSeconds case MessageType.prekeyBundle.rawValue: maxAgeSeconds = config.prekeyBundleMaxAgeSeconds @@ -262,6 +275,13 @@ final class GossipSyncManager { guard isPacketFresh(packet) else { return } let idHex = PacketIdUtil.computeId(packet).hexEncodedString() fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity)) + case .groupMessage: + // Opaque ciphertext to non-members; carried and served like any + // other broadcast so members get backfill from any relay. + guard isBroadcastRecipient else { return } + guard isPacketFresh(packet) else { return } + let idHex = PacketIdUtil.computeId(packet).hexEncodedString() + groupMessages.insert(idHex: idHex, packet: packet, capacity: max(1, config.groupMessageCapacity)) case .prekeyBundle: // Callers only feed verified bundles here (own bundles at send // time, peers' after signature verification), so gossip never @@ -454,6 +474,19 @@ final class GossipSyncManager { } } + if requestedTypes.contains(.groupMessage) { + let groupPkts = groupMessages.allPackets(isFresh: isPacketFresh) + for pkt in groupPkts { + if let since, pkt.timestamp < since { continue } + let idBytes = PacketIdUtil.computeId(pkt) + if !mightContain(idBytes) { + var toSend = pkt + toSend.ttl = 0 + toSend.isRSR = true // Mark as solicited response + delegate?.sendPacket(to: peerID, packet: toSend) + } + } + } // Like announces, prekey bundles are exempt from the since-cursor: // there is at most one per owner (newer replaces older), so the // resend cost is bounded and a joining peer must be able to learn @@ -505,6 +538,9 @@ final class GossipSyncManager { if types.contains(.fileTransfer) { candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh)) } + if types.contains(.groupMessage) { + candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh)) + } if types.contains(.prekeyBundle) { for (_, pair) in latestPrekeyBundleByPeer where isPacketFresh(pair.packet) { candidates.append(pair.packet) @@ -574,6 +610,7 @@ final class GossipSyncManager { } fragments.removeExpired(isFresh: isPacketFresh) fileTransfers.removeExpired(isFresh: isPacketFresh) + groupMessages.removeExpired(isFresh: isPacketFresh) latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in isPacketFresh(pair.packet) } @@ -677,6 +714,7 @@ final class GossipSyncManager { } fragments.remove { PeerID(hexData: $0.senderID) == peerID } fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID } + groupMessages.remove { PeerID(hexData: $0.senderID) == peerID } } } diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index cb743ce1..3b4b0c82 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -37,6 +37,13 @@ struct SyncTypeFlags: OptionSet { case .requestSync: return 6 case .fileTransfer: return 7 case .boardPost: return 8 + // Extended bits are compat-safe by construction: `toData()` encodes + // the bitfield little-endian with trailing zero bytes trimmed (bit 10 + // widens the wire form from 1 to 2 bytes inside the length-prefixed + // REQUEST_SYNC TLV 0x04), and `decode(_:)` accepts 1...8 bytes while + // `type(forBit:)` maps unknown bits to nil — so old clients simply + // ignore the group bit and answer with the types they know. + case .groupMessage: return 10 // Courier envelopes are directed deposits between trusted peers and // must never spread via gossip sync. case .courierEnvelope: return nil @@ -70,6 +77,7 @@ struct SyncTypeFlags: OptionSet { // known type, so old clients ignore board rounds instead of choking. case 8: return .boardPost case 9: return .prekeyBundle + case 10: return .groupMessage default: return nil } @@ -81,6 +89,7 @@ struct SyncTypeFlags: OptionSet { static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer]) static let board = SyncTypeFlags(messageTypes: [.boardPost]) static let prekeyBundle = SyncTypeFlags(messageTypes: [.prekeyBundle]) + static let groupMessage = SyncTypeFlags(messageTypes: [.groupMessage]) static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message]) diff --git a/bitchat/ViewModels/ChatGroupCoordinator.swift b/bitchat/ViewModels/ChatGroupCoordinator.swift new file mode 100644 index 00000000..a83cb024 --- /dev/null +++ b/bitchat/ViewModels/ChatGroupCoordinator.swift @@ -0,0 +1,602 @@ +import BitFoundation +import BitLogger +import Foundation + +/// The narrow surface `ChatGroupCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. Group chats are keyed like direct chats +/// (virtual "group_" peer IDs), so the conversation intents below reuse the +/// private-chat store operations. +@MainActor +protocol ChatGroupContext: AnyObject { + // MARK: Identity & state + var nickname: String { get } + var myPeerID: PeerID { get } + var selectedPrivateChatPeer: PeerID? { get } + var groupStore: GroupStore { get } + + /// Fingerprint of our own Noise static identity key. + func myNoiseFingerprint() -> String + /// Our Ed25519 signing public key. + func mySigningPublicKey() -> Data + /// Signs `data` with our Noise signing key. + func signWithNoiseKey(_ data: Data) -> Data? + + // MARK: Peers + func getPeerIDForNickname(_ nickname: String) -> PeerID? + func isPeerConnected(_ peerID: PeerID) -> Bool + func peerNickname(for peerID: PeerID) -> String? + /// The peer's Noise fingerprint from the live session/registry. + func meshFingerprint(for peerID: PeerID) -> String? + /// The peer's persisted crypto identity (fingerprint + signing key), if + /// the identity store has a signature-verified announce for them. + func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)? + /// The connected short peer ID whose fingerprint matches, if any. + func connectedPeerID(forFingerprint fingerprint: String) -> PeerID? + /// Whether the user has blocked the identity with this Noise fingerprint. + func isFingerprintBlocked(_ fingerprint: String) -> Bool + + // MARK: Transport + func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) + func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) + func broadcastGroupMessagePayload(_ payload: Data) + + // MARK: Conversation intents (group chats are direct-keyed) + @discardableResult + func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool + func markPrivateChatUnread(_ peerID: PeerID) + func removePrivateChat(_ peerID: PeerID) + func startPrivateChat(with peerID: PeerID) + func endPrivateChat() + func addSystemMessage(_ content: String) + func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) + func notifyUIChanged() + func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) +} + +extension ChatViewModel: ChatGroupContext { + // `nickname`, `myPeerID`, `selectedPrivateChatPeer`, `groupStore`, + // `getPeerIDForNickname(_:)`, `isPeerConnected(_:)`, `peerNickname(for:)`, + // `appendPrivateMessage(_:to:)`, `markPrivateChatUnread(_:)`, + // `removePrivateChat(_:)`, `startPrivateChat(with:)`, + // `addSystemMessage(_:)`, `addLocalPrivateSystemMessage(_:to:)`, + // `notifyUIChanged()`, and `notifyPrivateMessage(from:message:peerID:)` + // are shared requirements with the other contexts or satisfied by + // existing `ChatViewModel` members. The members below flatten nested + // service accesses into intent-named calls. + + func myNoiseFingerprint() -> String { + meshService.noiseIdentityFingerprint() + } + + func mySigningPublicKey() -> Data { + meshService.noiseSigningPublicKeyData() + } + + func signWithNoiseKey(_ data: Data) -> Data? { + meshService.noiseSignData(data) + } + + func meshFingerprint(for peerID: PeerID) -> String? { + meshService.getFingerprint(for: peerID) + } + + /// The persisted, signature-verified identity behind a short mesh peer + /// ID. Cross-checked against the live session fingerprint so a roster + /// entry can never be pinned to a signing key from a different identity. + func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)? { + guard let fingerprint = meshService.getFingerprint(for: peerID) else { return nil } + let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) + guard let identity = candidates.first(where: { $0.fingerprint == fingerprint }), + let signingKey = identity.signingPublicKey else { return nil } + return (fingerprint, signingKey) + } + + /// Short mesh peer IDs are the fingerprint's first 16 hex chars, so the + /// connected peer for a roster fingerprint is a direct derivation. + func connectedPeerID(forFingerprint fingerprint: String) -> PeerID? { + let shortID = PeerID(str: String(fingerprint.prefix(16))) + return meshService.isPeerConnected(shortID) ? shortID : nil + } + + func isFingerprintBlocked(_ fingerprint: String) -> Bool { + identityManager.isBlocked(fingerprint: fingerprint) + } + + func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) { + meshService.sendGroupInvite(payload, to: peerID) + } + + func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) { + meshService.sendGroupKeyUpdate(payload, to: peerID) + } + + func broadcastGroupMessagePayload(_ payload: Data) { + meshService.broadcastGroupMessage(payload) + } + + // MARK: CommandContextProvider group commands (parsed by CommandProcessor) + + func groupCreate(named name: String) -> CommandResult { + groupCoordinator.createGroup(named: name) + } + + func groupInvite(nickname: String) -> CommandResult { + groupCoordinator.inviteMember(nickname: nickname) + } + + func groupRemove(nickname: String) -> CommandResult { + groupCoordinator.removeMember(nickname: nickname) + } + + func groupLeave() -> CommandResult { + groupCoordinator.leaveGroup() + } + + func groupList() -> CommandResult { + groupCoordinator.listGroups() + } +} + +/// Owns the private-groups feature: creating groups, creator-managed invites +/// and key rotation over Noise, and sealing/opening group message broadcasts. +/// Delivery is fire-and-flood like public chat — no per-member acks in v1 — +/// with gossip-sync backfill as the only offline catch-up. +@MainActor +final class ChatGroupCoordinator { + private unowned let context: any ChatGroupContext + + private static let maxGroupNameLength = 40 + + init(context: any ChatGroupContext) { + self.context = context + } + + // MARK: - Commands + + func createGroup(named rawName: String) -> CommandResult { + let name = rawName.trimmed + guard !name.isEmpty else { + return .error(message: String(localized: "system.group.usage_create", comment: "Usage hint for /group create")) + } + guard name.count <= Self.maxGroupNameLength else { + return .error(message: String(localized: "system.group.name_too_long", comment: "Error when a group name exceeds the length cap")) + } + + let myFingerprint = context.myNoiseFingerprint() + let mySigningKey = context.mySigningPublicKey() + guard !myFingerprint.isEmpty, mySigningKey.count == 32 else { + return .error(message: String(localized: "system.group.identity_unavailable", comment: "Error when the local identity is not ready for group operations")) + } + + let creator = GroupMember(fingerprint: myFingerprint, signingKey: mySigningKey, nickname: context.nickname) + guard let group = context.groupStore.createGroup(named: name, creator: creator) else { + return .error(message: String(localized: "system.group.create_failed", comment: "Error when group creation fails")) + } + + context.startPrivateChat(with: group.peerID) + return .success(message: String( + format: String(localized: "system.group.created", comment: "System message after creating a group; placeholder is the group name"), + locale: .current, + name + )) + } + + func inviteMember(nickname rawNickname: String) -> CommandResult { + let nickname = normalizedNickname(rawNickname) + guard !nickname.isEmpty else { + return .error(message: String(localized: "system.group.usage_invite", comment: "Usage hint for /group invite")) + } + guard let group = selectedGroup() else { + return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat")) + } + guard group.creatorFingerprint == context.myNoiseFingerprint() else { + return .error(message: String(localized: "system.group.creator_only", comment: "Error when a non-creator attempts a creator-only group action")) + } + guard let peerID = context.getPeerIDForNickname(nickname) else { + return .error(message: String( + format: String(localized: "system.group.peer_not_found", comment: "Error when the invitee nickname is unknown; placeholder is the nickname"), + locale: .current, + nickname + )) + } + guard context.isPeerConnected(peerID) else { + return .error(message: String( + format: String(localized: "system.group.peer_not_connected", comment: "Error when the invitee is not connected over mesh; placeholder is the nickname"), + locale: .current, + nickname + )) + } + guard let identity = context.cryptoIdentity(for: peerID) else { + return .error(message: String( + format: String(localized: "system.group.peer_identity_unknown", comment: "Error when the invitee's verified identity is unavailable; placeholder is the nickname"), + locale: .current, + nickname + )) + } + guard !group.isMember(fingerprint: identity.fingerprint) else { + return .error(message: String( + format: String(localized: "system.group.already_member", comment: "Error when the invitee is already a member; placeholder is the nickname"), + locale: .current, + nickname + )) + } + guard group.members.count < BitchatGroup.maxMembers else { + return .error(message: String( + format: String(localized: "system.group.full", comment: "Error when the group is at the member cap; placeholder is the cap"), + locale: .current, + "\(BitchatGroup.maxMembers)" + )) + } + + let newMember = GroupMember( + fingerprint: identity.fingerprint, + signingKey: identity.signingKey, + nickname: context.peerNickname(for: peerID) ?? nickname + ) + // Rotate the key (epoch + 1) on every roster change, not just removals. + // A monotonically increasing epoch per roster gives the receiver a + // strict ordering: two out-of-order invite states can no longer share + // an epoch and last-writer-wins a just-added member back out. + let members = group.members + [newMember] + guard let (updated, key) = context.groupStore.rotateKey(groupID: group.groupID, members: members), + let payload = signedStatePayload(for: updated, key: key) else { + return .error(message: String(localized: "system.group.invite_failed", comment: "Error when building or signing a group invite fails")) + } + + context.sendGroupInvitePayload(payload, to: peerID) + distributeState(payload, group: updated, excluding: [identity.fingerprint], type: .keyUpdate) + + return .success(message: String( + format: String(localized: "system.group.invited", comment: "System message after inviting someone; placeholders are the nickname and the group name"), + locale: .current, + nickname, + updated.name + )) + } + + /// Creator-side removal: rotates the group key (epoch + 1) and sends the + /// new state to every remaining member so the removed member's key stops + /// decrypting future traffic. + func removeMember(nickname rawNickname: String) -> CommandResult { + let nickname = normalizedNickname(rawNickname) + guard !nickname.isEmpty else { + return .error(message: String(localized: "system.group.usage_remove", comment: "Usage hint for /group remove")) + } + guard let group = selectedGroup() else { + return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat")) + } + guard group.creatorFingerprint == context.myNoiseFingerprint() else { + return .error(message: String(localized: "system.group.creator_only", comment: "Error when a non-creator attempts a creator-only group action")) + } + guard let member = group.members.first(where: { $0.nickname.caseInsensitiveCompare(nickname) == .orderedSame }) else { + return .error(message: String( + format: String(localized: "system.group.member_not_found", comment: "Error when the member to remove is not in the roster; placeholder is the nickname"), + locale: .current, + nickname + )) + } + guard member.fingerprint != group.creatorFingerprint else { + return .error(message: String(localized: "system.group.cannot_remove_creator", comment: "Error when the creator tries to remove themselves")) + } + + let remaining = group.members.filter { $0.fingerprint != member.fingerprint } + guard let (rotated, newKey) = context.groupStore.rotateKey(groupID: group.groupID, members: remaining), + let payload = signedStatePayload(for: rotated, key: newKey) else { + return .error(message: String(localized: "system.group.rotate_failed", comment: "Error when rotating the group key fails")) + } + + distributeState(payload, group: rotated, excluding: [], type: .keyUpdate) + notifyRemovedMember(member, rotated: rotated) + + return .success(message: String( + format: String(localized: "system.group.removed_member", comment: "System message after removing a member and rotating the key; placeholder is the nickname"), + locale: .current, + member.nickname + )) + } + + func leaveGroup() -> CommandResult { + guard let group = selectedGroup() else { + return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat")) + } + // Close the chat window first so the confirmation message doesn't + // resurrect the conversation we're about to remove. + context.endPrivateChat() + context.removePrivateChat(group.peerID) + context.groupStore.removeGroup(withID: group.groupID) + context.notifyUIChanged() + return .success(message: String( + format: String(localized: "system.group.left", comment: "System message after leaving a group; placeholder is the group name"), + locale: .current, + group.name + )) + } + + func listGroups() -> CommandResult { + let groups = context.groupStore.groups + guard !groups.isEmpty else { + return .success(message: String(localized: "system.group.none", comment: "System message when the user is in no groups")) + } + let myFingerprint = context.myNoiseFingerprint() + let lines = groups.map { group -> String in + let role = group.creatorFingerprint == myFingerprint ? " (creator)" : "" + return "#\(group.name)\(role) — \(group.members.count)/\(BitchatGroup.maxMembers)" + } + return .success(message: String(localized: "system.group.list_header", comment: "Header line for the /group list output") + "\n" + lines.joined(separator: "\n")) + } + + // MARK: - Sending + + /// Fire-and-flood send: local echo goes straight to `.sent` because group + /// messages have no per-member acknowledgments in v1. + func sendGroupMessage(_ content: String, to groupPeerID: PeerID) { + guard !content.isEmpty, content.count <= InputValidator.Limits.maxMessageLength else { return } + guard let group = context.groupStore.group(for: groupPeerID), + let key = context.groupStore.key(forGroupID: group.groupID) else { + context.addSystemMessage(String(localized: "system.group.unknown", comment: "System message when sending into an unknown group")) + return + } + + let messageID = UUID().uuidString + let timestamp = Date() + let payload: Data + do { + payload = try GroupCrypto.sealMessage( + content: content, + messageID: messageID, + senderNickname: context.nickname, + senderSigningKey: context.mySigningPublicKey(), + timestampMs: UInt64(timestamp.timeIntervalSince1970 * 1000), + groupID: group.groupID, + epoch: group.epoch, + key: key, + sign: { [weak context] data in context?.signWithNoiseKey(data) } + ) + } catch { + SecureLogger.error("Failed to seal group message: \(error)", category: .encryption) + context.addLocalPrivateSystemMessage( + String(localized: "system.group.send_failed", comment: "System message when sealing a group message fails"), + to: groupPeerID + ) + return + } + + let message = BitchatMessage( + id: messageID, + sender: context.nickname, + content: content, + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: group.name, + senderPeerID: context.myPeerID, + mentions: nil, + deliveryStatus: .sent + ) + context.appendPrivateMessage(message, to: groupPeerID) + context.broadcastGroupMessagePayload(payload) + context.notifyUIChanged() + } + + // MARK: - Receiving + + /// Decrypt-verify path for an incoming 0x25 broadcast. Drops silently for + /// unknown groups (non-members relay but never read), wrong epochs, bad + /// sender signatures, and senders missing from the pinned roster. + func handleGroupMessagePayload(_ payload: Data, timestamp: Date) { + guard let envelope = GroupMessageEnvelope.decode(payload) else { return } + guard let group = context.groupStore.group(withID: envelope.groupID) else { return } + guard envelope.epoch == group.epoch else { + SecureLogger.debug("Dropping group message with epoch \(envelope.epoch) (current \(group.epoch))", category: .encryption) + return + } + guard let key = context.groupStore.key(forGroupID: group.groupID) else { return } + + let plaintext: GroupMessagePlaintext + do { + plaintext = try GroupCrypto.openMessage(envelope, key: key) + } catch { + SecureLogger.debug("Failed to open group message: \(error)", category: .encryption) + return + } + + // Sender must be pinned in the creator-signed roster; key possession + // alone is not authorship. + guard let member = group.member(withSigningKey: plaintext.senderSigningKey) else { + SecureLogger.warning("Dropping group message from non-roster sender", category: .security) + return + } + // Our own broadcast echoed back via relay or sync replay. + guard plaintext.senderSigningKey != context.mySigningPublicKey() else { return } + // Honor /block inside groups too: drop display + notification for a + // blocked member, consistent with every other inbound path. + guard !context.isFingerprintBlocked(member.fingerprint) else { + SecureLogger.debug("Dropping group message from blocked member", category: .security) + return + } + + let groupPeerID = group.peerID + // Trust the authenticated inner timestamp (clamped so a future-dated + // message cannot pin itself to the bottom of the timeline). + let messageDate = min(Date(timeIntervalSince1970: TimeInterval(plaintext.timestampMs) / 1000), Date()) + let senderName = member.nickname.isEmpty ? plaintext.senderNickname : member.nickname + let senderPeerID = PeerID(str: String(member.fingerprint.prefix(16))) + let message = BitchatMessage( + id: plaintext.messageID, + sender: senderName, + content: plaintext.content, + timestamp: messageDate, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: group.name, + senderPeerID: senderPeerID, + mentions: nil + ) + + guard context.appendPrivateMessage(message, to: groupPeerID) else { return } + + let isViewing = context.selectedPrivateChatPeer == groupPeerID + if !isViewing { + context.markPrivateChatUnread(groupPeerID) + let isRecent = Date().timeIntervalSince(messageDate) < 30 + if isRecent { + context.notifyPrivateMessage( + from: "\(senderName) @ \(group.name)", + message: plaintext.content, + peerID: groupPeerID + ) + } + } + context.notifyUIChanged() + } + + /// Accepts creator-signed group state arriving as an invite. The Noise + /// session peer must BE the creator, the signature must verify against + /// the creator key pinned in the roster, and we must be in the roster. + func handleGroupInvitePayload(from peerID: PeerID, payload: Data) { + applyGroupState(from: peerID, payload: payload, isInvite: true) + } + + /// Accepts creator-signed state updates (rotation/roster). A state whose + /// roster no longer includes us means we were removed: drop the group. + func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) { + applyGroupState(from: peerID, payload: payload, isInvite: false) + } +} + +private extension ChatGroupCoordinator { + enum StateSendType { + case invite + case keyUpdate + } + + func normalizedNickname(_ raw: String) -> String { + let trimmed = raw.trimmed + return trimmed.hasPrefix("@") ? String(trimmed.dropFirst()) : trimmed + } + + func selectedGroup() -> BitchatGroup? { + guard let selected = context.selectedPrivateChatPeer, selected.isGroup else { return nil } + return context.groupStore.group(for: selected) + } + + func signedStatePayload(for group: BitchatGroup, key: Data) -> Data? { + GroupStatePayload.makeSigned(group: group, key: key) { [weak context] data in + context?.signWithNoiseKey(data) + }?.encode() + } + + /// Sends the state payload to every connected roster member except us and + /// the excluded fingerprints. Offline members catch up the next time the + /// creator sends them state (v1 limitation, documented in the PR). + func distributeState(_ payload: Data, group: BitchatGroup, excluding excludedFingerprints: Set, type: StateSendType) { + let myFingerprint = context.myNoiseFingerprint() + for member in group.members { + guard member.fingerprint != myFingerprint, + !excludedFingerprints.contains(member.fingerprint), + let peerID = context.connectedPeerID(forFingerprint: member.fingerprint) else { continue } + switch type { + case .invite: + context.sendGroupInvitePayload(payload, to: peerID) + case .keyUpdate: + context.sendGroupKeyUpdatePayload(payload, to: peerID) + } + } + } + + /// Tells a just-removed member they're out so their client can deactivate + /// the group instead of silently going dark (dropping every message under + /// the epoch it no longer has the key for). The notice is a creator-signed + /// state whose roster excludes the removee — their `applyGroupState` + /// removal branch fires on the missing-self roster and surfaces the + /// "removed from group" system message. + /// + /// It carries a throwaway all-zero key, never the rotated key, so the + /// removee cannot decrypt post-removal traffic. State is sent 1:1 over + /// authenticated Noise, so no remaining member ever receives this blob + /// (and even if one did, its own missing-self check would not match). + /// If the removee is offline the notice can't be delivered — same v1 + /// limitation as any other missed key update, documented in the PR. + func notifyRemovedMember(_ removed: GroupMember, rotated: BitchatGroup) { + guard let peerID = context.connectedPeerID(forFingerprint: removed.fingerprint) else { return } + let throwawayKey = Data(count: BitchatGroup.keyLength) + guard let payload = signedStatePayload(for: rotated, key: throwawayKey) else { return } + context.sendGroupKeyUpdatePayload(payload, to: peerID) + } + + func applyGroupState(from peerID: PeerID, payload: Data, isInvite: Bool) { + guard let state = GroupStatePayload.decode(payload) else { + SecureLogger.warning("Malformed group state payload from \(peerID.id.prefix(8))…", category: .security) + return + } + // The Noise session already authenticated `peerID`; require that the + // authenticated peer IS the creator whose key signed the state, so a + // member can't re-invite or rotate on the creator's behalf. + guard let senderFingerprint = context.meshFingerprint(for: peerID), + senderFingerprint == state.creatorFingerprint else { + SecureLogger.warning("Dropping group state from non-creator \(peerID.id.prefix(8))…", category: .security) + return + } + guard state.verifyCreatorSignature() else { + SecureLogger.warning("Dropping group state with invalid creator signature", category: .security) + return + } + + let myFingerprint = context.myNoiseFingerprint() + let existing = context.groupStore.group(withID: state.groupID) + + // A creator-signed roster that no longer includes us is a removal. + guard state.members.contains(where: { $0.fingerprint == myFingerprint }) else { + if let existing { + if context.selectedPrivateChatPeer == existing.peerID { + context.endPrivateChat() + } + context.removePrivateChat(existing.peerID) + context.groupStore.removeGroup(withID: existing.groupID) + context.addSystemMessage(String( + format: String(localized: "system.group.removed_from", comment: "System message when removed from a group; placeholder is the group name"), + locale: .current, + existing.name + )) + context.notifyUIChanged() + } + return + } + + // Never regress the epoch: state travels over live Noise sessions, + // so an older epoch here is a stale (or misbehaving) creator device. + if let existing, state.epoch < existing.epoch { + SecureLogger.warning("Dropping stale group state (epoch \(state.epoch) < \(existing.epoch))", category: .security) + return + } + + let isNewMembership = existing == nil + guard context.groupStore.upsert(state.asGroup, key: state.key) else { + SecureLogger.error("Failed to store group state for \(state.name)", category: .session) + return + } + + if isNewMembership { + let inviter = state.members.first { $0.fingerprint == state.creatorFingerprint }?.nickname + ?? context.peerNickname(for: peerID) + ?? "?" + let notice = String( + format: String(localized: "system.group.joined", comment: "System message when added to a group; placeholders are the group name and the inviter"), + locale: .current, + state.name, + inviter + ) + context.addSystemMessage(notice) + context.markPrivateChatUnread(state.asGroup.peerID) + context.notifyPrivateMessage(from: inviter, message: notice, peerID: state.asGroup.peerID) + } else if isInvite == false, let existing, state.epoch > existing.epoch { + SecureLogger.info("Group '\(state.name)' rotated to epoch \(state.epoch)", category: .session) + } + context.notifyUIChanged() + } +} diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index 60c199f6..1241f71b 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -185,6 +185,13 @@ final class ChatLifecycleCoordinator { func markPrivateMessagesAsRead(from peerID: PeerID) { context.markChatAsRead(from: peerID) + // Group chats are keyed under a virtual group_ peerID; no member IS the + // conversation peer, so the receipt loops below (which gate on + // senderPeerID == peerID) must never emit a read/delivered receipt for + // one. This guard makes that explicit so a future refactor of the + // receipt matching can't silently start leaking receipts into groups. + guard !peerID.isGroup else { return } + if peerID.isGeoDM, let recipientHex = context.nostrKeyMapping[peerID], case .location(let channel) = context.activeChannel, diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 46e0787a..30d566b8 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -323,6 +323,15 @@ final class ChatPeerIdentityCoordinator { func startPrivateChat(with peerID: PeerID) { guard peerID != context.myPeerID else { return } + // Group chats are virtual conversations: no peer identity, favorites, + // handshake, or message consolidation applies — just select the chat. + if peerID.isGroup { + context.selectedPrivateChatFingerprint = nil + context.beginPrivateChatSession(with: peerID) + context.markPrivateChatRead(peerID) + return + } + let peerNickname = context.peerNickname(for: peerID) ?? "unknown" if context.unifiedIsBlocked(peerID) { diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index a24273d7..34b1653c 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -71,6 +71,10 @@ protocol ChatTransportEventContext: AnyObject { // MARK: Verification payloads func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) + + // MARK: Group payloads (creator-signed state over Noise) + func handleGroupInvitePayload(from peerID: PeerID, payload: Data) + func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) func handleVouchPayload(from peerID: PeerID, payload: Data) } @@ -131,6 +135,14 @@ extension ChatViewModel: ChatTransportEventContext { verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload) } + func handleGroupInvitePayload(from peerID: PeerID, payload: Data) { + groupCoordinator.handleGroupInvitePayload(from: peerID, payload: payload) + } + + func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) { + groupCoordinator.handleGroupKeyUpdatePayload(from: peerID, payload: payload) + } + func handleVouchPayload(from peerID: PeerID, payload: Data) { vouchCoordinator.handleVouchPayload(from: peerID, payload: payload) } @@ -377,6 +389,12 @@ private extension ChatTransportEventCoordinator { case .verifyResponse: context.handleVerifyResponsePayload(from: peerID, payload: payload) + case .groupInvite: + context.handleGroupInvitePayload(from: peerID, payload: payload) + + case .groupKeyUpdate: + context.handleGroupKeyUpdatePayload(from: peerID, payload: payload) + case .vouch: context.handleVouchPayload(from: peerID, payload: payload) } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 21e250f2..c0c0d8b1 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -102,7 +102,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele @MainActor var canSendMediaInCurrentContext: Bool { if let peer = selectedPrivateChatPeer { - return !(peer.isGeoDM || peer.isGeoChat) + // Media transfer is not wired for groups in v1 (sendFilePrivate + // rejects the virtual group_ recipient), so keep the affordance off. + return !(peer.isGeoDM || peer.isGeoChat || peer.isGroup) } switch activeChannel { case .mesh: return true @@ -177,6 +179,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele lazy var nostrCoordinator = ChatNostrCoordinator(context: self) lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self) lazy var verificationCoordinator = ChatVerificationCoordinator(context: self) + lazy var groupCoordinator = ChatGroupCoordinator(context: self) lazy var vouchCoordinator = ChatVouchCoordinator(context: self) // Computed properties for compatibility @@ -306,6 +309,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele var nostrRelayManager: NostrRelayManager? private let userDefaults = UserDefaults.standard let keychain: KeychainManagerProtocol + /// Private group membership: keys in the keychain, metadata on disk. + let groupStore: GroupStore private let nicknameKey = "bitchat.nickname" // Location channel state (macOS supports manual geohash selection) var activeChannel: ChannelID { @@ -813,6 +818,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele ) self.keychain = keychain + self.groupStore = GroupStore(keychain: keychain) self.idBridge = idBridge self.identityManager = identityManager self.conversations = conversations @@ -1209,6 +1215,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele GossipMessageArchive.wipeDefault() StoreAndForwardMetrics.shared.reset() + // Drop private group keys and rosters (keychain + disk) + groupStore.wipe() // Drop cached peers' prekey bundles (who we could write to is // metadata too). Our own prekey privates are keychain-backed and go // with deleteAllKeychainData above plus the identity reset below. @@ -1609,6 +1617,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele ) } + func didReceiveGroupMessage(payload: Data, timestamp: Date) { + Task { @MainActor [weak self] in + self?.groupCoordinator.handleGroupMessagePayload(payload, timestamp: timestamp) + } + } + // MARK: - QR Verification API @MainActor func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool { diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift index af8d06aa..939adf6a 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift @@ -13,6 +13,12 @@ extension ChatViewModel { @MainActor func sendPrivateMessage(_ content: String, to peerID: PeerID) { + // Group chats reuse the private-chat surface but broadcast a sealed + // envelope instead of routing to a single peer. + if peerID.isGroup { + groupCoordinator.sendGroupMessage(content, to: peerID) + return + } privateConversationCoordinator.sendPrivateMessage(content, to: peerID) } diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index c5bd6525..be50a687 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -303,7 +303,9 @@ final class NostrInboundPipeline { context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey) case .readReceipt: context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey) - case .verifyChallenge, .verifyResponse, .vouch: + // Group state travels only over mesh Noise sessions in v1; anything + // claiming to be group traffic over Nostr is ignored. + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch: break } } @@ -355,7 +357,9 @@ final class NostrInboundPipeline { context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) case .readReceipt: context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) - case .verifyChallenge, .verifyResponse, .vouch: + // Group state travels only over mesh Noise sessions in v1; anything + // claiming to be group traffic over Nostr is ignored. + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch: break } } @@ -434,7 +438,9 @@ final class NostrInboundPipeline { context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID) case .readReceipt: context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) - case .verifyChallenge, .verifyResponse, .vouch: + // Group state travels only over mesh Noise sessions + // in v1; group traffic over Nostr is ignored. + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch: break } } diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index dd72d8b6..4d9d5c5b 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -221,6 +221,13 @@ private struct ContentPeopleListView: View { } ) } else { + GroupChatList( + groups: peerListModel.groupRows, + onTapGroup: { peerID in + peerListModel.startConversation(with: peerID) + showSidebar = true + } + ) MeshPeerList( onTapPeer: { peerID in peerListModel.startConversation(with: peerID) @@ -455,6 +462,10 @@ private struct ContentPrivateChatSheetView: View { } private var privacyCaptionText: String { + // Group chats are ChaCha20-Poly1305 sealed to the roster's shared key. + if privateConversationModel.selectedPeerID?.isGroup == true { + return String(localized: "content.private.caption_group", comment: "Caption above the group chat composer noting messages are encrypted to group members") + } // Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted, // even though they carry no Noise session status. Mesh DMs earn the // "encrypted" claim only once the Noise handshake has secured. @@ -503,30 +514,39 @@ private struct ContentPrivateHeaderInfoButton: View { var body: some View { Button(action: { + // A group has no single fingerprint to show. + guard !headerState.isGroupConversation else { return } appChromeModel.showFingerprint(for: headerState.headerPeerID) }) { HStack(spacing: 6) { - switch headerState.availability { - case .bluetoothConnected: - Image(systemName: "dot.radiowaves.left.and.right") + if headerState.isGroupConversation { + Image(systemName: "person.3.fill") .font(.bitchatSystem(size: 14)) .foregroundColor(palette.primary) - .accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")) - case .meshReachable: - Image(systemName: "point.3.filled.connected.trianglepath.dotted") - .font(.bitchatSystem(size: 14)) - .foregroundColor(palette.primary) - .accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")) - case .nostrAvailable: - Image(systemName: "globe") - .font(.bitchatSystem(size: 14)) - .foregroundColor(.purple) - .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")) - case .offline: - // Absence of a glyph was the only offline signal; say it. - Text("mesh_peers.state.offline") - .bitchatFont(size: 11) - .foregroundColor(palette.secondary) + .accessibilityLabel(String(localized: "content.accessibility.group_chat", comment: "Accessibility label for the group chat indicator")) + } else { + switch headerState.availability { + case .bluetoothConnected: + Image(systemName: "dot.radiowaves.left.and.right") + .font(.bitchatSystem(size: 14)) + .foregroundColor(palette.primary) + .accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")) + case .meshReachable: + Image(systemName: "point.3.filled.connected.trianglepath.dotted") + .font(.bitchatSystem(size: 14)) + .foregroundColor(palette.primary) + .accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")) + case .nostrAvailable: + Image(systemName: "globe") + .font(.bitchatSystem(size: 14)) + .foregroundColor(.purple) + .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")) + case .offline: + // Absence of a glyph was the only offline signal; say it. + Text("mesh_peers.state.offline") + .bitchatFont(size: 11) + .foregroundColor(palette.secondary) + } } Text(headerState.displayName) @@ -571,7 +591,9 @@ private struct ContentPrivateHeaderInfoButton: View { ) ) .accessibilityHint( - String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint") + headerState.isGroupConversation + ? "" + : String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint") ) .frame(minHeight: headerHeight) } diff --git a/bitchat/Views/GroupChatList.swift b/bitchat/Views/GroupChatList.swift new file mode 100644 index 00000000..8b62d8a2 --- /dev/null +++ b/bitchat/Views/GroupChatList.swift @@ -0,0 +1,86 @@ +import BitFoundation +import SwiftUI + +/// Compact "groups" section for the people sheet: one row per private group +/// this device belongs to, tappable to open the group chat window. +struct GroupChatList: View { + @ThemedPalette private var palette + + let groups: [GroupChatRow] + let onTapGroup: (PeerID) -> Void + + private enum Strings { + static let header = String(localized: "groups.section.header", comment: "Section header above the private groups list") + static let creator = String(localized: "groups.state.creator", comment: "State label for a group the user created") + static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages") + static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator") + static let openGroupHint = String(localized: "groups.accessibility.open_group_hint", comment: "Accessibility hint on a group row explaining activation opens the group chat") + static let memberCountFormat = String(localized: "groups.member_count %@", comment: "Member count shown next to a group name; placeholder is the count") + } + + var body: some View { + if !groups.isEmpty { + VStack(alignment: .leading, spacing: 0) { + Text(Strings.header) + .bitchatFont(size: 11, weight: .medium) + .foregroundColor(palette.secondary) + .padding(.horizontal) + .padding(.top, 10) + .padding(.bottom, 2) + .accessibilityAddTraits(.isHeader) + + ForEach(groups) { group in + HStack(spacing: 4) { + Image(systemName: "person.3.fill") + .font(.bitchatSystem(size: 10)) + .foregroundColor(palette.primary) + + Text("#\(group.name)") + .bitchatFont(size: 14) + .foregroundColor(palette.primary) + .lineLimit(1) + .truncationMode(.tail) + + Text(String(format: Strings.memberCountFormat, locale: .current, "\(group.memberCount)")) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + + if group.isCreator { + Image(systemName: "crown.fill") + .font(.bitchatSystem(size: 9)) + .foregroundColor(.yellow) + .help(Strings.creator) + } + + Spacer() + + if group.hasUnread { + Image(systemName: "envelope.fill") + .font(.bitchatSystem(size: 10)) + .foregroundColor(.orange) + .help(Strings.newMessagesTooltip) + } + } + .padding(.horizontal) + .padding(.vertical, 6) + .contentShape(Rectangle()) + .onTapGesture { onTapGroup(group.peerID) } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityDescription(for: group)) + .accessibilityAddTraits(.isButton) + .accessibilityHint(Strings.openGroupHint) + } + } + } + } + + private func accessibilityDescription(for group: GroupChatRow) -> String { + var parts: [String] = [ + group.name, + String(format: Strings.memberCountFormat, locale: .current, "\(group.memberCount)") + ] + if group.isCreator { parts.append(Strings.creator) } + if group.hasUnread { parts.append(Strings.unread) } + return parts.joined(separator: ", ") + } +} diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift index bd112e9b..b0d39782 100644 --- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift +++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift @@ -157,6 +157,18 @@ private final class MockChatTransportEventContext: ChatTransportEventContext { verifyResponsePayloads.append((peerID, payload)) } + // Group payloads + private(set) var groupInvitePayloads: [(peerID: PeerID, payload: Data)] = [] + private(set) var groupKeyUpdatePayloads: [(peerID: PeerID, payload: Data)] = [] + + func handleGroupInvitePayload(from peerID: PeerID, payload: Data) { + groupInvitePayloads.append((peerID, payload)) + } + + func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) { + groupKeyUpdatePayloads.append((peerID, payload)) + } + private(set) var vouchPayloads: [(peerID: PeerID, payload: Data)] = [] func handleVouchPayload(from peerID: PeerID, payload: Data) { diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index 718df1c8..3da82140 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -678,4 +678,32 @@ private final class MockCommandContextProvider: CommandContextProvider { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { favoriteNotifications.append((peerID, isFavorite)) } + + // Groups: record the parsed subcommand + argument the processor forwarded. + private(set) var groupCommands: [(subcommand: String, argument: String)] = [] + + func groupCreate(named name: String) -> CommandResult { + groupCommands.append(("create", name)) + return .handled + } + + func groupInvite(nickname: String) -> CommandResult { + groupCommands.append(("invite", nickname)) + return .handled + } + + func groupRemove(nickname: String) -> CommandResult { + groupCommands.append(("remove", nickname)) + return .handled + } + + func groupLeave() -> CommandResult { + groupCommands.append(("leave", "")) + return .handled + } + + func groupList() -> CommandResult { + groupCommands.append(("list", "")) + return .handled + } } diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift index 5f15e377..de1fa209 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -208,7 +208,10 @@ struct GossipSyncManagerTests { #expect(allTypes.contains(.fragment)) #expect(allTypes.contains(.fileTransfer)) #expect(allTypes.contains(.prekeyBundle)) - #expect(decoded.contains { $0.types == .publicMessages }) + #expect(allTypes.contains(.groupMessage)) + // The message schedule also asks for group messages (bit 10); + // responders that don't know the bit just ignore it. + #expect(decoded.contains { $0.types == SyncTypeFlags.publicMessages.union(.groupMessage) }) #expect(decoded.contains { $0.types == .fragment }) #expect(decoded.contains { $0.types == .fileTransfer }) #expect(decoded.contains { $0.types == .prekeyBundle }) diff --git a/bitchatTests/Services/GroupProtocolTests.swift b/bitchatTests/Services/GroupProtocolTests.swift new file mode 100644 index 00000000..f9f19e7f --- /dev/null +++ b/bitchatTests/Services/GroupProtocolTests.swift @@ -0,0 +1,374 @@ +// +// GroupProtocolTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import CryptoKit +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +struct GroupProtocolTests { + + // MARK: - Fixtures + + /// Deterministic member identity: an Ed25519 keypair plus the 64-hex + /// fingerprint the roster pins. + private struct TestIdentity { + let signingKey: Curve25519.Signing.PrivateKey + let fingerprint: String + + init(seed: UInt8) { + signingKey = Curve25519.Signing.PrivateKey() + fingerprint = Data(repeating: seed, count: 32).hexEncodedString() + } + + var member: GroupMember { + GroupMember( + fingerprint: fingerprint, + signingKey: signingKey.publicKey.rawRepresentation, + nickname: "peer-\(fingerprint.prefix(4))" + ) + } + + func sign(_ data: Data) -> Data? { + try? signingKey.signature(for: data) + } + } + + private let creator = TestIdentity(seed: 0xC1) + private let member = TestIdentity(seed: 0xA2) + private let outsider = TestIdentity(seed: 0xE3) + + private let groupID = Data((0..<16).map { UInt8($0) }) + private let key = Data(repeating: 0x42, count: 32) + + private func makeGroup(extraMembers: [GroupMember] = [], epoch: UInt32 = 1) -> BitchatGroup { + BitchatGroup( + groupID: groupID, + name: "trail crew", + epoch: epoch, + members: [creator.member, member.member] + extraMembers, + creatorFingerprint: creator.fingerprint + ) + } + + // MARK: - State payload (invite / key update) + + @Test func statePayloadRoundTripAndSignatureVerify() throws { + let group = makeGroup() + let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign)) + let encoded = try #require(payload.encode()) + + let decoded = try #require(GroupStatePayload.decode(encoded)) + #expect(decoded == payload) + #expect(decoded.groupID == groupID) + #expect(decoded.name == "trail crew") + #expect(decoded.key == key) + #expect(decoded.epoch == 1) + #expect(decoded.members == group.members) + #expect(decoded.creatorFingerprint == creator.fingerprint) + #expect(decoded.verifyCreatorSignature()) + #expect(decoded.asGroup == group) + } + + @Test func forgedCreatorSignatureIsRejected() throws { + let group = makeGroup() + // Signed by a member who is in the roster but is NOT the creator. + let forged = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: member.sign)) + #expect(!forged.verifyCreatorSignature()) + + // An outsider signing is equally rejected. + let outsiderForged = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: outsider.sign)) + #expect(!outsiderForged.verifyCreatorSignature()) + } + + @Test func tamperedStateFailsSignature() throws { + let group = makeGroup() + let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign)) + + // Bumping the epoch invalidates the signature. + let epochTampered = GroupStatePayload( + groupID: payload.groupID, + name: payload.name, + key: payload.key, + epoch: payload.epoch + 1, + members: payload.members, + creatorFingerprint: payload.creatorFingerprint, + signature: payload.signature + ) + #expect(!epochTampered.verifyCreatorSignature()) + + // So does swapping the key. + let keyTampered = GroupStatePayload( + groupID: payload.groupID, + name: payload.name, + key: Data(repeating: 0x99, count: 32), + epoch: payload.epoch, + members: payload.members, + creatorFingerprint: payload.creatorFingerprint, + signature: payload.signature + ) + #expect(!keyTampered.verifyCreatorSignature()) + + // And so does adding a member to the roster. + let rosterTampered = GroupStatePayload( + groupID: payload.groupID, + name: payload.name, + key: payload.key, + epoch: payload.epoch, + members: payload.members + [outsider.member], + creatorFingerprint: payload.creatorFingerprint, + signature: payload.signature + ) + #expect(!rosterTampered.verifyCreatorSignature()) + } + + @Test func creatorMissingFromRosterIsRejected() throws { + // State claiming a creator whose fingerprint is not in the roster has + // no key to verify against and must fail closed. + let group = BitchatGroup( + groupID: groupID, + name: "orphan", + epoch: 1, + members: [member.member], + creatorFingerprint: creator.fingerprint + ) + guard let rosterBlob = GroupRosterCoding.encode(group.members) else { + Issue.record("roster should encode") + return + } + let content = GroupStatePayload.signingContent(groupID: groupID, epoch: 1, key: key, rosterBlob: rosterBlob, name: group.name) + let payload = GroupStatePayload( + groupID: groupID, + name: group.name, + key: key, + epoch: 1, + members: group.members, + creatorFingerprint: creator.fingerprint, + signature: creator.sign(content) ?? Data() + ) + #expect(!payload.verifyCreatorSignature()) + } + + @Test func rosterCapIsEnforcedOnTheWire() { + // 17 members cannot be encoded (hard cap is 16)… + let seventeen = (0..<17).map { TestIdentity(seed: UInt8($0 + 1)).member } + #expect(GroupRosterCoding.encode(seventeen) == nil) + + // …and a hand-built blob claiming 17 members fails to decode. + let sixteen = (0..<16).map { TestIdentity(seed: UInt8($0 + 1)).member } + guard var blob = GroupRosterCoding.encode(sixteen) else { + Issue.record("16-member roster should encode") + return + } + #expect(GroupRosterCoding.decode(blob)?.count == 16) + blob[blob.startIndex] = 17 + #expect(GroupRosterCoding.decode(blob) == nil) + } + + // MARK: - Message seal / open + + @Test func messageRoundTrip() throws { + let group = makeGroup() + let timestampMs: UInt64 = 1_750_000_000_000 + let sealed = try GroupCrypto.sealMessage( + content: "summit at noon", + messageID: "msg-1", + senderNickname: "alice", + senderSigningKey: member.member.signingKey, + timestampMs: timestampMs, + groupID: groupID, + epoch: group.epoch, + key: key, + sign: member.sign + ) + + let envelope = try #require(GroupMessageEnvelope.decode(sealed)) + #expect(envelope.groupID == groupID) + #expect(envelope.epoch == group.epoch) + + let plaintext = try GroupCrypto.openMessage(envelope, key: key) + #expect(plaintext.messageID == "msg-1") + #expect(plaintext.content == "summit at noon") + #expect(plaintext.senderNickname == "alice") + #expect(plaintext.timestampMs == timestampMs) + #expect(plaintext.senderSigningKey == member.member.signingKey) + + // The roster resolves the sender; an outsider's key would not. + #expect(group.member(withSigningKey: plaintext.senderSigningKey) != nil) + #expect(group.member(withSigningKey: outsider.member.signingKey) == nil) + } + + @Test func wrongKeyFailsToDecrypt() throws { + let sealed = try GroupCrypto.sealMessage( + content: "hi", + messageID: "msg-2", + senderNickname: "alice", + senderSigningKey: member.member.signingKey, + timestampMs: 1, + groupID: groupID, + epoch: 1, + key: key, + sign: member.sign + ) + let envelope = try #require(GroupMessageEnvelope.decode(sealed)) + #expect(throws: GroupCryptoError.decryptionFailed) { + _ = try GroupCrypto.openMessage(envelope, key: Data(repeating: 0x7F, count: 32)) + } + } + + @Test func epochIsBoundIntoTheCiphertext() throws { + // Re-labeling an epoch-1 envelope as epoch 2 must break the AEAD: + // a rotated-out member cannot replay old ciphertext into a new epoch. + let sealed = try GroupCrypto.sealMessage( + content: "hi", + messageID: "msg-3", + senderNickname: "alice", + senderSigningKey: member.member.signingKey, + timestampMs: 1, + groupID: groupID, + epoch: 1, + key: key, + sign: member.sign + ) + let envelope = try #require(GroupMessageEnvelope.decode(sealed)) + let relabeled = GroupMessageEnvelope( + groupID: envelope.groupID, + epoch: 2, + nonce: envelope.nonce, + ciphertext: envelope.ciphertext + ) + #expect(throws: GroupCryptoError.decryptionFailed) { + _ = try GroupCrypto.openMessage(relabeled, key: key) + } + } + + @Test func badSenderSignatureIsRejected() throws { + // A key-holder who signs with a key other than the one they claim + // (or garbage) is dropped even though decryption succeeds. + let sealed = try GroupCrypto.sealMessage( + content: "spoof", + messageID: "msg-4", + senderNickname: "mallory", + senderSigningKey: member.member.signingKey, // claims member's key… + timestampMs: 1, + groupID: groupID, + epoch: 1, + key: key, + sign: outsider.sign // …but signs with the outsider's + ) + let envelope = try #require(GroupMessageEnvelope.decode(sealed)) + #expect(throws: GroupCryptoError.badSenderSignature) { + _ = try GroupCrypto.openMessage(envelope, key: key) + } + } + + @Test func tamperedCiphertextFailsToOpen() throws { + let sealed = try GroupCrypto.sealMessage( + content: "hi", + messageID: "msg-5", + senderNickname: "alice", + senderSigningKey: member.member.signingKey, + timestampMs: 1, + groupID: groupID, + epoch: 1, + key: key, + sign: member.sign + ) + let envelope = try #require(GroupMessageEnvelope.decode(sealed)) + var flipped = envelope.ciphertext + flipped[flipped.startIndex] ^= 0x01 + let tampered = GroupMessageEnvelope( + groupID: envelope.groupID, + epoch: envelope.epoch, + nonce: envelope.nonce, + ciphertext: flipped + ) + #expect(throws: GroupCryptoError.decryptionFailed) { + _ = try GroupCrypto.openMessage(tampered, key: key) + } + } + + @Test func malformedEnvelopesAreRejected() { + #expect(GroupMessageEnvelope.decode(Data()) == nil) + #expect(GroupMessageEnvelope.decode(Data([0x01, 0x00])) == nil) + #expect(GroupStatePayload.decode(Data([0xFF, 0x00, 0x01])) == nil) + } + + // MARK: - Oversize / UTF-8 safety (Codex findings) + + @Test func oversizeMessageContentFailsToSealInsteadOfTruncating() { + // A content whose UTF-8 exceeds the 16-bit TLV length must fail to + // seal (surfacing send_failed) rather than silently truncate into a + // ciphertext recipients would drop. + let oversize = String(repeating: "a", count: 70_000) + #expect(throws: (any Error).self) { + _ = try GroupCrypto.sealMessage( + content: oversize, + messageID: "big", + senderNickname: "alice", + senderSigningKey: member.member.signingKey, + timestampMs: 1, + groupID: groupID, + epoch: 1, + key: key, + sign: member.sign + ) + } + } + + @Test func multiByteNicknameTruncatesOnScalarBoundary() throws { + // 40 euro signs = 120 UTF-8 bytes; a raw 64-byte prefix would split + // the 21st scalar and make the roster undecodable. Truncation must + // land on a Character boundary so the blob round-trips. + let euros = String(repeating: "€", count: 40) + let wide = GroupMember( + fingerprint: creator.fingerprint, + signingKey: creator.member.signingKey, + nickname: euros + ) + let blob = try #require(GroupRosterCoding.encode([wide])) + let decoded = try #require(GroupRosterCoding.decode(blob)) + #expect(decoded.count == 1) + #expect(Data(decoded[0].nickname.utf8).count <= 64) + #expect(decoded[0].nickname.allSatisfy { $0 == "€" }) + #expect(!decoded[0].nickname.isEmpty) + } + + // MARK: - Signable-bytes forward-proofing + + @Test func creatorSignatureCoversName() throws { + let group = makeGroup() + let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign)) + #expect(payload.verifyCreatorSignature()) + + // Swapping only the display name must invalidate the creator signature. + let renamed = GroupStatePayload( + groupID: payload.groupID, + name: "totally different name", + key: payload.key, + epoch: payload.epoch, + members: payload.members, + creatorFingerprint: payload.creatorFingerprint, + signature: payload.signature + ) + #expect(!renamed.verifyCreatorSignature()) + } + + @Test func messageSignatureCoversEpoch() { + // The signed bytes differ by epoch, so a signature captured at one + // epoch cannot verify when re-sealed under a later epoch key. + let atEpoch1 = GroupCrypto.messageSigningContent( + groupID: groupID, epoch: 1, messageID: "m", timestampMs: 1, content: "x" + ) + let atEpoch2 = GroupCrypto.messageSigningContent( + groupID: groupID, epoch: 2, messageID: "m", timestampMs: 1, content: "x" + ) + #expect(atEpoch1 != atEpoch2) + } +} diff --git a/bitchatTests/Services/GroupStoreTests.swift b/bitchatTests/Services/GroupStoreTests.swift new file mode 100644 index 00000000..adf04806 --- /dev/null +++ b/bitchatTests/Services/GroupStoreTests.swift @@ -0,0 +1,170 @@ +// +// GroupStoreTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +@MainActor +struct GroupStoreTests { + + private func makeMember(seed: UInt8, nickname: String = "peer") -> GroupMember { + GroupMember( + fingerprint: Data(repeating: seed, count: 32).hexEncodedString(), + signingKey: Data(repeating: seed &+ 1, count: 32), + nickname: nickname + ) + } + + private func tempFileURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("group-store-tests-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("groups.json") + } + + // MARK: - Create / read + + @Test func createGroupStoresMetadataAndKey() throws { + let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) + let creator = makeMember(seed: 0xC1, nickname: "me") + + let group = try #require(store.createGroup(named: "ops", creator: creator)) + #expect(group.groupID.count == BitchatGroup.groupIDLength) + #expect(group.epoch == 1) + #expect(group.members == [creator]) + #expect(group.creatorFingerprint == creator.fingerprint) + + #expect(store.group(withID: group.groupID) == group) + #expect(store.group(for: group.peerID) == group) + let key = try #require(store.key(forGroupID: group.groupID)) + #expect(key.count == BitchatGroup.keyLength) + #expect(group.peerID.isGroup) + #expect(group.peerID.groupIDData == group.groupID) + } + + // MARK: - Roster cap + + @Test func rosterCapIsEnforced() throws { + let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) + let creator = makeMember(seed: 0xC1) + let group = try #require(store.createGroup(named: "big", creator: creator)) + + // Filling to the cap works… + let fifteen = (1...15).map { makeMember(seed: UInt8($0)) } + #expect(store.updateRoster(groupID: group.groupID, members: [creator] + fifteen) != nil) + #expect(store.group(withID: group.groupID)?.members.count == BitchatGroup.maxMembers) + + // …one more is rejected. + let overflow = [creator] + fifteen + [makeMember(seed: 0x99)] + #expect(store.updateRoster(groupID: group.groupID, members: overflow) == nil) + #expect(store.group(withID: group.groupID)?.members.count == BitchatGroup.maxMembers) + + // Direct upsert past the cap is rejected too. + var oversized = group + oversized.members = overflow + #expect(!store.upsert(oversized, key: Data(repeating: 1, count: 32))) + } + + @Test func rosterMustRetainCreator() throws { + let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) + let creator = makeMember(seed: 0xC1) + let other = makeMember(seed: 0xA1) + let group = try #require(store.createGroup(named: "crew", creator: creator)) + + #expect(store.updateRoster(groupID: group.groupID, members: [other]) == nil) + #expect(store.group(withID: group.groupID)?.members == [creator]) + } + + // MARK: - Rotation + + @Test func rotateKeyBumpsEpochAndReplacesKey() throws { + let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) + let creator = makeMember(seed: 0xC1) + let removed = makeMember(seed: 0xA1) + let group = try #require(store.createGroup(named: "crew", creator: creator)) + #expect(store.updateRoster(groupID: group.groupID, members: [creator, removed]) != nil) + let oldKey = try #require(store.key(forGroupID: group.groupID)) + + let rotation = try #require(store.rotateKey(groupID: group.groupID, members: [creator])) + #expect(rotation.group.epoch == 2) + #expect(rotation.group.members == [creator]) + #expect(rotation.key != oldKey) + #expect(store.key(forGroupID: group.groupID) == rotation.key) + #expect(store.group(withID: group.groupID)?.epoch == 2) + } + + // MARK: - Persistence + + @Test func persistsAcrossInstances() throws { + let keychain = MockKeychain() + let fileURL = tempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let creator = makeMember(seed: 0xC1, nickname: "me") + let group: BitchatGroup + do { + let store = GroupStore(keychain: keychain, fileURL: fileURL) + group = try #require(store.createGroup(named: "hike", creator: creator)) + } + + let reloaded = GroupStore(keychain: keychain, fileURL: fileURL) + #expect(reloaded.groups == [group]) + #expect(reloaded.key(forGroupID: group.groupID) != nil) + } + + @Test func groupsWithoutKeysAreDroppedOnLoad() throws { + let keychain = MockKeychain() + let fileURL = tempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let group: BitchatGroup + do { + let store = GroupStore(keychain: keychain, fileURL: fileURL) + group = try #require(store.createGroup(named: "stale", creator: makeMember(seed: 0xC1))) + } + // Simulate a keychain wipe without the metadata file being removed. + _ = keychain.deleteAllKeychainData() + + let reloaded = GroupStore(keychain: keychain, fileURL: fileURL) + #expect(reloaded.groups.isEmpty) + #expect(reloaded.group(withID: group.groupID) == nil) + } + + // MARK: - Panic wipe + + @Test func wipeRemovesMetadataAndKeys() throws { + let keychain = MockKeychain() + let fileURL = tempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let store = GroupStore(keychain: keychain, fileURL: fileURL) + let group = try #require(store.createGroup(named: "gone", creator: makeMember(seed: 0xC1))) + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + + store.wipe() + + #expect(store.groups.isEmpty) + #expect(store.key(forGroupID: group.groupID) == nil) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + + // A fresh instance sees nothing either. + let reloaded = GroupStore(keychain: keychain, fileURL: fileURL) + #expect(reloaded.groups.isEmpty) + } + + @Test func removeGroupDeletesItsKey() throws { + let keychain = MockKeychain() + let store = GroupStore(keychain: keychain, persistsToDisk: false) + let group = try #require(store.createGroup(named: "bye", creator: makeMember(seed: 0xC1))) + + store.removeGroup(withID: group.groupID) + #expect(store.groups.isEmpty) + #expect(store.key(forGroupID: group.groupID) == nil) + } +} diff --git a/bitchatTests/Services/MeshDiagnosticsTests.swift b/bitchatTests/Services/MeshDiagnosticsTests.swift index ced8a92e..b565840d 100644 --- a/bitchatTests/Services/MeshDiagnosticsTests.swift +++ b/bitchatTests/Services/MeshDiagnosticsTests.swift @@ -277,6 +277,11 @@ private final class DiagnosticsMockContext: CommandContextProvider { func clearPrivateChat(_ peerID: PeerID) {} func sendPublicRaw(_ content: String) {} func sendPublicMessage(_ content: String) {} + func groupCreate(named name: String) -> CommandResult { .handled } + func groupInvite(nickname: String) -> CommandResult { .handled } + func groupRemove(nickname: String) -> CommandResult { .handled } + func groupLeave() -> CommandResult { .handled } + func groupList() -> CommandResult { .handled } func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {} func addPublicSystemMessage(_ content: String) {} func toggleFavorite(peerID: PeerID) {} diff --git a/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift b/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift index 75cbbd54..829bfd4d 100644 --- a/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift +++ b/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift @@ -38,19 +38,19 @@ struct SyncTypeFlagsBoardTests { /// decode path accepts the bytes and simply maps unknown bits to no /// message type, so a board-only request reads as "nothing I can serve". @Test func unknownBitsDecodeToNoTypes() throws { - // Bits 10-15 are unassigned (bit 8 = board, bit 9 = prekeyBundle); a - // future (or unknown) two-byte bitfield must decode without error and - // yield no known types. - let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xFC]))) + // Bits 11-15 are unassigned (bit 8 = board, bit 9 = prekeyBundle, + // bit 10 = groupMessage); a future (or unknown) two-byte bitfield must + // decode without error and yield no known types. + let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xF8]))) #expect(decoded.toMessageTypes().isEmpty) - for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost, .prekeyBundle] { + for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost, .prekeyBundle, .groupMessage] { #expect(!decoded.contains(type)) } } @Test func mixedKnownAndUnknownBitsKeepKnownTypes() throws { - // Known low-byte flags survive alongside unknown high bits (10-15). - let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xFC]))) + // Known low-byte flags survive alongside unknown high bits (11-15). + let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xF8]))) #expect(decoded.contains(.announce)) #expect(decoded.contains(.message)) #expect(Set(decoded.toMessageTypes()) == Set([.announce, .message])) diff --git a/bitchatTests/Sync/SyncTypeFlagsGroupTests.swift b/bitchatTests/Sync/SyncTypeFlagsGroupTests.swift new file mode 100644 index 00000000..2bd9d53b --- /dev/null +++ b/bitchatTests/Sync/SyncTypeFlagsGroupTests.swift @@ -0,0 +1,62 @@ +// +// SyncTypeFlagsGroupTests.swift +// bitchat +// +// Wire-compat proof for the groupMessage sync bit (bit 10): the types +// bitfield widens from 1 to 2 bytes, and clients that don't know the bit +// simply ignore it. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +struct SyncTypeFlagsGroupTests { + + @Test func groupMessageOccupiesBitTen() { + #expect(SyncTypeFlags.groupMessage.rawValue == 1 << 10) + #expect(SyncTypeFlags.groupMessage.contains(.groupMessage)) + #expect(!SyncTypeFlags.publicMessages.contains(.groupMessage)) + } + + @Test func extendedBitfieldWidensToTwoBytes() throws { + // Legacy flags fit one byte… + #expect(SyncTypeFlags.publicMessages.toData() == Data([0x03])) + + // …the group bit widens the little-endian encoding to two bytes. + let combined = SyncTypeFlags.publicMessages.union(.groupMessage) + let encoded = try #require(combined.toData()) + #expect(encoded == Data([0x03, 0x04])) + + let decoded = try #require(SyncTypeFlags.decode(encoded)) + #expect(decoded == combined) + #expect(Set(decoded.toMessageTypes()) == Set([.announce, .message, .groupMessage])) + } + + @Test func unknownBitsAreIgnoredNotRejected() throws { + // An "old client" reading a 2-byte field keeps the raw bits but maps + // unknown bit indices to no message type — it answers with the types + // it knows instead of dropping the request. + let futuristic = try #require(SyncTypeFlags.decode(Data([0x03, 0xFC]))) + #expect(Set(futuristic.toMessageTypes()) == Set([.announce, .message, .groupMessage])) + #expect(futuristic.contains(.announce)) + #expect(futuristic.contains(.message)) + } + + @Test func requestSyncPacketRoundTripsGroupFlag() throws { + let types = SyncTypeFlags.publicMessages.union(.groupMessage) + let packet = RequestSyncPacket(p: 8, m: 1024, data: Data([0xAB, 0xCD]), types: types) + let encoded = packet.encode() + + let decoded = try #require(RequestSyncPacket.decode(from: encoded)) + #expect(decoded.types == types) + #expect(decoded.types?.contains(.groupMessage) == true) + #expect(decoded.p == 8) + #expect(decoded.m == 1024) + #expect(decoded.data == Data([0xAB, 0xCD])) + } +} diff --git a/bitchatTests/Sync/SyncTypeFlagsTests.swift b/bitchatTests/Sync/SyncTypeFlagsTests.swift index 45dee06a..8c400119 100644 --- a/bitchatTests/Sync/SyncTypeFlagsTests.swift +++ b/bitchatTests/Sync/SyncTypeFlagsTests.swift @@ -13,9 +13,10 @@ struct SyncTypeFlagsTests { } @Test func decodeDropsPhantomBits() { - // Bits 10+ map to no message type (bit 8 = boardPost, bit 9 = - // prekeyBundle). They must not survive decode as phantom membership. - let phantom = Data([0x00, 0xFC]) // bits 10..15 set, no known type + // Bits 11+ map to no message type (bit 8 = boardPost, bit 9 = + // prekeyBundle, bit 10 = groupMessage). They must not survive decode + // as phantom membership. + let phantom = Data([0x00, 0xF8]) // bits 11..15 set, no known type let decoded = SyncTypeFlags.decode(phantom) #expect(decoded?.rawValue == 0) #expect(decoded?.toMessageTypes().isEmpty == true) @@ -23,17 +24,18 @@ struct SyncTypeFlagsTests { @Test func boardBitSurvivesDecode() { // Bit 8 maps to boardPost and spills the field into a second byte; - // it must survive decode while the phantom high bits (10+) are - // stripped. Bit 9 (prekeyBundle) is cleared to isolate the board bit. - let mixed = Data([0x00, 0xFD]) // bit 8 (board) known, bits 10..15 phantom + // it must survive decode while the phantom high bits (11+) are + // stripped. Bits 9 (prekeyBundle) and 10 (groupMessage) are cleared + // to isolate the board bit. + let mixed = Data([0x00, 0xF9]) // bit 8 (board) known, bits 11..15 phantom let decoded = SyncTypeFlags.decode(mixed) #expect(decoded?.contains(.board) == true) #expect(decoded?.rawValue == 0b1_0000_0000) } @Test func phantomBitsAreStrippedButKnownBitsSurvive() { - // Low byte = announce(0) + message(1); high byte bits 10+ are phantom. - let mixed = Data([0b0000_0011, 0xFC]) + // Low byte = announce(0) + message(1); high byte bits 11+ are phantom. + let mixed = Data([0b0000_0011, 0xF8]) let decoded = SyncTypeFlags.decode(mixed) #expect(decoded?.contains(.announce) == true) #expect(decoded?.contains(.message) == true) diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift index 47f46b85..4ddf5ab1 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift @@ -26,6 +26,7 @@ public enum MessageType: UInt8 { case fileTransfer = 0x22 // Binary file/audio/image payloads case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped) + case groupMessage = 0x25 // Group-encrypted broadcast (cleartext group ID, ChaChaPoly body) // Mesh diagnostics case ping = 0x26 // Directed echo request (nonce + origin TTL) @@ -48,6 +49,7 @@ public enum MessageType: UInt8 { case .fileTransfer: return "fileTransfer" case .boardPost: return "boardPost" case .prekeyBundle: return "prekeyBundle" + case .groupMessage: return "groupMessage" case .ping: return "ping" case .pong: return "pong" case .nostrCarrier: return "nostrCarrier" diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift index 0b2721c2..f22fac18 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift @@ -34,6 +34,9 @@ public struct PeerID: Equatable, Hashable, Sendable { case geoDM = "nostr_" /// `"nostr:"` (+ 8 characters hex) case geoChat = "nostr:" + /// `"group_"` (+ 32 characters hex) — virtual conversation ID for a + /// private group (16-byte group ID). Never routed to a single peer. + case group = "group_" } public let prefix: Prefix @@ -96,6 +99,22 @@ public extension PeerID { } } +// MARK: - Group Conversation Helpers + +public extension PeerID { + /// Convenience init to create a virtual group conversation PeerID from a + /// 16-byte group ID ("group_" + 32 hex characters). + init(groupID: Data) { + self.init(str: Prefix.group.rawValue + groupID.hexEncodedString()) + } + + /// The 16-byte group ID behind a "group_" PeerID, if this is one. + var groupIDData: Data? { + guard isGroup, bare.count == 32 else { return nil } + return Data(hexString: bare) + } +} + // MARK: - Noise Public Key Helpers public extension PeerID { @@ -143,6 +162,11 @@ public extension PeerID { prefix == .geoDM } + /// Returns true if `id` starts with "`group_`" + var isGroup: Bool { + prefix == .group + } + func toPercentEncoded() -> String { id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id }