From 7ad19ab4c3b900cb0b874e1d1ff624bb05be8ae8 Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 6 Jul 2026 20:22:38 +0200 Subject: [PATCH] Private groups: creator-managed encrypted group chat over the mesh Small encrypted crews (hard cap 16) between public broadcast and 1:1 DMs: Protocol - MessageType.groupMessage = 0x25: broadcast packets with a cleartext 16-byte group ID + epoch, ChaCha20-Poly1305 ciphertext (epoch bound as AEAD AAD), inner Ed25519 sender signature over "bitchat-group-msg-v1"|groupID|messageID|timestamp|content - NoisePayloadType.groupInvite = 0x06 / .groupKeyUpdate = 0x07: creator-signed group state (key, epoch, roster) 1:1 over Noise; signature over "bitchat-group-v1"|groupID|epoch|key-hash|roster-hash and the Noise session peer must BE the creator - SyncTypeFlags bit 10 (groupMessage): variable-length LE bitfield widens 1 -> 2 bytes inside the length-prefixed REQUEST_SYNC TLV; old clients ignore unknown bits and answer with types they know - PeerCapabilities.localSupported now advertises .groups Storage - GroupStore: symmetric keys in the keychain, roster/name/epoch as protected JSON in Application Support; wiped in panicClearAllData() Behavior - Non-members relay 0x25 like any broadcast but cannot read it; group messages join gossip-sync backfill with the public-message window - Receivers drop wrong-epoch envelopes, bad sender signatures, and senders missing from the creator-signed roster - Fire-and-flood delivery (no per-member acks in v1) UI - Groups open as chat windows through the private-chat sheet (virtual "group_" peer IDs); groups section in the people sheet; /group create/invite/remove/leave/list commands; invitees get a system message + notification and the group appears in their people sheet Co-Authored-By: Claude Fable 5 --- bitchat/App/PeerListModel.swift | 36 ++ bitchat/App/PrivateConversationModels.swift | 35 +- bitchat/Localizable.xcstrings | 385 ++++++++++++ bitchat/Models/CommandInfo.swift | 10 +- bitchat/Protocols/BitchatProtocol.swift | 14 +- .../Protocols/PeerCapabilities+Local.swift | 2 +- .../BLE/BLEOutboundPacketPolicy.swift | 2 +- bitchat/Services/BLE/BLEService.swift | 65 ++ bitchat/Services/CommandProcessor.swift | 41 ++ bitchat/Services/Groups/GroupProtocol.swift | 532 ++++++++++++++++ bitchat/Services/Groups/GroupStore.swift | 194 ++++++ bitchat/Services/Transport.swift | 14 + bitchat/Sync/GossipSyncManager.swift | 45 +- bitchat/Sync/SyncTypeFlags.swift | 10 + bitchat/ViewModels/ChatGroupCoordinator.swift | 566 ++++++++++++++++++ .../ChatPeerIdentityCoordinator.swift | 9 + .../ChatTransportEventCoordinator.swift | 18 + bitchat/ViewModels/ChatViewModel.swift | 13 + .../ChatViewModel+PrivateChat.swift | 6 + bitchat/ViewModels/NostrInboundPipeline.swift | 12 +- bitchat/Views/ContentSheetViews.swift | 62 +- bitchat/Views/GroupChatList.swift | 86 +++ ...ransportEventCoordinatorContextTests.swift | 12 + bitchatTests/CommandProcessorTests.swift | 28 + bitchatTests/GossipSyncManagerTests.swift | 4 +- .../Services/GroupProtocolTests.swift | 302 ++++++++++ bitchatTests/Services/GroupStoreTests.swift | 170 ++++++ .../Sync/SyncTypeFlagsGroupTests.swift | 62 ++ .../Sources/BitFoundation/MessageType.swift | 6 +- .../Sources/BitFoundation/PeerID.swift | 24 + 30 files changed, 2731 insertions(+), 34 deletions(-) create mode 100644 bitchat/Services/Groups/GroupProtocol.swift create mode 100644 bitchat/Services/Groups/GroupStore.swift create mode 100644 bitchat/ViewModels/ChatGroupCoordinator.swift create mode 100644 bitchat/Views/GroupChatList.swift create mode 100644 bitchatTests/Services/GroupProtocolTests.swift create mode 100644 bitchatTests/Services/GroupStoreTests.swift create mode 100644 bitchatTests/Sync/SyncTypeFlagsGroupTests.swift diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index 38f20a1c..e6595428 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -26,11 +26,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 @@ -129,6 +140,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 @@ -217,22 +235,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 7301e613..208c8d49 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -9348,6 +9348,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", @@ -15055,6 +15066,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", @@ -18230,6 +18252,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", @@ -19913,6 +19946,17 @@ } } }, + "content.private.caption_group" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "encrypted group · members only" + } + } + } + }, "encryption.accessibility.establishing" : { "extractionState" : "manual", "localizations" : { @@ -24424,6 +24468,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", @@ -33893,6 +33981,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 1f8245be..b75d3db0 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" @@ -33,6 +34,8 @@ enum CommandInfo: String, Identifiable { switch self { case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite: return "<" + String(localized: "content.input.nickname_placeholder") + ">" + case .group: + return "<" + String(localized: "content.input.group_placeholder") + ">" case .clear, .help, .who: return nil } @@ -42,6 +45,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") @@ -55,11 +59,11 @@ enum CommandInfo: String, Identifiable { static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] { let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who] - // The processor rejects favorites in geohash contexts, so only - // suggest them where they actually work: mesh. + // The processor rejects favorites and groups in geohash contexts, so + // only suggest them where they actually work: mesh. if isGeoPublic || isGeoDM { return baseCommands } - return baseCommands + [.favorite, .unfavorite] + return baseCommands + [.favorite, .unfavorite, .group] } } diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 4ebe6509..65963af5 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -72,15 +72,20 @@ 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 - + var description: String { switch self { 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" } @@ -114,6 +119,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?) @@ -133,6 +141,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 40f97b9f..3bfb0599 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 = [] + static let localSupported: PeerCapabilities = [.groups] } diff --git a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift index 18b144d2..cf9e9c9d 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: + case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .groupMessage: return false } } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index d1ed84f2..dbe5c1dc 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -1307,6 +1307,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) @@ -3210,6 +3252,9 @@ extension BLEService { case .courierEnvelope: handleCourierEnvelope(packet, from: peerID) + case .groupMessage: + handleGroupMessage(packet, from: senderID) + case .leave: handleLeave(packet, from: senderID) @@ -3466,6 +3511,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 9f2f8e9b..683fed50 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -54,6 +54,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 @@ -100,6 +109,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) @@ -125,6 +137,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 /help — this list """ @@ -331,6 +346,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) + } + } + private func handleFavorite(_ args: String, add: Bool) -> CommandResult { let targetName = args.trimmed guard !targetName.isEmpty else { diff --git a/bitchat/Services/Groups/GroupProtocol.swift b/bitchat/Services/Groups/GroupProtocol.swift new file mode 100644 index 00000000..14f3d7f3 --- /dev/null +++ b/bitchat/Services/Groups/GroupProtocol.swift @@ -0,0 +1,532 @@ +// +// 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 + +private enum GroupTLV { + static func put(_ type: UInt8, _ value: Data, into out: inout Data) { + out.append(type) + let length = UInt16(clamping: value.count) + out.append(UInt8((length >> 8) & 0xFF)) + out.append(UInt8(length & 0xFF)) + out.append(value.prefix(Int(length))) + } + + /// 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) + let nickname = Data(member.nickname.utf8).prefix(maxNicknameBytes) + 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 content = signingDomain + content.append(groupID) + content.append(GroupTLV.epochData(epoch)) + content.append(key.sha256Hash()) + content.append(rosterBlob.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) + 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() + GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out) + GroupTLV.put(FieldType.name.rawValue, Data(name.utf8), into: &out) + GroupTLV.put(FieldType.key.rawValue, key, into: &out) + GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out) + GroupTLV.put(FieldType.roster.rawValue, rosterBlob, into: &out) + GroupTLV.put(FieldType.creatorFingerprint.rawValue, fingerprintData, into: &out) + GroupTLV.put(FieldType.signature.rawValue, signature, into: &out) + 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) + 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() -> Data { + var out = Data() + GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out) + GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out) + GroupTLV.put(FieldType.nonce.rawValue, nonce, into: &out) + 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 | messageID | timestamp | content. + static func messageSigningContent(groupID: Data, messageID: String, timestampMs: UInt64, content: String) -> Data { + var data = messageSigningDomain + data.append(groupID) + 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, + messageID: messageID, + timestampMs: timestampMs, + content: content + ) + guard let signature = sign(signingContent), signature.count == 64 else { + throw GroupCryptoError.signingFailed + } + + var inner = Data() + GroupTLV.put(InnerField.messageID.rawValue, Data(messageID.utf8), into: &inner) + GroupTLV.put(InnerField.senderSigningKey.rawValue, senderSigningKey, into: &inner) + GroupTLV.put(InnerField.senderNickname.rawValue, Data(senderNickname.utf8), into: &inner) + GroupTLV.put(InnerField.timestamp.rawValue, GroupTLV.timestampData(timestampMs), into: &inner) + GroupTLV.put(InnerField.content.rawValue, Data(content.utf8), into: &inner) + 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 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, + 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 27c38700..67bba52e 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -35,6 +35,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]) @@ -124,6 +127,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) + // QR verification (optional for transports) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) @@ -153,6 +162,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 sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false } func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} @@ -186,6 +198,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 c966a7ca..b5075906 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 @@ -90,6 +91,7 @@ final class GossipSyncManager { private var messages = PacketStore() private var fragments = PacketStore() private var fileTransfers = PacketStore() + private var groupMessages = PacketStore() private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:] private var archiveDirty = false @@ -111,7 +113,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)) @@ -149,6 +157,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) } @@ -169,7 +180,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 { - let maxAgeSeconds = packet.type == MessageType.message.rawValue + // Group messages share the whole-message window: members off the mesh + // for a while should backfill their crew's history like public chat. + let usesLongWindow = packet.type == MessageType.message.rawValue + || packet.type == MessageType.groupMessage.rawValue + let maxAgeSeconds = usesLongWindow ? config.publicMessageMaxAgeSeconds : config.maxMessageAgeSeconds let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) @@ -225,6 +240,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)) default: break } @@ -366,6 +388,20 @@ 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) + } + } + } } // Build REQUEST_SYNC payload using current candidates and GCS params @@ -385,6 +421,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 candidates.isEmpty { let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr) let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types) @@ -442,6 +481,7 @@ final class GossipSyncManager { } fragments.removeExpired(isFresh: isPacketFresh) fileTransfers.removeExpired(isFresh: isPacketFresh) + groupMessages.removeExpired(isFresh: isPacketFresh) } // MARK: - Archive (public message persistence) @@ -537,6 +577,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 430485a0..7f689396 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -20,6 +20,14 @@ struct SyncTypeFlags: OptionSet { case .fragment: return 5 case .requestSync: return 6 case .fileTransfer: return 7 + // Bits 8/9 are reserved by other in-flight features. + // 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 @@ -36,6 +44,7 @@ struct SyncTypeFlags: OptionSet { case 5: return .fragment case 6: return .requestSync case 7: return .fileTransfer + case 10: return .groupMessage default: return nil } @@ -45,6 +54,7 @@ struct SyncTypeFlags: OptionSet { static let message = SyncTypeFlags(messageTypes: [.message]) static let fragment = SyncTypeFlags(messageTypes: [.fragment]) static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer]) + 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..fe576132 --- /dev/null +++ b/bitchat/ViewModels/ChatGroupCoordinator.swift @@ -0,0 +1,566 @@ +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? + + // 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 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 + ) + let members = group.members + [newMember] + guard let updated = context.groupStore.updateRoster(groupID: group.groupID, members: members), + let key = context.groupStore.key(forGroupID: group.groupID), + 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) + + 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 } + + 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) + } + } + } + + 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/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 75571b2e..bdd696e9 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) } extension ChatViewModel: ChatTransportEventContext { @@ -129,6 +133,14 @@ extension ChatViewModel: ChatTransportEventContext { func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) { 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) + } } final class ChatTransportEventCoordinator { @@ -371,6 +383,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) } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 578b6509..646e04bc 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -177,6 +177,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) // Computed properties for compatibility @MainActor @@ -305,6 +306,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 { @@ -809,6 +812,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele ) self.keychain = keychain + self.groupStore = GroupStore(keychain: keychain) self.idBridge = idBridge self.identityManager = identityManager self.conversations = conversations @@ -1205,6 +1209,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele GossipMessageArchive.wipeDefault() StoreAndForwardMetrics.shared.reset() + // Drop private group keys and rosters (keychain + disk) + groupStore.wipe() + // Identity manager has cleared persisted identity data above // Clear autocomplete state @@ -1555,6 +1562,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 337fa474..acb14b20 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -297,7 +297,9 @@ final class NostrInboundPipeline { context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey) case .readReceipt: context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey) - case .verifyChallenge, .verifyResponse: + // 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: break } } @@ -349,7 +351,9 @@ final class NostrInboundPipeline { context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) case .readReceipt: context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) - case .verifyChallenge, .verifyResponse: + // 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: break } } @@ -428,7 +432,9 @@ final class NostrInboundPipeline { context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID) case .readReceipt: context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) - case .verifyChallenge, .verifyResponse: + // Group state travels only over mesh Noise sessions + // in v1; group traffic over Nostr is ignored. + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate: 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 1c1fc46f..2ba7fc2a 100644 --- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift +++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift @@ -156,6 +156,18 @@ private final class MockChatTransportEventContext: ChatTransportEventContext { func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) { 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)) + } } // MARK: - Helpers diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index 99bed2b5..7ddede02 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -492,4 +492,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 c44de8f6..89054302 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -205,7 +205,9 @@ struct GossipSyncManagerTests { #expect(allTypes.contains(.message)) #expect(allTypes.contains(.fragment)) #expect(allTypes.contains(.fileTransfer)) - #expect(decoded.contains { $0.types == .publicMessages }) + // 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 }) } diff --git a/bitchatTests/Services/GroupProtocolTests.swift b/bitchatTests/Services/GroupProtocolTests.swift new file mode 100644 index 00000000..a99c48a9 --- /dev/null +++ b/bitchatTests/Services/GroupProtocolTests.swift @@ -0,0 +1,302 @@ +// +// 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) + 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) + } +} 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/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/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift index ceac0cd0..fd2d3a79 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift @@ -24,7 +24,10 @@ public enum MessageType: UInt8 { // Fragmentation (simplified) case fragment = 0x20 // Single fragment type for large messages case fileTransfer = 0x22 // Binary file/audio/image payloads - + + // Private groups (0x23/0x24/0x26/0x27/0x28 reserved by other features) + case groupMessage = 0x25 // Group-encrypted broadcast (cleartext group ID, ChaChaPoly body) + public var description: String { switch self { case .announce: return "announce" @@ -36,6 +39,7 @@ public enum MessageType: UInt8 { case .noiseEncrypted: return "noiseEncrypted" case .fragment: return "fragment" case .fileTransfer: return "fileTransfer" + case .groupMessage: return "groupMessage" } } } 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 }