mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +00:00
Merge remote-tracking branch 'origin/feat/private-groups' into feat/integration-all
# Conflicts: # bitchat/Protocols/BitchatProtocol.swift # bitchat/Protocols/PeerCapabilities+Local.swift # bitchat/Services/BLE/BLEOutboundPacketPolicy.swift # bitchat/Services/BLE/BLEService.swift # bitchat/Services/Transport.swift # bitchat/Sync/GossipSyncManager.swift # bitchat/Sync/SyncTypeFlags.swift # bitchat/ViewModels/ChatTransportEventCoordinator.swift # bitchat/ViewModels/ChatViewModel.swift # bitchat/ViewModels/NostrInboundPipeline.swift # bitchatTests/ChatTransportEventCoordinatorContextTests.swift # bitchatTests/GossipSyncManagerTests.swift # localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift
This commit is contained in:
@@ -193,7 +193,9 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
|
|
||||||
private func refreshComputedState() {
|
private func refreshComputedState() {
|
||||||
if let selectedPeerID = privateConversationModel.selectedPeerID {
|
if let selectedPeerID = privateConversationModel.selectedPeerID {
|
||||||
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat)
|
// Media transfer is not wired for groups in v1; keep it off so the
|
||||||
|
// composer can't strand a media placeholder that never sends.
|
||||||
|
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat || selectedPeerID.isGroup)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,11 +29,22 @@ struct GeohashPersonRow: Identifiable, Equatable {
|
|||||||
let isBlocked: Bool
|
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
|
@MainActor
|
||||||
final class PeerListModel: ObservableObject {
|
final class PeerListModel: ObservableObject {
|
||||||
@Published private(set) var allPeers: [BitchatPeer] = []
|
@Published private(set) var allPeers: [BitchatPeer] = []
|
||||||
@Published private(set) var meshRows: [MeshPeerRow] = []
|
@Published private(set) var meshRows: [MeshPeerRow] = []
|
||||||
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
||||||
|
@Published private(set) var groupRows: [GroupChatRow] = []
|
||||||
@Published private(set) var reachableMeshPeerCount = 0
|
@Published private(set) var reachableMeshPeerCount = 0
|
||||||
@Published private(set) var connectedMeshPeerCount = 0
|
@Published private(set) var connectedMeshPeerCount = 0
|
||||||
@Published private(set) var visibleGeohashPeerCount = 0
|
@Published private(set) var visibleGeohashPeerCount = 0
|
||||||
@@ -132,6 +143,13 @@ final class PeerListModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.groupStore.$groups
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
peerIdentityStore.$encryptionStatuses
|
peerIdentityStore.$encryptionStatuses
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
@@ -220,22 +238,40 @@ final class PeerListModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let geohashPeople = buildGeohashPeople()
|
let geohashPeople = buildGeohashPeople()
|
||||||
|
let groupRows = buildGroupRows()
|
||||||
|
|
||||||
self.meshRows = meshRows
|
self.meshRows = meshRows
|
||||||
reachableMeshPeerCount = meshCounts.reachable
|
reachableMeshPeerCount = meshCounts.reachable
|
||||||
connectedMeshPeerCount = meshCounts.connected
|
connectedMeshPeerCount = meshCounts.connected
|
||||||
self.geohashPeople = geohashPeople
|
self.geohashPeople = geohashPeople
|
||||||
visibleGeohashPeerCount = geohashPeople.count
|
visibleGeohashPeerCount = geohashPeople.count
|
||||||
|
self.groupRows = groupRows
|
||||||
renderID = (
|
renderID = (
|
||||||
meshRows.map {
|
meshRows.map {
|
||||||
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
||||||
} +
|
} +
|
||||||
geohashPeople.map {
|
geohashPeople.map {
|
||||||
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
|
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
|
||||||
|
} +
|
||||||
|
groupRows.map {
|
||||||
|
"group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)"
|
||||||
}
|
}
|
||||||
).joined(separator: "|")
|
).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] {
|
private func buildGeohashPeople() -> [GeohashPersonRow] {
|
||||||
let myHex = currentGeohashIdentityHex()
|
let myHex = currentGeohashIdentityHex()
|
||||||
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
|
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
|
||||||
|
|||||||
@@ -108,7 +108,13 @@ struct PrivateConversationHeaderState: Equatable {
|
|||||||
let encryptionStatus: EncryptionStatus?
|
let encryptionStatus: EncryptionStatus?
|
||||||
|
|
||||||
var supportsFavoriteToggle: Bool {
|
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)
|
.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"))
|
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
@@ -229,6 +242,26 @@ final class PrivateConversationModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
|
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 headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||||
|
|||||||
@@ -9348,6 +9348,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"content.accessibility.group_chat" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Group chat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"content.accessibility.jump_to_latest" : {
|
"content.accessibility.jump_to_latest" : {
|
||||||
"comment" : "Accessibility label for the jump to latest messages button",
|
"comment" : "Accessibility label for the jump to latest messages button",
|
||||||
"extractionState" : "manual",
|
"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" : {
|
"content.commands.help" : {
|
||||||
"comment" : "Description of the /help command in the suggestions panel",
|
"comment" : "Description of the /help command in the suggestions panel",
|
||||||
"extractionState" : "manual",
|
"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" : {
|
"content.input.placeholder.location" : {
|
||||||
"comment" : "Composer placeholder for a public geohash channel, naming it",
|
"comment" : "Composer placeholder for a public geohash channel, naming it",
|
||||||
"extractionState" : "manual",
|
"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" : {
|
"encryption.accessibility.establishing" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -24470,6 +24514,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" : {
|
"location_channels.accessibility.add_bookmark" : {
|
||||||
"comment" : "Accessibility action name for bookmarking a channel",
|
"comment" : "Accessibility action name for bookmarking a channel",
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
@@ -33963,6 +34051,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 <name> 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 <name>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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" : {
|
"system.location.not_in_channel" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ enum CommandInfo: String, Identifiable {
|
|||||||
// suggesting a spelling the processor rejects teaches users dead ends.
|
// suggesting a spelling the processor rejects teaches users dead ends.
|
||||||
case block
|
case block
|
||||||
case clear
|
case clear
|
||||||
|
case group
|
||||||
case help
|
case help
|
||||||
case hug
|
case hug
|
||||||
case message = "msg"
|
case message = "msg"
|
||||||
@@ -33,6 +34,8 @@ enum CommandInfo: String, Identifiable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
||||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||||
|
case .group:
|
||||||
|
return "<" + String(localized: "content.input.group_placeholder") + ">"
|
||||||
case .clear, .help, .who:
|
case .clear, .help, .who:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -42,6 +45,7 @@ enum CommandInfo: String, Identifiable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .block: String(localized: "content.commands.block")
|
case .block: String(localized: "content.commands.block")
|
||||||
case .clear: String(localized: "content.commands.clear")
|
case .clear: String(localized: "content.commands.clear")
|
||||||
|
case .group: String(localized: "content.commands.group")
|
||||||
case .help: String(localized: "content.commands.help")
|
case .help: String(localized: "content.commands.help")
|
||||||
case .hug: String(localized: "content.commands.hug")
|
case .hug: String(localized: "content.commands.hug")
|
||||||
case .message: String(localized: "content.commands.message")
|
case .message: String(localized: "content.commands.message")
|
||||||
@@ -55,11 +59,11 @@ enum CommandInfo: String, Identifiable {
|
|||||||
|
|
||||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
||||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
|
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
|
||||||
// The processor rejects favorites in geohash contexts, so only
|
// The processor rejects favorites and groups in geohash contexts, so
|
||||||
// suggest them where they actually work: mesh.
|
// only suggest them where they actually work: mesh.
|
||||||
if isGeoPublic || isGeoDM {
|
if isGeoPublic || isGeoDM {
|
||||||
return baseCommands
|
return baseCommands
|
||||||
}
|
}
|
||||||
return baseCommands + [.favorite, .unfavorite]
|
return baseCommands + [.favorite, .unfavorite, .group]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case privateMessage = 0x01 // Private chat message
|
case privateMessage = 0x01 // Private chat message
|
||||||
case readReceipt = 0x02 // Message was read
|
case readReceipt = 0x02 // Message was read
|
||||||
case delivered = 0x03 // Message was delivered
|
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)
|
// Verification (QR-based OOB binding)
|
||||||
case verifyChallenge = 0x10 // Verification challenge
|
case verifyChallenge = 0x10 // Verification challenge
|
||||||
case verifyResponse = 0x11 // Verification response
|
case verifyResponse = 0x11 // Verification response
|
||||||
@@ -83,6 +86,8 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case .privateMessage: return "privateMessage"
|
case .privateMessage: return "privateMessage"
|
||||||
case .readReceipt: return "readReceipt"
|
case .readReceipt: return "readReceipt"
|
||||||
case .delivered: return "delivered"
|
case .delivered: return "delivered"
|
||||||
|
case .groupInvite: return "groupInvite"
|
||||||
|
case .groupKeyUpdate: return "groupKeyUpdate"
|
||||||
case .verifyChallenge: return "verifyChallenge"
|
case .verifyChallenge: return "verifyChallenge"
|
||||||
case .verifyResponse: return "verifyResponse"
|
case .verifyResponse: return "verifyResponse"
|
||||||
case .vouch: return "vouch"
|
case .vouch: return "vouch"
|
||||||
@@ -117,6 +122,9 @@ protocol BitchatDelegate: AnyObject {
|
|||||||
// Low-level events for better separation of concerns
|
// Low-level events for better separation of concerns
|
||||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
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
|
// Bluetooth state updates for user notifications
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||||
@@ -136,6 +144,10 @@ extension BitchatDelegate {
|
|||||||
// Default empty implementation
|
// 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?) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ import BitFoundation
|
|||||||
extension PeerCapabilities {
|
extension PeerCapabilities {
|
||||||
/// Capabilities this build advertises in its announce packets.
|
/// Capabilities this build advertises in its announce packets.
|
||||||
/// Each feature adds its bit here when it ships.
|
/// Each feature adds its bit here when it ships.
|
||||||
static let localSupported: PeerCapabilities = [.prekeys, .vouch]
|
static let localSupported: PeerCapabilities = [.prekeys, .vouch, .groups]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
|
|||||||
switch MessageType(rawValue: packetType) {
|
switch MessageType(rawValue: packetType) {
|
||||||
case .noiseEncrypted, .noiseHandshake:
|
case .noiseEncrypted, .noiseHandshake:
|
||||||
return true
|
return true
|
||||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .prekeyBundle:
|
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .prekeyBundle, .groupMessage:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1330,6 +1330,48 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// MARK: QR Verification over Noise
|
// 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) {
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||||
let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA)
|
let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA)
|
||||||
sendNoisePayload(payload, to: peerID)
|
sendNoisePayload(payload, to: peerID)
|
||||||
@@ -3447,6 +3489,9 @@ extension BLEService {
|
|||||||
case .prekeyBundle:
|
case .prekeyBundle:
|
||||||
handlePrekeyBundle(packet, from: senderID)
|
handlePrekeyBundle(packet, from: senderID)
|
||||||
|
|
||||||
|
case .groupMessage:
|
||||||
|
handleGroupMessage(packet, from: senderID)
|
||||||
|
|
||||||
case .leave:
|
case .leave:
|
||||||
handleLeave(packet, from: senderID)
|
handleLeave(packet, from: senderID)
|
||||||
|
|
||||||
@@ -3709,6 +3754,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) {
|
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
noisePacketHandler.handleHandshake(packet, from: peerID)
|
noisePacketHandler.handleHandshake(packet, from: peerID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ protocol CommandContextProvider: AnyObject {
|
|||||||
/// Toggles the favorite via the unified peer flow, which persists by the
|
/// Toggles the favorite via the unified peer flow, which persists by the
|
||||||
/// real noise key and notifies the peer over mesh or Nostr.
|
/// real noise key and notifies the peer over mesh or Nostr.
|
||||||
func toggleFavorite(peerID: PeerID)
|
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
|
/// Processes chat commands in a focused, efficient way
|
||||||
@@ -100,6 +109,9 @@ final class CommandProcessor {
|
|||||||
return handleBlock(args)
|
return handleBlock(args)
|
||||||
case "/unblock":
|
case "/unblock":
|
||||||
return handleUnblock(args)
|
return handleUnblock(args)
|
||||||
|
case "/group":
|
||||||
|
if inGeoPublic || inGeoDM { return .error(message: "groups are only for mesh peers in #mesh") }
|
||||||
|
return handleGroup(args)
|
||||||
case "/fav":
|
case "/fav":
|
||||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||||
return handleFavorite(args, add: true)
|
return handleFavorite(args, add: true)
|
||||||
@@ -125,6 +137,9 @@ final class CommandProcessor {
|
|||||||
/slap @name — slap with a large trout
|
/slap @name — slap with a large trout
|
||||||
/block @name · /unblock @name
|
/block @name · /unblock @name
|
||||||
/fav @name · /unfav @name — favorites (mesh only)
|
/fav @name · /unfav @name — favorites (mesh only)
|
||||||
|
/group create <name> — 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
|
/help — this list
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -331,6 +346,32 @@ final class CommandProcessor {
|
|||||||
return .error(message: "cannot unblock \(nickname): not found")
|
return .error(message: "cannot unblock \(nickname): not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static let groupUsage = "usage: /group create <name> · 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 {
|
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||||
let targetName = args.trimmed
|
let targetName = args.trimmed
|
||||||
guard !targetName.isEmpty else {
|
guard !targetName.isEmpty else {
|
||||||
|
|||||||
@@ -0,0 +1,569 @@
|
|||||||
|
//
|
||||||
|
// GroupProtocol.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Wire formats and crypto for private groups: creator-signed group state
|
||||||
|
// (invites and key updates over Noise) and ChaCha20-Poly1305 group messages
|
||||||
|
// broadcast as MessageType.groupMessage (0x25).
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Models
|
||||||
|
|
||||||
|
/// A member of a private group as pinned in the creator-signed roster.
|
||||||
|
struct GroupMember: Codable, Equatable {
|
||||||
|
/// SHA-256 fingerprint (64 hex chars) of the member's Noise static key.
|
||||||
|
let fingerprint: String
|
||||||
|
/// The member's Ed25519 signing public key (32 bytes, from their announce).
|
||||||
|
let signingKey: Data
|
||||||
|
/// Nickname at invite time; display fallback when the peer is offline.
|
||||||
|
var nickname: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creator-managed encrypted group. Metadata only — the symmetric key lives
|
||||||
|
/// in the keychain (see `GroupStore`).
|
||||||
|
struct BitchatGroup: Codable, Equatable {
|
||||||
|
static let maxMembers = 16
|
||||||
|
static let groupIDLength = 16
|
||||||
|
static let keyLength = 32
|
||||||
|
|
||||||
|
/// 16 random bytes; travels in cleartext on group message packets so
|
||||||
|
/// relays can dedup/filter without membership.
|
||||||
|
let groupID: Data
|
||||||
|
var name: String
|
||||||
|
/// Bumps on every key rotation; messages are bound to the epoch they
|
||||||
|
/// were sealed under.
|
||||||
|
var epoch: UInt32
|
||||||
|
var members: [GroupMember]
|
||||||
|
/// Fingerprint of the creator — the only identity allowed to sign group
|
||||||
|
/// state (invites, key updates) in v1.
|
||||||
|
let creatorFingerprint: String
|
||||||
|
|
||||||
|
/// Virtual conversation ID this group's chat is keyed under.
|
||||||
|
var peerID: PeerID { PeerID(groupID: groupID) }
|
||||||
|
|
||||||
|
var creator: GroupMember? {
|
||||||
|
members.first { $0.fingerprint == creatorFingerprint }
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMember(fingerprint: String) -> Bool {
|
||||||
|
members.contains { $0.fingerprint == fingerprint }
|
||||||
|
}
|
||||||
|
|
||||||
|
func member(withSigningKey signingKey: Data) -> GroupMember? {
|
||||||
|
members.first { $0.signingKey == signingKey }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TLV helpers
|
||||||
|
|
||||||
|
enum GroupTLVError: Error, Equatable {
|
||||||
|
/// A TLV value exceeded the 16-bit length field. Encoding fails instead
|
||||||
|
/// of silently truncating (which would ship a value the receiver drops).
|
||||||
|
case valueTooLong
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum GroupTLV {
|
||||||
|
/// Appends a (type, 16-bit length, value) triple. Throws rather than
|
||||||
|
/// truncating when `value` does not fit the 16-bit length field, so an
|
||||||
|
/// oversize field surfaces a send failure instead of a silently truncated
|
||||||
|
/// blob the recipient rejects during decrypt/verify.
|
||||||
|
static func put(_ type: UInt8, _ value: Data, into out: inout Data) throws {
|
||||||
|
guard value.count <= Int(UInt16.max) else { throw GroupTLVError.valueTooLong }
|
||||||
|
out.append(type)
|
||||||
|
let length = UInt16(value.count)
|
||||||
|
out.append(UInt8((length >> 8) & 0xFF))
|
||||||
|
out.append(UInt8(length & 0xFF))
|
||||||
|
out.append(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterates (type, value) pairs; returns nil on malformed framing.
|
||||||
|
static func parse(_ data: Data) -> [(type: UInt8, value: Data)]? {
|
||||||
|
var fields: [(UInt8, Data)] = []
|
||||||
|
var offset = data.startIndex
|
||||||
|
while offset < data.endIndex {
|
||||||
|
guard data.distance(from: offset, to: data.endIndex) >= 3 else { return nil }
|
||||||
|
let type = data[offset]
|
||||||
|
let high = Int(data[data.index(offset, offsetBy: 1)])
|
||||||
|
let low = Int(data[data.index(offset, offsetBy: 2)])
|
||||||
|
let length = (high << 8) | low
|
||||||
|
let valueStart = data.index(offset, offsetBy: 3)
|
||||||
|
guard data.distance(from: valueStart, to: data.endIndex) >= length else { return nil }
|
||||||
|
let valueEnd = data.index(valueStart, offsetBy: length)
|
||||||
|
fields.append((type, Data(data[valueStart..<valueEnd])))
|
||||||
|
offset = valueEnd
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
static func epochData(_ epoch: UInt32) -> Data {
|
||||||
|
var bigEndian = epoch.bigEndian
|
||||||
|
return withUnsafeBytes(of: &bigEndian) { Data($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func epoch(from data: Data) -> UInt32? {
|
||||||
|
guard data.count == 4 else { return nil }
|
||||||
|
return data.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func timestampData(_ timestampMs: UInt64) -> Data {
|
||||||
|
var bigEndian = timestampMs.bigEndian
|
||||||
|
return withUnsafeBytes(of: &bigEndian) { Data($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func timestamp(from data: Data) -> UInt64? {
|
||||||
|
guard data.count == 8 else { return nil }
|
||||||
|
return data.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Roster wire form
|
||||||
|
|
||||||
|
enum GroupRosterCoding {
|
||||||
|
private static let fingerprintLength = 32
|
||||||
|
private static let signingKeyLength = 32
|
||||||
|
private static let maxNicknameBytes = 64
|
||||||
|
|
||||||
|
/// Deterministic roster blob: count byte, then per member the raw 32-byte
|
||||||
|
/// fingerprint, 32-byte signing key, and length-prefixed UTF-8 nickname.
|
||||||
|
/// The creator signature covers the SHA-256 of these exact bytes.
|
||||||
|
static func encode(_ members: [GroupMember]) -> Data? {
|
||||||
|
guard members.count <= BitchatGroup.maxMembers else { return nil }
|
||||||
|
var out = Data([UInt8(members.count)])
|
||||||
|
for member in members {
|
||||||
|
guard let fingerprintData = Data(hexString: member.fingerprint),
|
||||||
|
fingerprintData.count == fingerprintLength,
|
||||||
|
member.signingKey.count == signingKeyLength else { return nil }
|
||||||
|
out.append(fingerprintData)
|
||||||
|
out.append(member.signingKey)
|
||||||
|
// Truncate on a Character boundary so the byte prefix is always
|
||||||
|
// valid UTF-8; a raw byte-prefix could split a multi-byte scalar
|
||||||
|
// and make the whole signed roster undecodable on the recipient.
|
||||||
|
let nickname = truncatedNicknameBytes(member.nickname)
|
||||||
|
out.append(UInt8(nickname.count))
|
||||||
|
out.append(nickname)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ data: Data) -> [GroupMember]? {
|
||||||
|
guard let count = data.first, count <= UInt8(BitchatGroup.maxMembers) else { return nil }
|
||||||
|
var members: [GroupMember] = []
|
||||||
|
var offset = data.index(after: data.startIndex)
|
||||||
|
for _ in 0..<count {
|
||||||
|
let fixed = fingerprintLength + signingKeyLength + 1
|
||||||
|
guard data.distance(from: offset, to: data.endIndex) >= fixed else { return nil }
|
||||||
|
let fingerprintEnd = data.index(offset, offsetBy: fingerprintLength)
|
||||||
|
let fingerprint = Data(data[offset..<fingerprintEnd]).hexEncodedString()
|
||||||
|
let signingKeyEnd = data.index(fingerprintEnd, offsetBy: signingKeyLength)
|
||||||
|
let signingKey = Data(data[fingerprintEnd..<signingKeyEnd])
|
||||||
|
let nickLength = Int(data[signingKeyEnd])
|
||||||
|
let nickStart = data.index(after: signingKeyEnd)
|
||||||
|
guard data.distance(from: nickStart, to: data.endIndex) >= nickLength else { return nil }
|
||||||
|
let nickEnd = data.index(nickStart, offsetBy: nickLength)
|
||||||
|
guard let nickname = String(data: Data(data[nickStart..<nickEnd]), encoding: .utf8) else { return nil }
|
||||||
|
members.append(GroupMember(fingerprint: fingerprint, signingKey: signingKey, nickname: nickname))
|
||||||
|
offset = nickEnd
|
||||||
|
}
|
||||||
|
guard offset == data.endIndex else { return nil }
|
||||||
|
return members
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UTF-8 bytes of `nickname` trimmed to at most `maxNicknameBytes`,
|
||||||
|
/// dropping whole Characters so the result is never split mid-scalar.
|
||||||
|
private static func truncatedNicknameBytes(_ nickname: String) -> Data {
|
||||||
|
var candidate = nickname
|
||||||
|
while Data(candidate.utf8).count > maxNicknameBytes {
|
||||||
|
candidate.removeLast()
|
||||||
|
}
|
||||||
|
return Data(candidate.utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Group state payload (groupInvite / groupKeyUpdate over Noise)
|
||||||
|
|
||||||
|
/// Creator-signed group state. The same wire form serves invites (0x06) and
|
||||||
|
/// key updates (0x07); receivers verify the creator signature — computed over
|
||||||
|
/// "bitchat-group-v1" | groupID | epoch | SHA256(key) | SHA256(roster) —
|
||||||
|
/// against the creator's signing key pinned in the roster, and require the
|
||||||
|
/// Noise session peer to BE the creator before accepting any state.
|
||||||
|
struct GroupStatePayload: Equatable {
|
||||||
|
let groupID: Data
|
||||||
|
let name: String
|
||||||
|
/// Symmetric ChaCha20-Poly1305 group key (32 bytes) for `epoch`.
|
||||||
|
let key: Data
|
||||||
|
let epoch: UInt32
|
||||||
|
let members: [GroupMember]
|
||||||
|
let creatorFingerprint: String
|
||||||
|
/// Ed25519 signature by the creator.
|
||||||
|
let signature: Data
|
||||||
|
|
||||||
|
private enum FieldType: UInt8 {
|
||||||
|
case groupID = 0x01
|
||||||
|
case name = 0x02
|
||||||
|
case key = 0x03
|
||||||
|
case epoch = 0x04
|
||||||
|
case roster = 0x05
|
||||||
|
case creatorFingerprint = 0x06
|
||||||
|
case signature = 0x07
|
||||||
|
}
|
||||||
|
|
||||||
|
static let signingDomain = Data("bitchat-group-v1".utf8)
|
||||||
|
|
||||||
|
/// The bytes the creator signs. Binding the key, roster, and name by hash
|
||||||
|
/// keeps the signed content fixed-size. The name is covered so a relay
|
||||||
|
/// that caches/replays a signed state (e.g. store-and-forward) cannot swap
|
||||||
|
/// the display name while keeping a valid creator signature.
|
||||||
|
static func signingContent(groupID: Data, epoch: UInt32, key: Data, rosterBlob: Data, name: String) -> Data {
|
||||||
|
var content = signingDomain
|
||||||
|
content.append(groupID)
|
||||||
|
content.append(GroupTLV.epochData(epoch))
|
||||||
|
content.append(key.sha256Hash())
|
||||||
|
content.append(rosterBlob.sha256Hash())
|
||||||
|
content.append(Data(name.utf8).sha256Hash())
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a signed state payload. Returns nil when the roster cannot be
|
||||||
|
/// encoded (over cap, malformed member) or signing fails.
|
||||||
|
static func makeSigned(
|
||||||
|
group: BitchatGroup,
|
||||||
|
key: Data,
|
||||||
|
sign: (Data) -> Data?
|
||||||
|
) -> GroupStatePayload? {
|
||||||
|
guard let rosterBlob = GroupRosterCoding.encode(group.members) else { return nil }
|
||||||
|
let content = signingContent(groupID: group.groupID, epoch: group.epoch, key: key, rosterBlob: rosterBlob, name: group.name)
|
||||||
|
guard let signature = sign(content) else { return nil }
|
||||||
|
return GroupStatePayload(
|
||||||
|
groupID: group.groupID,
|
||||||
|
name: group.name,
|
||||||
|
key: key,
|
||||||
|
epoch: group.epoch,
|
||||||
|
members: group.members,
|
||||||
|
creatorFingerprint: group.creatorFingerprint,
|
||||||
|
signature: signature
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode() -> Data? {
|
||||||
|
guard let rosterBlob = GroupRosterCoding.encode(members),
|
||||||
|
let fingerprintData = Data(hexString: creatorFingerprint),
|
||||||
|
fingerprintData.count == 32 else { return nil }
|
||||||
|
var out = Data()
|
||||||
|
do {
|
||||||
|
try GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out)
|
||||||
|
try GroupTLV.put(FieldType.name.rawValue, Data(name.utf8), into: &out)
|
||||||
|
try GroupTLV.put(FieldType.key.rawValue, key, into: &out)
|
||||||
|
try GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out)
|
||||||
|
try GroupTLV.put(FieldType.roster.rawValue, rosterBlob, into: &out)
|
||||||
|
try GroupTLV.put(FieldType.creatorFingerprint.rawValue, fingerprintData, into: &out)
|
||||||
|
try GroupTLV.put(FieldType.signature.rawValue, signature, into: &out)
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ data: Data) -> GroupStatePayload? {
|
||||||
|
guard let fields = GroupTLV.parse(data) else { return nil }
|
||||||
|
var groupID: Data?
|
||||||
|
var name: String?
|
||||||
|
var key: Data?
|
||||||
|
var epoch: UInt32?
|
||||||
|
var rosterBlob: Data?
|
||||||
|
var members: [GroupMember]?
|
||||||
|
var creatorFingerprint: String?
|
||||||
|
var signature: Data?
|
||||||
|
|
||||||
|
for (type, value) in fields {
|
||||||
|
switch FieldType(rawValue: type) {
|
||||||
|
case .groupID where value.count == BitchatGroup.groupIDLength:
|
||||||
|
groupID = value
|
||||||
|
case .name:
|
||||||
|
name = String(data: value, encoding: .utf8)
|
||||||
|
case .key where value.count == BitchatGroup.keyLength:
|
||||||
|
key = value
|
||||||
|
case .epoch:
|
||||||
|
epoch = GroupTLV.epoch(from: value)
|
||||||
|
case .roster:
|
||||||
|
rosterBlob = value
|
||||||
|
members = GroupRosterCoding.decode(value)
|
||||||
|
case .creatorFingerprint where value.count == 32:
|
||||||
|
creatorFingerprint = value.hexEncodedString()
|
||||||
|
case .signature where value.count == 64:
|
||||||
|
signature = value
|
||||||
|
default:
|
||||||
|
break // forward compatible; ignore unknown TLVs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let groupID, let name, let key, let epoch,
|
||||||
|
rosterBlob != nil, let members, !members.isEmpty,
|
||||||
|
let creatorFingerprint, let signature else { return nil }
|
||||||
|
return GroupStatePayload(
|
||||||
|
groupID: groupID,
|
||||||
|
name: name,
|
||||||
|
key: key,
|
||||||
|
epoch: epoch,
|
||||||
|
members: members,
|
||||||
|
creatorFingerprint: creatorFingerprint,
|
||||||
|
signature: signature
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies the creator signature against the creator's signing key
|
||||||
|
/// pinned in the roster, and that the creator is actually in the roster.
|
||||||
|
func verifyCreatorSignature() -> Bool {
|
||||||
|
guard members.count <= BitchatGroup.maxMembers,
|
||||||
|
let creator = members.first(where: { $0.fingerprint == creatorFingerprint }),
|
||||||
|
let rosterBlob = GroupRosterCoding.encode(members) else { return false }
|
||||||
|
let content = GroupStatePayload.signingContent(groupID: groupID, epoch: epoch, key: key, rosterBlob: rosterBlob, name: name)
|
||||||
|
return GroupCrypto.verify(signature: signature, for: content, publicKey: creator.signingKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
var asGroup: BitchatGroup {
|
||||||
|
BitchatGroup(
|
||||||
|
groupID: groupID,
|
||||||
|
name: name,
|
||||||
|
epoch: epoch,
|
||||||
|
members: members,
|
||||||
|
creatorFingerprint: creatorFingerprint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Group message envelope (MessageType 0x25 payload)
|
||||||
|
|
||||||
|
/// Cleartext framing of a group message broadcast. Only the group ID, epoch,
|
||||||
|
/// and nonce are visible to relays; everything about the message — sender,
|
||||||
|
/// content, timestamps — is inside the ChaCha20-Poly1305 ciphertext.
|
||||||
|
struct GroupMessageEnvelope: Equatable {
|
||||||
|
let groupID: Data
|
||||||
|
let epoch: UInt32
|
||||||
|
let nonce: Data
|
||||||
|
/// ChaChaPoly ciphertext || 16-byte tag.
|
||||||
|
let ciphertext: Data
|
||||||
|
|
||||||
|
private enum FieldType: UInt8 {
|
||||||
|
case groupID = 0x01
|
||||||
|
case epoch = 0x02
|
||||||
|
case nonce = 0x03
|
||||||
|
case ciphertext = 0x04
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode() throws -> Data {
|
||||||
|
var out = Data()
|
||||||
|
try GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out)
|
||||||
|
try GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out)
|
||||||
|
try GroupTLV.put(FieldType.nonce.rawValue, nonce, into: &out)
|
||||||
|
try GroupTLV.put(FieldType.ciphertext.rawValue, ciphertext, into: &out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ data: Data) -> GroupMessageEnvelope? {
|
||||||
|
guard let fields = GroupTLV.parse(data) else { return nil }
|
||||||
|
var groupID: Data?
|
||||||
|
var epoch: UInt32?
|
||||||
|
var nonce: Data?
|
||||||
|
var ciphertext: Data?
|
||||||
|
for (type, value) in fields {
|
||||||
|
switch FieldType(rawValue: type) {
|
||||||
|
case .groupID where value.count == BitchatGroup.groupIDLength:
|
||||||
|
groupID = value
|
||||||
|
case .epoch:
|
||||||
|
epoch = GroupTLV.epoch(from: value)
|
||||||
|
case .nonce where value.count == 12:
|
||||||
|
nonce = value
|
||||||
|
case .ciphertext where !value.isEmpty:
|
||||||
|
ciphertext = value
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let groupID, let epoch, let nonce, let ciphertext else { return nil }
|
||||||
|
return GroupMessageEnvelope(groupID: groupID, epoch: epoch, nonce: nonce, ciphertext: ciphertext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypted, signature-verified inner content of a group message.
|
||||||
|
struct GroupMessagePlaintext: Equatable {
|
||||||
|
let messageID: String
|
||||||
|
let senderSigningKey: Data
|
||||||
|
let senderNickname: String
|
||||||
|
let timestampMs: UInt64
|
||||||
|
let content: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Crypto
|
||||||
|
|
||||||
|
enum GroupCryptoError: Error, Equatable {
|
||||||
|
case malformedPayload
|
||||||
|
case signingFailed
|
||||||
|
case sealFailed
|
||||||
|
case wrongEpoch
|
||||||
|
case decryptionFailed
|
||||||
|
case badSenderSignature
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GroupCrypto {
|
||||||
|
static let messageSigningDomain = Data("bitchat-group-msg-v1".utf8)
|
||||||
|
|
||||||
|
private enum InnerField: UInt8 {
|
||||||
|
case messageID = 0x01
|
||||||
|
case senderSigningKey = 0x02
|
||||||
|
case senderNickname = 0x03
|
||||||
|
case timestamp = 0x04
|
||||||
|
case content = 0x05
|
||||||
|
case signature = 0x06
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes the sender signs: domain | groupID | epoch | messageID | timestamp | content.
|
||||||
|
/// Covering the epoch stops a current member from re-sealing another
|
||||||
|
/// member's decrypted inner bytes under a later epoch key (the signature
|
||||||
|
/// would no longer verify at the new epoch).
|
||||||
|
static func messageSigningContent(groupID: Data, epoch: UInt32, messageID: String, timestampMs: UInt64, content: String) -> Data {
|
||||||
|
var data = messageSigningDomain
|
||||||
|
data.append(groupID)
|
||||||
|
data.append(GroupTLV.epochData(epoch))
|
||||||
|
data.append(Data(messageID.utf8))
|
||||||
|
data.append(GroupTLV.timestampData(timestampMs))
|
||||||
|
data.append(Data(content.utf8))
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func verify(signature: Data, for data: Data, publicKey: Data) -> Bool {
|
||||||
|
guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else { return false }
|
||||||
|
return key.isValidSignature(signature, for: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seals a group message: builds the signed inner TLV and encrypts it with
|
||||||
|
/// the epoch key. The cleartext group ID and epoch are bound into the AEAD
|
||||||
|
/// as additional data so ciphertext cannot be replayed across groups or
|
||||||
|
/// epochs. Returns the encoded 0x25 packet payload.
|
||||||
|
static func sealMessage(
|
||||||
|
content: String,
|
||||||
|
messageID: String,
|
||||||
|
senderNickname: String,
|
||||||
|
senderSigningKey: Data,
|
||||||
|
timestampMs: UInt64,
|
||||||
|
groupID: Data,
|
||||||
|
epoch: UInt32,
|
||||||
|
key: Data,
|
||||||
|
sign: (Data) -> Data?
|
||||||
|
) throws -> Data {
|
||||||
|
let signingContent = messageSigningContent(
|
||||||
|
groupID: groupID,
|
||||||
|
epoch: epoch,
|
||||||
|
messageID: messageID,
|
||||||
|
timestampMs: timestampMs,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
guard let signature = sign(signingContent), signature.count == 64 else {
|
||||||
|
throw GroupCryptoError.signingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
var inner = Data()
|
||||||
|
try GroupTLV.put(InnerField.messageID.rawValue, Data(messageID.utf8), into: &inner)
|
||||||
|
try GroupTLV.put(InnerField.senderSigningKey.rawValue, senderSigningKey, into: &inner)
|
||||||
|
try GroupTLV.put(InnerField.senderNickname.rawValue, Data(senderNickname.utf8), into: &inner)
|
||||||
|
try GroupTLV.put(InnerField.timestamp.rawValue, GroupTLV.timestampData(timestampMs), into: &inner)
|
||||||
|
try GroupTLV.put(InnerField.content.rawValue, Data(content.utf8), into: &inner)
|
||||||
|
try GroupTLV.put(InnerField.signature.rawValue, signature, into: &inner)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let symmetricKey = SymmetricKey(data: key)
|
||||||
|
var aad = groupID
|
||||||
|
aad.append(GroupTLV.epochData(epoch))
|
||||||
|
let sealed = try ChaChaPoly.seal(inner, using: symmetricKey, authenticating: aad)
|
||||||
|
var ciphertext = sealed.ciphertext
|
||||||
|
ciphertext.append(sealed.tag)
|
||||||
|
let envelope = GroupMessageEnvelope(
|
||||||
|
groupID: groupID,
|
||||||
|
epoch: epoch,
|
||||||
|
nonce: Data(sealed.nonce),
|
||||||
|
ciphertext: ciphertext
|
||||||
|
)
|
||||||
|
return try envelope.encode()
|
||||||
|
} catch {
|
||||||
|
throw GroupCryptoError.sealFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a group message envelope with the epoch key: decrypts, parses the
|
||||||
|
/// inner TLV, and verifies the sender's Ed25519 signature. Roster
|
||||||
|
/// membership of the sender is the CALLER's check — this function only
|
||||||
|
/// proves the payload was authored by `senderSigningKey`.
|
||||||
|
static func openMessage(_ envelope: GroupMessageEnvelope, key: Data) throws -> GroupMessagePlaintext {
|
||||||
|
let inner: Data
|
||||||
|
do {
|
||||||
|
let symmetricKey = SymmetricKey(data: key)
|
||||||
|
var aad = envelope.groupID
|
||||||
|
aad.append(GroupTLV.epochData(envelope.epoch))
|
||||||
|
let nonce = try ChaChaPoly.Nonce(data: envelope.nonce)
|
||||||
|
guard envelope.ciphertext.count > 16 else { throw GroupCryptoError.decryptionFailed }
|
||||||
|
let tag = envelope.ciphertext.suffix(16)
|
||||||
|
let body = envelope.ciphertext.prefix(envelope.ciphertext.count - 16)
|
||||||
|
let sealedBox = try ChaChaPoly.SealedBox(nonce: nonce, ciphertext: body, tag: tag)
|
||||||
|
inner = try ChaChaPoly.open(sealedBox, using: symmetricKey, authenticating: aad)
|
||||||
|
} catch {
|
||||||
|
throw GroupCryptoError.decryptionFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let fields = GroupTLV.parse(inner) else { throw GroupCryptoError.malformedPayload }
|
||||||
|
var messageID: String?
|
||||||
|
var senderSigningKey: Data?
|
||||||
|
var senderNickname: String?
|
||||||
|
var timestampMs: UInt64?
|
||||||
|
var content: String?
|
||||||
|
var signature: Data?
|
||||||
|
for (type, value) in fields {
|
||||||
|
switch InnerField(rawValue: type) {
|
||||||
|
case .messageID:
|
||||||
|
messageID = String(data: value, encoding: .utf8)
|
||||||
|
case .senderSigningKey where value.count == 32:
|
||||||
|
senderSigningKey = value
|
||||||
|
case .senderNickname:
|
||||||
|
senderNickname = String(data: value, encoding: .utf8)
|
||||||
|
case .timestamp:
|
||||||
|
timestampMs = GroupTLV.timestamp(from: value)
|
||||||
|
case .content:
|
||||||
|
content = String(data: value, encoding: .utf8)
|
||||||
|
case .signature where value.count == 64:
|
||||||
|
signature = value
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let messageID, !messageID.isEmpty,
|
||||||
|
let senderSigningKey,
|
||||||
|
let senderNickname,
|
||||||
|
let timestampMs,
|
||||||
|
let content,
|
||||||
|
let signature else { throw GroupCryptoError.malformedPayload }
|
||||||
|
|
||||||
|
let signingContent = messageSigningContent(
|
||||||
|
groupID: envelope.groupID,
|
||||||
|
epoch: envelope.epoch,
|
||||||
|
messageID: messageID,
|
||||||
|
timestampMs: timestampMs,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
guard verify(signature: signature, for: signingContent, publicKey: senderSigningKey) else {
|
||||||
|
throw GroupCryptoError.badSenderSignature
|
||||||
|
}
|
||||||
|
|
||||||
|
return GroupMessagePlaintext(
|
||||||
|
messageID: messageID,
|
||||||
|
senderSigningKey: senderSigningKey,
|
||||||
|
senderNickname: senderNickname,
|
||||||
|
timestampMs: timestampMs,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,9 @@ enum TransportEvent: @unchecked Sendable {
|
|||||||
case messageReceived(BitchatMessage)
|
case messageReceived(BitchatMessage)
|
||||||
case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||||
case noisePayloadReceived(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
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 peerConnected(PeerID)
|
||||||
case peerDisconnected(PeerID)
|
case peerDisconnected(PeerID)
|
||||||
case peerListUpdated([PeerID])
|
case peerListUpdated([PeerID])
|
||||||
@@ -124,6 +127,12 @@ protocol Transport: AnyObject {
|
|||||||
// transport cannot courier (no connected courier, or unsupported).
|
// transport cannot courier (no connected courier, or unsupported).
|
||||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
|
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)
|
// QR verification (optional for transports)
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
@@ -168,6 +177,9 @@ extension Transport {
|
|||||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
||||||
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
|
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
|
||||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
|
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
|
||||||
|
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 sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
|
||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||||
@@ -201,6 +213,8 @@ extension BitchatDelegate {
|
|||||||
)
|
)
|
||||||
case let .noisePayloadReceived(peerID, type, payload, timestamp):
|
case let .noisePayloadReceived(peerID, type, payload, timestamp):
|
||||||
didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp)
|
didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp)
|
||||||
|
case let .groupMessageReceived(payload, timestamp):
|
||||||
|
didReceiveGroupMessage(payload: payload, timestamp: timestamp)
|
||||||
case .peerConnected(let peerID):
|
case .peerConnected(let peerID):
|
||||||
didConnectToPeer(peerID)
|
didConnectToPeer(peerID)
|
||||||
case .peerDisconnected(let peerID):
|
case .peerDisconnected(let peerID):
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ final class GossipSyncManager {
|
|||||||
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||||
var fragmentCapacity: Int = 600
|
var fragmentCapacity: Int = 600
|
||||||
var fileTransferCapacity: Int = 200
|
var fileTransferCapacity: Int = 200
|
||||||
|
var groupMessageCapacity: Int = 200
|
||||||
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
|
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
|
||||||
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
|
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
|
||||||
var messageSyncIntervalSeconds: TimeInterval = 15.0
|
var messageSyncIntervalSeconds: TimeInterval = 15.0
|
||||||
@@ -95,6 +96,7 @@ final class GossipSyncManager {
|
|||||||
private var messages = PacketStore()
|
private var messages = PacketStore()
|
||||||
private var fragments = PacketStore()
|
private var fragments = PacketStore()
|
||||||
private var fileTransfers = PacketStore()
|
private var fileTransfers = PacketStore()
|
||||||
|
private var groupMessages = PacketStore()
|
||||||
private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:]
|
private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:]
|
||||||
// Latest verified prekey bundle per owner. Unlike announces, bundles are
|
// Latest verified prekey bundle per owner. Unlike announces, bundles are
|
||||||
// NOT dropped on leave/stale peer: their whole purpose is reaching a
|
// NOT dropped on leave/stale peer: their whole purpose is reaching a
|
||||||
@@ -120,7 +122,13 @@ final class GossipSyncManager {
|
|||||||
)
|
)
|
||||||
var schedules: [SyncSchedule] = []
|
var schedules: [SyncSchedule] = []
|
||||||
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
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 {
|
if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 {
|
||||||
schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))
|
schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))
|
||||||
@@ -161,6 +169,9 @@ final class GossipSyncManager {
|
|||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
var types: SyncTypeFlags = .publicMessages
|
var types: SyncTypeFlags = .publicMessages
|
||||||
|
if self.config.groupMessageCapacity > 0 {
|
||||||
|
types.formUnion(.groupMessage)
|
||||||
|
}
|
||||||
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
|
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
|
||||||
types.formUnion(.fragment)
|
types.formUnion(.fragment)
|
||||||
}
|
}
|
||||||
@@ -186,7 +197,9 @@ final class GossipSyncManager {
|
|||||||
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
||||||
let maxAgeSeconds: TimeInterval
|
let maxAgeSeconds: TimeInterval
|
||||||
switch packet.type {
|
switch packet.type {
|
||||||
case 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.
|
||||||
|
case MessageType.message.rawValue, MessageType.groupMessage.rawValue:
|
||||||
maxAgeSeconds = config.publicMessageMaxAgeSeconds
|
maxAgeSeconds = config.publicMessageMaxAgeSeconds
|
||||||
case MessageType.prekeyBundle.rawValue:
|
case MessageType.prekeyBundle.rawValue:
|
||||||
maxAgeSeconds = config.prekeyBundleMaxAgeSeconds
|
maxAgeSeconds = config.prekeyBundleMaxAgeSeconds
|
||||||
@@ -268,6 +281,13 @@ final class GossipSyncManager {
|
|||||||
|| latestPrekeyBundleByPeer.count < max(1, config.prekeyBundleCapacity) else { return }
|
|| latestPrekeyBundleByPeer.count < max(1, config.prekeyBundleCapacity) else { return }
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
latestPrekeyBundleByPeer[owner] = (id: idHex, packet: packet)
|
latestPrekeyBundleByPeer[owner] = (id: idHex, packet: packet)
|
||||||
|
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:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -426,6 +446,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
|
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||||
@@ -450,6 +484,9 @@ final class GossipSyncManager {
|
|||||||
candidates.append(pair.packet)
|
candidates.append(pair.packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if types.contains(.groupMessage) {
|
||||||
|
candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh))
|
||||||
|
}
|
||||||
if candidates.isEmpty {
|
if candidates.isEmpty {
|
||||||
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
||||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
||||||
@@ -512,6 +549,7 @@ final class GossipSyncManager {
|
|||||||
latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in
|
latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in
|
||||||
isPacketFresh(pair.packet)
|
isPacketFresh(pair.packet)
|
||||||
}
|
}
|
||||||
|
groupMessages.removeExpired(isFresh: isPacketFresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Archive (public message persistence)
|
// MARK: - Archive (public message persistence)
|
||||||
@@ -609,6 +647,7 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
|
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||||
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
|
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||||
|
groupMessages.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ struct SyncTypeFlags: OptionSet {
|
|||||||
case .fragment: return 5
|
case .fragment: return 5
|
||||||
case .requestSync: return 6
|
case .requestSync: return 6
|
||||||
case .fileTransfer: return 7
|
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
|
// Courier envelopes are directed deposits between trusted peers and
|
||||||
// must never spread via gossip sync.
|
// must never spread via gossip sync.
|
||||||
case .courierEnvelope: return nil
|
case .courierEnvelope: return nil
|
||||||
@@ -59,6 +67,7 @@ struct SyncTypeFlags: OptionSet {
|
|||||||
case 7: return .fileTransfer
|
case 7: return .fileTransfer
|
||||||
// Bit 8 reserved (board).
|
// Bit 8 reserved (board).
|
||||||
case 9: return .prekeyBundle
|
case 9: return .prekeyBundle
|
||||||
|
case 10: return .groupMessage
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -69,6 +78,7 @@ struct SyncTypeFlags: OptionSet {
|
|||||||
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
|
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
|
||||||
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
|
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
|
||||||
static let prekeyBundle = SyncTypeFlags(messageTypes: [.prekeyBundle])
|
static let prekeyBundle = SyncTypeFlags(messageTypes: [.prekeyBundle])
|
||||||
|
static let groupMessage = SyncTypeFlags(messageTypes: [.groupMessage])
|
||||||
|
|
||||||
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
|
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,602 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The narrow surface `ChatGroupCoordinator` needs from its owner.
|
||||||
|
///
|
||||||
|
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
|
||||||
|
/// minimal context it actually uses instead of holding an `unowned` back-ref
|
||||||
|
/// to the whole `ChatViewModel`. Group chats are keyed like direct chats
|
||||||
|
/// (virtual "group_" peer IDs), so the conversation intents below reuse the
|
||||||
|
/// private-chat store operations.
|
||||||
|
@MainActor
|
||||||
|
protocol ChatGroupContext: AnyObject {
|
||||||
|
// MARK: Identity & state
|
||||||
|
var nickname: String { get }
|
||||||
|
var myPeerID: PeerID { get }
|
||||||
|
var selectedPrivateChatPeer: PeerID? { get }
|
||||||
|
var groupStore: GroupStore { get }
|
||||||
|
|
||||||
|
/// Fingerprint of our own Noise static identity key.
|
||||||
|
func myNoiseFingerprint() -> String
|
||||||
|
/// Our Ed25519 signing public key.
|
||||||
|
func mySigningPublicKey() -> Data
|
||||||
|
/// Signs `data` with our Noise signing key.
|
||||||
|
func signWithNoiseKey(_ data: Data) -> Data?
|
||||||
|
|
||||||
|
// MARK: Peers
|
||||||
|
func getPeerIDForNickname(_ nickname: String) -> PeerID?
|
||||||
|
func isPeerConnected(_ peerID: PeerID) -> Bool
|
||||||
|
func peerNickname(for peerID: PeerID) -> String?
|
||||||
|
/// The peer's Noise fingerprint from the live session/registry.
|
||||||
|
func meshFingerprint(for peerID: PeerID) -> String?
|
||||||
|
/// The peer's persisted crypto identity (fingerprint + signing key), if
|
||||||
|
/// the identity store has a signature-verified announce for them.
|
||||||
|
func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)?
|
||||||
|
/// The connected short peer ID whose fingerprint matches, if any.
|
||||||
|
func connectedPeerID(forFingerprint fingerprint: String) -> PeerID?
|
||||||
|
/// Whether the user has blocked the identity with this Noise fingerprint.
|
||||||
|
func isFingerprintBlocked(_ fingerprint: String) -> Bool
|
||||||
|
|
||||||
|
// MARK: Transport
|
||||||
|
func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID)
|
||||||
|
func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID)
|
||||||
|
func broadcastGroupMessagePayload(_ payload: Data)
|
||||||
|
|
||||||
|
// MARK: Conversation intents (group chats are direct-keyed)
|
||||||
|
@discardableResult
|
||||||
|
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
|
||||||
|
func markPrivateChatUnread(_ peerID: PeerID)
|
||||||
|
func removePrivateChat(_ peerID: PeerID)
|
||||||
|
func startPrivateChat(with peerID: PeerID)
|
||||||
|
func endPrivateChat()
|
||||||
|
func addSystemMessage(_ content: String)
|
||||||
|
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
|
||||||
|
func notifyUIChanged()
|
||||||
|
func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ChatViewModel: ChatGroupContext {
|
||||||
|
// `nickname`, `myPeerID`, `selectedPrivateChatPeer`, `groupStore`,
|
||||||
|
// `getPeerIDForNickname(_:)`, `isPeerConnected(_:)`, `peerNickname(for:)`,
|
||||||
|
// `appendPrivateMessage(_:to:)`, `markPrivateChatUnread(_:)`,
|
||||||
|
// `removePrivateChat(_:)`, `startPrivateChat(with:)`,
|
||||||
|
// `addSystemMessage(_:)`, `addLocalPrivateSystemMessage(_:to:)`,
|
||||||
|
// `notifyUIChanged()`, and `notifyPrivateMessage(from:message:peerID:)`
|
||||||
|
// are shared requirements with the other contexts or satisfied by
|
||||||
|
// existing `ChatViewModel` members. The members below flatten nested
|
||||||
|
// service accesses into intent-named calls.
|
||||||
|
|
||||||
|
func myNoiseFingerprint() -> String {
|
||||||
|
meshService.noiseIdentityFingerprint()
|
||||||
|
}
|
||||||
|
|
||||||
|
func mySigningPublicKey() -> Data {
|
||||||
|
meshService.noiseSigningPublicKeyData()
|
||||||
|
}
|
||||||
|
|
||||||
|
func signWithNoiseKey(_ data: Data) -> Data? {
|
||||||
|
meshService.noiseSignData(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func meshFingerprint(for peerID: PeerID) -> String? {
|
||||||
|
meshService.getFingerprint(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persisted, signature-verified identity behind a short mesh peer
|
||||||
|
/// ID. Cross-checked against the live session fingerprint so a roster
|
||||||
|
/// entry can never be pinned to a signing key from a different identity.
|
||||||
|
func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)? {
|
||||||
|
guard let fingerprint = meshService.getFingerprint(for: peerID) else { return nil }
|
||||||
|
let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
|
||||||
|
guard let identity = candidates.first(where: { $0.fingerprint == fingerprint }),
|
||||||
|
let signingKey = identity.signingPublicKey else { return nil }
|
||||||
|
return (fingerprint, signingKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short mesh peer IDs are the fingerprint's first 16 hex chars, so the
|
||||||
|
/// connected peer for a roster fingerprint is a direct derivation.
|
||||||
|
func connectedPeerID(forFingerprint fingerprint: String) -> PeerID? {
|
||||||
|
let shortID = PeerID(str: String(fingerprint.prefix(16)))
|
||||||
|
return meshService.isPeerConnected(shortID) ? shortID : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFingerprintBlocked(_ fingerprint: String) -> Bool {
|
||||||
|
identityManager.isBlocked(fingerprint: fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) {
|
||||||
|
meshService.sendGroupInvite(payload, to: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) {
|
||||||
|
meshService.sendGroupKeyUpdate(payload, to: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func broadcastGroupMessagePayload(_ payload: Data) {
|
||||||
|
meshService.broadcastGroupMessage(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: CommandContextProvider group commands (parsed by CommandProcessor)
|
||||||
|
|
||||||
|
func groupCreate(named name: String) -> CommandResult {
|
||||||
|
groupCoordinator.createGroup(named: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupInvite(nickname: String) -> CommandResult {
|
||||||
|
groupCoordinator.inviteMember(nickname: nickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupRemove(nickname: String) -> CommandResult {
|
||||||
|
groupCoordinator.removeMember(nickname: nickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupLeave() -> CommandResult {
|
||||||
|
groupCoordinator.leaveGroup()
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupList() -> CommandResult {
|
||||||
|
groupCoordinator.listGroups()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns the private-groups feature: creating groups, creator-managed invites
|
||||||
|
/// and key rotation over Noise, and sealing/opening group message broadcasts.
|
||||||
|
/// Delivery is fire-and-flood like public chat — no per-member acks in v1 —
|
||||||
|
/// with gossip-sync backfill as the only offline catch-up.
|
||||||
|
@MainActor
|
||||||
|
final class ChatGroupCoordinator {
|
||||||
|
private unowned let context: any ChatGroupContext
|
||||||
|
|
||||||
|
private static let maxGroupNameLength = 40
|
||||||
|
|
||||||
|
init(context: any ChatGroupContext) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Commands
|
||||||
|
|
||||||
|
func createGroup(named rawName: String) -> CommandResult {
|
||||||
|
let name = rawName.trimmed
|
||||||
|
guard !name.isEmpty else {
|
||||||
|
return .error(message: String(localized: "system.group.usage_create", comment: "Usage hint for /group create"))
|
||||||
|
}
|
||||||
|
guard name.count <= Self.maxGroupNameLength else {
|
||||||
|
return .error(message: String(localized: "system.group.name_too_long", comment: "Error when a group name exceeds the length cap"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let myFingerprint = context.myNoiseFingerprint()
|
||||||
|
let mySigningKey = context.mySigningPublicKey()
|
||||||
|
guard !myFingerprint.isEmpty, mySigningKey.count == 32 else {
|
||||||
|
return .error(message: String(localized: "system.group.identity_unavailable", comment: "Error when the local identity is not ready for group operations"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let creator = GroupMember(fingerprint: myFingerprint, signingKey: mySigningKey, nickname: context.nickname)
|
||||||
|
guard let group = context.groupStore.createGroup(named: name, creator: creator) else {
|
||||||
|
return .error(message: String(localized: "system.group.create_failed", comment: "Error when group creation fails"))
|
||||||
|
}
|
||||||
|
|
||||||
|
context.startPrivateChat(with: group.peerID)
|
||||||
|
return .success(message: String(
|
||||||
|
format: String(localized: "system.group.created", comment: "System message after creating a group; placeholder is the group name"),
|
||||||
|
locale: .current,
|
||||||
|
name
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func inviteMember(nickname rawNickname: String) -> CommandResult {
|
||||||
|
let nickname = normalizedNickname(rawNickname)
|
||||||
|
guard !nickname.isEmpty else {
|
||||||
|
return .error(message: String(localized: "system.group.usage_invite", comment: "Usage hint for /group invite"))
|
||||||
|
}
|
||||||
|
guard let group = selectedGroup() else {
|
||||||
|
return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat"))
|
||||||
|
}
|
||||||
|
guard group.creatorFingerprint == context.myNoiseFingerprint() else {
|
||||||
|
return .error(message: String(localized: "system.group.creator_only", comment: "Error when a non-creator attempts a creator-only group action"))
|
||||||
|
}
|
||||||
|
guard let peerID = context.getPeerIDForNickname(nickname) else {
|
||||||
|
return .error(message: String(
|
||||||
|
format: String(localized: "system.group.peer_not_found", comment: "Error when the invitee nickname is unknown; placeholder is the nickname"),
|
||||||
|
locale: .current,
|
||||||
|
nickname
|
||||||
|
))
|
||||||
|
}
|
||||||
|
guard context.isPeerConnected(peerID) else {
|
||||||
|
return .error(message: String(
|
||||||
|
format: String(localized: "system.group.peer_not_connected", comment: "Error when the invitee is not connected over mesh; placeholder is the nickname"),
|
||||||
|
locale: .current,
|
||||||
|
nickname
|
||||||
|
))
|
||||||
|
}
|
||||||
|
guard let identity = context.cryptoIdentity(for: peerID) else {
|
||||||
|
return .error(message: String(
|
||||||
|
format: String(localized: "system.group.peer_identity_unknown", comment: "Error when the invitee's verified identity is unavailable; placeholder is the nickname"),
|
||||||
|
locale: .current,
|
||||||
|
nickname
|
||||||
|
))
|
||||||
|
}
|
||||||
|
guard !group.isMember(fingerprint: identity.fingerprint) else {
|
||||||
|
return .error(message: String(
|
||||||
|
format: String(localized: "system.group.already_member", comment: "Error when the invitee is already a member; placeholder is the nickname"),
|
||||||
|
locale: .current,
|
||||||
|
nickname
|
||||||
|
))
|
||||||
|
}
|
||||||
|
guard group.members.count < BitchatGroup.maxMembers else {
|
||||||
|
return .error(message: String(
|
||||||
|
format: String(localized: "system.group.full", comment: "Error when the group is at the member cap; placeholder is the cap"),
|
||||||
|
locale: .current,
|
||||||
|
"\(BitchatGroup.maxMembers)"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
let newMember = GroupMember(
|
||||||
|
fingerprint: identity.fingerprint,
|
||||||
|
signingKey: identity.signingKey,
|
||||||
|
nickname: context.peerNickname(for: peerID) ?? nickname
|
||||||
|
)
|
||||||
|
// Rotate the key (epoch + 1) on every roster change, not just removals.
|
||||||
|
// A monotonically increasing epoch per roster gives the receiver a
|
||||||
|
// strict ordering: two out-of-order invite states can no longer share
|
||||||
|
// an epoch and last-writer-wins a just-added member back out.
|
||||||
|
let members = group.members + [newMember]
|
||||||
|
guard let (updated, key) = context.groupStore.rotateKey(groupID: group.groupID, members: members),
|
||||||
|
let payload = signedStatePayload(for: updated, key: key) else {
|
||||||
|
return .error(message: String(localized: "system.group.invite_failed", comment: "Error when building or signing a group invite fails"))
|
||||||
|
}
|
||||||
|
|
||||||
|
context.sendGroupInvitePayload(payload, to: peerID)
|
||||||
|
distributeState(payload, group: updated, excluding: [identity.fingerprint], type: .keyUpdate)
|
||||||
|
|
||||||
|
return .success(message: String(
|
||||||
|
format: String(localized: "system.group.invited", comment: "System message after inviting someone; placeholders are the nickname and the group name"),
|
||||||
|
locale: .current,
|
||||||
|
nickname,
|
||||||
|
updated.name
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creator-side removal: rotates the group key (epoch + 1) and sends the
|
||||||
|
/// new state to every remaining member so the removed member's key stops
|
||||||
|
/// decrypting future traffic.
|
||||||
|
func removeMember(nickname rawNickname: String) -> CommandResult {
|
||||||
|
let nickname = normalizedNickname(rawNickname)
|
||||||
|
guard !nickname.isEmpty else {
|
||||||
|
return .error(message: String(localized: "system.group.usage_remove", comment: "Usage hint for /group remove"))
|
||||||
|
}
|
||||||
|
guard let group = selectedGroup() else {
|
||||||
|
return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat"))
|
||||||
|
}
|
||||||
|
guard group.creatorFingerprint == context.myNoiseFingerprint() else {
|
||||||
|
return .error(message: String(localized: "system.group.creator_only", comment: "Error when a non-creator attempts a creator-only group action"))
|
||||||
|
}
|
||||||
|
guard let member = group.members.first(where: { $0.nickname.caseInsensitiveCompare(nickname) == .orderedSame }) else {
|
||||||
|
return .error(message: String(
|
||||||
|
format: String(localized: "system.group.member_not_found", comment: "Error when the member to remove is not in the roster; placeholder is the nickname"),
|
||||||
|
locale: .current,
|
||||||
|
nickname
|
||||||
|
))
|
||||||
|
}
|
||||||
|
guard member.fingerprint != group.creatorFingerprint else {
|
||||||
|
return .error(message: String(localized: "system.group.cannot_remove_creator", comment: "Error when the creator tries to remove themselves"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = group.members.filter { $0.fingerprint != member.fingerprint }
|
||||||
|
guard let (rotated, newKey) = context.groupStore.rotateKey(groupID: group.groupID, members: remaining),
|
||||||
|
let payload = signedStatePayload(for: rotated, key: newKey) else {
|
||||||
|
return .error(message: String(localized: "system.group.rotate_failed", comment: "Error when rotating the group key fails"))
|
||||||
|
}
|
||||||
|
|
||||||
|
distributeState(payload, group: rotated, excluding: [], type: .keyUpdate)
|
||||||
|
notifyRemovedMember(member, rotated: rotated)
|
||||||
|
|
||||||
|
return .success(message: String(
|
||||||
|
format: String(localized: "system.group.removed_member", comment: "System message after removing a member and rotating the key; placeholder is the nickname"),
|
||||||
|
locale: .current,
|
||||||
|
member.nickname
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func leaveGroup() -> CommandResult {
|
||||||
|
guard let group = selectedGroup() else {
|
||||||
|
return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat"))
|
||||||
|
}
|
||||||
|
// Close the chat window first so the confirmation message doesn't
|
||||||
|
// resurrect the conversation we're about to remove.
|
||||||
|
context.endPrivateChat()
|
||||||
|
context.removePrivateChat(group.peerID)
|
||||||
|
context.groupStore.removeGroup(withID: group.groupID)
|
||||||
|
context.notifyUIChanged()
|
||||||
|
return .success(message: String(
|
||||||
|
format: String(localized: "system.group.left", comment: "System message after leaving a group; placeholder is the group name"),
|
||||||
|
locale: .current,
|
||||||
|
group.name
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func listGroups() -> CommandResult {
|
||||||
|
let groups = context.groupStore.groups
|
||||||
|
guard !groups.isEmpty else {
|
||||||
|
return .success(message: String(localized: "system.group.none", comment: "System message when the user is in no groups"))
|
||||||
|
}
|
||||||
|
let myFingerprint = context.myNoiseFingerprint()
|
||||||
|
let lines = groups.map { group -> String in
|
||||||
|
let role = group.creatorFingerprint == myFingerprint ? " (creator)" : ""
|
||||||
|
return "#\(group.name)\(role) — \(group.members.count)/\(BitchatGroup.maxMembers)"
|
||||||
|
}
|
||||||
|
return .success(message: String(localized: "system.group.list_header", comment: "Header line for the /group list output") + "\n" + lines.joined(separator: "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sending
|
||||||
|
|
||||||
|
/// Fire-and-flood send: local echo goes straight to `.sent` because group
|
||||||
|
/// messages have no per-member acknowledgments in v1.
|
||||||
|
func sendGroupMessage(_ content: String, to groupPeerID: PeerID) {
|
||||||
|
guard !content.isEmpty, content.count <= InputValidator.Limits.maxMessageLength else { return }
|
||||||
|
guard let group = context.groupStore.group(for: groupPeerID),
|
||||||
|
let key = context.groupStore.key(forGroupID: group.groupID) else {
|
||||||
|
context.addSystemMessage(String(localized: "system.group.unknown", comment: "System message when sending into an unknown group"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageID = UUID().uuidString
|
||||||
|
let timestamp = Date()
|
||||||
|
let payload: Data
|
||||||
|
do {
|
||||||
|
payload = try GroupCrypto.sealMessage(
|
||||||
|
content: content,
|
||||||
|
messageID: messageID,
|
||||||
|
senderNickname: context.nickname,
|
||||||
|
senderSigningKey: context.mySigningPublicKey(),
|
||||||
|
timestampMs: UInt64(timestamp.timeIntervalSince1970 * 1000),
|
||||||
|
groupID: group.groupID,
|
||||||
|
epoch: group.epoch,
|
||||||
|
key: key,
|
||||||
|
sign: { [weak context] data in context?.signWithNoiseKey(data) }
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Failed to seal group message: \(error)", category: .encryption)
|
||||||
|
context.addLocalPrivateSystemMessage(
|
||||||
|
String(localized: "system.group.send_failed", comment: "System message when sealing a group message fails"),
|
||||||
|
to: groupPeerID
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
|
sender: context.nickname,
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: group.name,
|
||||||
|
senderPeerID: context.myPeerID,
|
||||||
|
mentions: nil,
|
||||||
|
deliveryStatus: .sent
|
||||||
|
)
|
||||||
|
context.appendPrivateMessage(message, to: groupPeerID)
|
||||||
|
context.broadcastGroupMessagePayload(payload)
|
||||||
|
context.notifyUIChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Receiving
|
||||||
|
|
||||||
|
/// Decrypt-verify path for an incoming 0x25 broadcast. Drops silently for
|
||||||
|
/// unknown groups (non-members relay but never read), wrong epochs, bad
|
||||||
|
/// sender signatures, and senders missing from the pinned roster.
|
||||||
|
func handleGroupMessagePayload(_ payload: Data, timestamp: Date) {
|
||||||
|
guard let envelope = GroupMessageEnvelope.decode(payload) else { return }
|
||||||
|
guard let group = context.groupStore.group(withID: envelope.groupID) else { return }
|
||||||
|
guard envelope.epoch == group.epoch else {
|
||||||
|
SecureLogger.debug("Dropping group message with epoch \(envelope.epoch) (current \(group.epoch))", category: .encryption)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let key = context.groupStore.key(forGroupID: group.groupID) else { return }
|
||||||
|
|
||||||
|
let plaintext: GroupMessagePlaintext
|
||||||
|
do {
|
||||||
|
plaintext = try GroupCrypto.openMessage(envelope, key: key)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.debug("Failed to open group message: \(error)", category: .encryption)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender must be pinned in the creator-signed roster; key possession
|
||||||
|
// alone is not authorship.
|
||||||
|
guard let member = group.member(withSigningKey: plaintext.senderSigningKey) else {
|
||||||
|
SecureLogger.warning("Dropping group message from non-roster sender", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Our own broadcast echoed back via relay or sync replay.
|
||||||
|
guard plaintext.senderSigningKey != context.mySigningPublicKey() else { return }
|
||||||
|
// Honor /block inside groups too: drop display + notification for a
|
||||||
|
// blocked member, consistent with every other inbound path.
|
||||||
|
guard !context.isFingerprintBlocked(member.fingerprint) else {
|
||||||
|
SecureLogger.debug("Dropping group message from blocked member", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let groupPeerID = group.peerID
|
||||||
|
// Trust the authenticated inner timestamp (clamped so a future-dated
|
||||||
|
// message cannot pin itself to the bottom of the timeline).
|
||||||
|
let messageDate = min(Date(timeIntervalSince1970: TimeInterval(plaintext.timestampMs) / 1000), Date())
|
||||||
|
let senderName = member.nickname.isEmpty ? plaintext.senderNickname : member.nickname
|
||||||
|
let senderPeerID = PeerID(str: String(member.fingerprint.prefix(16)))
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: plaintext.messageID,
|
||||||
|
sender: senderName,
|
||||||
|
content: plaintext.content,
|
||||||
|
timestamp: messageDate,
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: group.name,
|
||||||
|
senderPeerID: senderPeerID,
|
||||||
|
mentions: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
guard context.appendPrivateMessage(message, to: groupPeerID) else { return }
|
||||||
|
|
||||||
|
let isViewing = context.selectedPrivateChatPeer == groupPeerID
|
||||||
|
if !isViewing {
|
||||||
|
context.markPrivateChatUnread(groupPeerID)
|
||||||
|
let isRecent = Date().timeIntervalSince(messageDate) < 30
|
||||||
|
if isRecent {
|
||||||
|
context.notifyPrivateMessage(
|
||||||
|
from: "\(senderName) @ \(group.name)",
|
||||||
|
message: plaintext.content,
|
||||||
|
peerID: groupPeerID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.notifyUIChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepts creator-signed group state arriving as an invite. The Noise
|
||||||
|
/// session peer must BE the creator, the signature must verify against
|
||||||
|
/// the creator key pinned in the roster, and we must be in the roster.
|
||||||
|
func handleGroupInvitePayload(from peerID: PeerID, payload: Data) {
|
||||||
|
applyGroupState(from: peerID, payload: payload, isInvite: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepts creator-signed state updates (rotation/roster). A state whose
|
||||||
|
/// roster no longer includes us means we were removed: drop the group.
|
||||||
|
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
|
||||||
|
applyGroupState(from: peerID, payload: payload, isInvite: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ChatGroupCoordinator {
|
||||||
|
enum StateSendType {
|
||||||
|
case invite
|
||||||
|
case keyUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedNickname(_ raw: String) -> String {
|
||||||
|
let trimmed = raw.trimmed
|
||||||
|
return trimmed.hasPrefix("@") ? String(trimmed.dropFirst()) : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectedGroup() -> BitchatGroup? {
|
||||||
|
guard let selected = context.selectedPrivateChatPeer, selected.isGroup else { return nil }
|
||||||
|
return context.groupStore.group(for: selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedStatePayload(for group: BitchatGroup, key: Data) -> Data? {
|
||||||
|
GroupStatePayload.makeSigned(group: group, key: key) { [weak context] data in
|
||||||
|
context?.signWithNoiseKey(data)
|
||||||
|
}?.encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends the state payload to every connected roster member except us and
|
||||||
|
/// the excluded fingerprints. Offline members catch up the next time the
|
||||||
|
/// creator sends them state (v1 limitation, documented in the PR).
|
||||||
|
func distributeState(_ payload: Data, group: BitchatGroup, excluding excludedFingerprints: Set<String>, type: StateSendType) {
|
||||||
|
let myFingerprint = context.myNoiseFingerprint()
|
||||||
|
for member in group.members {
|
||||||
|
guard member.fingerprint != myFingerprint,
|
||||||
|
!excludedFingerprints.contains(member.fingerprint),
|
||||||
|
let peerID = context.connectedPeerID(forFingerprint: member.fingerprint) else { continue }
|
||||||
|
switch type {
|
||||||
|
case .invite:
|
||||||
|
context.sendGroupInvitePayload(payload, to: peerID)
|
||||||
|
case .keyUpdate:
|
||||||
|
context.sendGroupKeyUpdatePayload(payload, to: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tells a just-removed member they're out so their client can deactivate
|
||||||
|
/// the group instead of silently going dark (dropping every message under
|
||||||
|
/// the epoch it no longer has the key for). The notice is a creator-signed
|
||||||
|
/// state whose roster excludes the removee — their `applyGroupState`
|
||||||
|
/// removal branch fires on the missing-self roster and surfaces the
|
||||||
|
/// "removed from group" system message.
|
||||||
|
///
|
||||||
|
/// It carries a throwaway all-zero key, never the rotated key, so the
|
||||||
|
/// removee cannot decrypt post-removal traffic. State is sent 1:1 over
|
||||||
|
/// authenticated Noise, so no remaining member ever receives this blob
|
||||||
|
/// (and even if one did, its own missing-self check would not match).
|
||||||
|
/// If the removee is offline the notice can't be delivered — same v1
|
||||||
|
/// limitation as any other missed key update, documented in the PR.
|
||||||
|
func notifyRemovedMember(_ removed: GroupMember, rotated: BitchatGroup) {
|
||||||
|
guard let peerID = context.connectedPeerID(forFingerprint: removed.fingerprint) else { return }
|
||||||
|
let throwawayKey = Data(count: BitchatGroup.keyLength)
|
||||||
|
guard let payload = signedStatePayload(for: rotated, key: throwawayKey) else { return }
|
||||||
|
context.sendGroupKeyUpdatePayload(payload, to: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyGroupState(from peerID: PeerID, payload: Data, isInvite: Bool) {
|
||||||
|
guard let state = GroupStatePayload.decode(payload) else {
|
||||||
|
SecureLogger.warning("Malformed group state payload from \(peerID.id.prefix(8))…", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The Noise session already authenticated `peerID`; require that the
|
||||||
|
// authenticated peer IS the creator whose key signed the state, so a
|
||||||
|
// member can't re-invite or rotate on the creator's behalf.
|
||||||
|
guard let senderFingerprint = context.meshFingerprint(for: peerID),
|
||||||
|
senderFingerprint == state.creatorFingerprint else {
|
||||||
|
SecureLogger.warning("Dropping group state from non-creator \(peerID.id.prefix(8))…", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard state.verifyCreatorSignature() else {
|
||||||
|
SecureLogger.warning("Dropping group state with invalid creator signature", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let myFingerprint = context.myNoiseFingerprint()
|
||||||
|
let existing = context.groupStore.group(withID: state.groupID)
|
||||||
|
|
||||||
|
// A creator-signed roster that no longer includes us is a removal.
|
||||||
|
guard state.members.contains(where: { $0.fingerprint == myFingerprint }) else {
|
||||||
|
if let existing {
|
||||||
|
if context.selectedPrivateChatPeer == existing.peerID {
|
||||||
|
context.endPrivateChat()
|
||||||
|
}
|
||||||
|
context.removePrivateChat(existing.peerID)
|
||||||
|
context.groupStore.removeGroup(withID: existing.groupID)
|
||||||
|
context.addSystemMessage(String(
|
||||||
|
format: String(localized: "system.group.removed_from", comment: "System message when removed from a group; placeholder is the group name"),
|
||||||
|
locale: .current,
|
||||||
|
existing.name
|
||||||
|
))
|
||||||
|
context.notifyUIChanged()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never regress the epoch: state travels over live Noise sessions,
|
||||||
|
// so an older epoch here is a stale (or misbehaving) creator device.
|
||||||
|
if let existing, state.epoch < existing.epoch {
|
||||||
|
SecureLogger.warning("Dropping stale group state (epoch \(state.epoch) < \(existing.epoch))", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isNewMembership = existing == nil
|
||||||
|
guard context.groupStore.upsert(state.asGroup, key: state.key) else {
|
||||||
|
SecureLogger.error("Failed to store group state for \(state.name)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isNewMembership {
|
||||||
|
let inviter = state.members.first { $0.fingerprint == state.creatorFingerprint }?.nickname
|
||||||
|
?? context.peerNickname(for: peerID)
|
||||||
|
?? "?"
|
||||||
|
let notice = String(
|
||||||
|
format: String(localized: "system.group.joined", comment: "System message when added to a group; placeholders are the group name and the inviter"),
|
||||||
|
locale: .current,
|
||||||
|
state.name,
|
||||||
|
inviter
|
||||||
|
)
|
||||||
|
context.addSystemMessage(notice)
|
||||||
|
context.markPrivateChatUnread(state.asGroup.peerID)
|
||||||
|
context.notifyPrivateMessage(from: inviter, message: notice, peerID: state.asGroup.peerID)
|
||||||
|
} else if isInvite == false, let existing, state.epoch > existing.epoch {
|
||||||
|
SecureLogger.info("Group '\(state.name)' rotated to epoch \(state.epoch)", category: .session)
|
||||||
|
}
|
||||||
|
context.notifyUIChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -185,6 +185,13 @@ final class ChatLifecycleCoordinator {
|
|||||||
func markPrivateMessagesAsRead(from peerID: PeerID) {
|
func markPrivateMessagesAsRead(from peerID: PeerID) {
|
||||||
context.markChatAsRead(from: peerID)
|
context.markChatAsRead(from: peerID)
|
||||||
|
|
||||||
|
// Group chats are keyed under a virtual group_ peerID; no member IS the
|
||||||
|
// conversation peer, so the receipt loops below (which gate on
|
||||||
|
// senderPeerID == peerID) must never emit a read/delivered receipt for
|
||||||
|
// one. This guard makes that explicit so a future refactor of the
|
||||||
|
// receipt matching can't silently start leaking receipts into groups.
|
||||||
|
guard !peerID.isGroup else { return }
|
||||||
|
|
||||||
if peerID.isGeoDM,
|
if peerID.isGeoDM,
|
||||||
let recipientHex = context.nostrKeyMapping[peerID],
|
let recipientHex = context.nostrKeyMapping[peerID],
|
||||||
case .location(let channel) = context.activeChannel,
|
case .location(let channel) = context.activeChannel,
|
||||||
|
|||||||
@@ -323,6 +323,15 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
func startPrivateChat(with peerID: PeerID) {
|
func startPrivateChat(with peerID: PeerID) {
|
||||||
guard peerID != context.myPeerID else { return }
|
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"
|
let peerNickname = context.peerNickname(for: peerID) ?? "unknown"
|
||||||
|
|
||||||
if context.unifiedIsBlocked(peerID) {
|
if context.unifiedIsBlocked(peerID) {
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ protocol ChatTransportEventContext: AnyObject {
|
|||||||
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data)
|
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data)
|
||||||
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data)
|
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data)
|
||||||
func handleVouchPayload(from peerID: PeerID, payload: Data)
|
func handleVouchPayload(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 {
|
extension ChatViewModel: ChatTransportEventContext {
|
||||||
@@ -134,6 +138,14 @@ extension ChatViewModel: ChatTransportEventContext {
|
|||||||
func handleVouchPayload(from peerID: PeerID, payload: Data) {
|
func handleVouchPayload(from peerID: PeerID, payload: Data) {
|
||||||
vouchCoordinator.handleVouchPayload(from: peerID, payload: payload)
|
vouchCoordinator.handleVouchPayload(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 {
|
final class ChatTransportEventCoordinator {
|
||||||
@@ -379,6 +391,12 @@ private extension ChatTransportEventCoordinator {
|
|||||||
|
|
||||||
case .vouch:
|
case .vouch:
|
||||||
context.handleVouchPayload(from: peerID, payload: payload)
|
context.handleVouchPayload(from: peerID, payload: payload)
|
||||||
|
|
||||||
|
case .groupInvite:
|
||||||
|
context.handleGroupInvitePayload(from: peerID, payload: payload)
|
||||||
|
|
||||||
|
case .groupKeyUpdate:
|
||||||
|
context.handleGroupKeyUpdatePayload(from: peerID, payload: payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
@MainActor
|
@MainActor
|
||||||
var canSendMediaInCurrentContext: Bool {
|
var canSendMediaInCurrentContext: Bool {
|
||||||
if let peer = selectedPrivateChatPeer {
|
if let peer = selectedPrivateChatPeer {
|
||||||
return !(peer.isGeoDM || peer.isGeoChat)
|
// Media transfer is not wired for groups in v1 (sendFilePrivate
|
||||||
|
// rejects the virtual group_ recipient), so keep the affordance off.
|
||||||
|
return !(peer.isGeoDM || peer.isGeoChat || peer.isGroup)
|
||||||
}
|
}
|
||||||
switch activeChannel {
|
switch activeChannel {
|
||||||
case .mesh: return true
|
case .mesh: return true
|
||||||
@@ -178,6 +180,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
|
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
|
||||||
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
|
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
|
||||||
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
||||||
|
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
|
||||||
|
|
||||||
// Computed properties for compatibility
|
// Computed properties for compatibility
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -306,6 +309,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
var nostrRelayManager: NostrRelayManager?
|
var nostrRelayManager: NostrRelayManager?
|
||||||
private let userDefaults = UserDefaults.standard
|
private let userDefaults = UserDefaults.standard
|
||||||
let keychain: KeychainManagerProtocol
|
let keychain: KeychainManagerProtocol
|
||||||
|
/// Private group membership: keys in the keychain, metadata on disk.
|
||||||
|
let groupStore: GroupStore
|
||||||
private let nicknameKey = "bitchat.nickname"
|
private let nicknameKey = "bitchat.nickname"
|
||||||
// Location channel state (macOS supports manual geohash selection)
|
// Location channel state (macOS supports manual geohash selection)
|
||||||
var activeChannel: ChannelID {
|
var activeChannel: ChannelID {
|
||||||
@@ -810,6 +815,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
self.groupStore = GroupStore(keychain: keychain)
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
self.conversations = conversations
|
self.conversations = conversations
|
||||||
@@ -1211,6 +1217,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
// with deleteAllKeychainData above plus the identity reset below.
|
// with deleteAllKeychainData above plus the identity reset below.
|
||||||
PrekeyBundleStore.shared.wipe()
|
PrekeyBundleStore.shared.wipe()
|
||||||
|
|
||||||
|
// Drop private group keys and rosters (keychain + disk)
|
||||||
|
groupStore.wipe()
|
||||||
|
|
||||||
// Identity manager has cleared persisted identity data above
|
// Identity manager has cleared persisted identity data above
|
||||||
|
|
||||||
// Clear autocomplete state
|
// Clear autocomplete state
|
||||||
@@ -1574,6 +1583,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
|
// MARK: - QR Verification API
|
||||||
@MainActor
|
@MainActor
|
||||||
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ extension ChatViewModel {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
|
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)
|
privateConversationCoordinator.sendPrivateMessage(content, to: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -297,7 +297,9 @@ final class NostrInboundPipeline {
|
|||||||
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||||
case .verifyChallenge, .verifyResponse, .vouch:
|
// Group state travels only over mesh Noise sessions in v1; anything
|
||||||
|
// claiming to be group traffic over Nostr is ignored.
|
||||||
|
case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,7 +351,9 @@ final class NostrInboundPipeline {
|
|||||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||||
case .verifyChallenge, .verifyResponse, .vouch:
|
// Group state travels only over mesh Noise sessions in v1; anything
|
||||||
|
// claiming to be group traffic over Nostr is ignored.
|
||||||
|
case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -428,7 +432,9 @@ final class NostrInboundPipeline {
|
|||||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||||
case .verifyChallenge, .verifyResponse, .vouch:
|
// Group state travels only over mesh Noise sessions
|
||||||
|
// in v1; group traffic over Nostr is ignored.
|
||||||
|
case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,6 +221,13 @@ private struct ContentPeopleListView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
GroupChatList(
|
||||||
|
groups: peerListModel.groupRows,
|
||||||
|
onTapGroup: { peerID in
|
||||||
|
peerListModel.startConversation(with: peerID)
|
||||||
|
showSidebar = true
|
||||||
|
}
|
||||||
|
)
|
||||||
MeshPeerList(
|
MeshPeerList(
|
||||||
onTapPeer: { peerID in
|
onTapPeer: { peerID in
|
||||||
peerListModel.startConversation(with: peerID)
|
peerListModel.startConversation(with: peerID)
|
||||||
@@ -455,6 +462,10 @@ private struct ContentPrivateChatSheetView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var privacyCaptionText: String {
|
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,
|
// Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted,
|
||||||
// even though they carry no Noise session status. Mesh DMs earn the
|
// even though they carry no Noise session status. Mesh DMs earn the
|
||||||
// "encrypted" claim only once the Noise handshake has secured.
|
// "encrypted" claim only once the Noise handshake has secured.
|
||||||
@@ -503,30 +514,39 @@ private struct ContentPrivateHeaderInfoButton: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
|
// A group has no single fingerprint to show.
|
||||||
|
guard !headerState.isGroupConversation else { return }
|
||||||
appChromeModel.showFingerprint(for: headerState.headerPeerID)
|
appChromeModel.showFingerprint(for: headerState.headerPeerID)
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
switch headerState.availability {
|
if headerState.isGroupConversation {
|
||||||
case .bluetoothConnected:
|
Image(systemName: "person.3.fill")
|
||||||
Image(systemName: "dot.radiowaves.left.and.right")
|
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(palette.primary)
|
.foregroundColor(palette.primary)
|
||||||
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
|
.accessibilityLabel(String(localized: "content.accessibility.group_chat", comment: "Accessibility label for the group chat indicator"))
|
||||||
case .meshReachable:
|
} else {
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
switch headerState.availability {
|
||||||
.font(.bitchatSystem(size: 14))
|
case .bluetoothConnected:
|
||||||
.foregroundColor(palette.primary)
|
Image(systemName: "dot.radiowaves.left.and.right")
|
||||||
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
|
.font(.bitchatSystem(size: 14))
|
||||||
case .nostrAvailable:
|
.foregroundColor(palette.primary)
|
||||||
Image(systemName: "globe")
|
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
|
||||||
.font(.bitchatSystem(size: 14))
|
case .meshReachable:
|
||||||
.foregroundColor(.purple)
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||||
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
.font(.bitchatSystem(size: 14))
|
||||||
case .offline:
|
.foregroundColor(palette.primary)
|
||||||
// Absence of a glyph was the only offline signal; say it.
|
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
|
||||||
Text("mesh_peers.state.offline")
|
case .nostrAvailable:
|
||||||
.bitchatFont(size: 11)
|
Image(systemName: "globe")
|
||||||
.foregroundColor(palette.secondary)
|
.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)
|
Text(headerState.displayName)
|
||||||
@@ -571,7 +591,9 @@ private struct ContentPrivateHeaderInfoButton: View {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.accessibilityHint(
|
.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)
|
.frame(minHeight: headerHeight)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -162,6 +162,18 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
|
|||||||
func handleVouchPayload(from peerID: PeerID, payload: Data) {
|
func handleVouchPayload(from peerID: PeerID, payload: Data) {
|
||||||
vouchPayloads.append((peerID, payload))
|
vouchPayloads.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
|
// MARK: - Helpers
|
||||||
|
|||||||
@@ -492,4 +492,32 @@ private final class MockCommandContextProvider: CommandContextProvider {
|
|||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
favoriteNotifications.append((peerID, isFavorite))
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,7 +208,10 @@ struct GossipSyncManagerTests {
|
|||||||
#expect(allTypes.contains(.fragment))
|
#expect(allTypes.contains(.fragment))
|
||||||
#expect(allTypes.contains(.fileTransfer))
|
#expect(allTypes.contains(.fileTransfer))
|
||||||
#expect(allTypes.contains(.prekeyBundle))
|
#expect(allTypes.contains(.prekeyBundle))
|
||||||
#expect(decoded.contains { $0.types == .publicMessages })
|
#expect(allTypes.contains(.groupMessage))
|
||||||
|
// The message schedule also asks for group messages (bit 10);
|
||||||
|
// responders that don't know the bit just ignore it.
|
||||||
|
#expect(decoded.contains { $0.types == SyncTypeFlags.publicMessages.union(.groupMessage) })
|
||||||
#expect(decoded.contains { $0.types == .fragment })
|
#expect(decoded.contains { $0.types == .fragment })
|
||||||
#expect(decoded.contains { $0.types == .fileTransfer })
|
#expect(decoded.contains { $0.types == .fileTransfer })
|
||||||
#expect(decoded.contains { $0.types == .prekeyBundle })
|
#expect(decoded.contains { $0.types == .prekeyBundle })
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
//
|
||||||
|
// GroupProtocolTests.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import BitFoundation
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
struct GroupProtocolTests {
|
||||||
|
|
||||||
|
// MARK: - Fixtures
|
||||||
|
|
||||||
|
/// Deterministic member identity: an Ed25519 keypair plus the 64-hex
|
||||||
|
/// fingerprint the roster pins.
|
||||||
|
private struct TestIdentity {
|
||||||
|
let signingKey: Curve25519.Signing.PrivateKey
|
||||||
|
let fingerprint: String
|
||||||
|
|
||||||
|
init(seed: UInt8) {
|
||||||
|
signingKey = Curve25519.Signing.PrivateKey()
|
||||||
|
fingerprint = Data(repeating: seed, count: 32).hexEncodedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
var member: GroupMember {
|
||||||
|
GroupMember(
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
signingKey: signingKey.publicKey.rawRepresentation,
|
||||||
|
nickname: "peer-\(fingerprint.prefix(4))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sign(_ data: Data) -> Data? {
|
||||||
|
try? signingKey.signature(for: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let creator = TestIdentity(seed: 0xC1)
|
||||||
|
private let member = TestIdentity(seed: 0xA2)
|
||||||
|
private let outsider = TestIdentity(seed: 0xE3)
|
||||||
|
|
||||||
|
private let groupID = Data((0..<16).map { UInt8($0) })
|
||||||
|
private let key = Data(repeating: 0x42, count: 32)
|
||||||
|
|
||||||
|
private func makeGroup(extraMembers: [GroupMember] = [], epoch: UInt32 = 1) -> BitchatGroup {
|
||||||
|
BitchatGroup(
|
||||||
|
groupID: groupID,
|
||||||
|
name: "trail crew",
|
||||||
|
epoch: epoch,
|
||||||
|
members: [creator.member, member.member] + extraMembers,
|
||||||
|
creatorFingerprint: creator.fingerprint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - State payload (invite / key update)
|
||||||
|
|
||||||
|
@Test func statePayloadRoundTripAndSignatureVerify() throws {
|
||||||
|
let group = makeGroup()
|
||||||
|
let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign))
|
||||||
|
let encoded = try #require(payload.encode())
|
||||||
|
|
||||||
|
let decoded = try #require(GroupStatePayload.decode(encoded))
|
||||||
|
#expect(decoded == payload)
|
||||||
|
#expect(decoded.groupID == groupID)
|
||||||
|
#expect(decoded.name == "trail crew")
|
||||||
|
#expect(decoded.key == key)
|
||||||
|
#expect(decoded.epoch == 1)
|
||||||
|
#expect(decoded.members == group.members)
|
||||||
|
#expect(decoded.creatorFingerprint == creator.fingerprint)
|
||||||
|
#expect(decoded.verifyCreatorSignature())
|
||||||
|
#expect(decoded.asGroup == group)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func forgedCreatorSignatureIsRejected() throws {
|
||||||
|
let group = makeGroup()
|
||||||
|
// Signed by a member who is in the roster but is NOT the creator.
|
||||||
|
let forged = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: member.sign))
|
||||||
|
#expect(!forged.verifyCreatorSignature())
|
||||||
|
|
||||||
|
// An outsider signing is equally rejected.
|
||||||
|
let outsiderForged = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: outsider.sign))
|
||||||
|
#expect(!outsiderForged.verifyCreatorSignature())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func tamperedStateFailsSignature() throws {
|
||||||
|
let group = makeGroup()
|
||||||
|
let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign))
|
||||||
|
|
||||||
|
// Bumping the epoch invalidates the signature.
|
||||||
|
let epochTampered = GroupStatePayload(
|
||||||
|
groupID: payload.groupID,
|
||||||
|
name: payload.name,
|
||||||
|
key: payload.key,
|
||||||
|
epoch: payload.epoch + 1,
|
||||||
|
members: payload.members,
|
||||||
|
creatorFingerprint: payload.creatorFingerprint,
|
||||||
|
signature: payload.signature
|
||||||
|
)
|
||||||
|
#expect(!epochTampered.verifyCreatorSignature())
|
||||||
|
|
||||||
|
// So does swapping the key.
|
||||||
|
let keyTampered = GroupStatePayload(
|
||||||
|
groupID: payload.groupID,
|
||||||
|
name: payload.name,
|
||||||
|
key: Data(repeating: 0x99, count: 32),
|
||||||
|
epoch: payload.epoch,
|
||||||
|
members: payload.members,
|
||||||
|
creatorFingerprint: payload.creatorFingerprint,
|
||||||
|
signature: payload.signature
|
||||||
|
)
|
||||||
|
#expect(!keyTampered.verifyCreatorSignature())
|
||||||
|
|
||||||
|
// And so does adding a member to the roster.
|
||||||
|
let rosterTampered = GroupStatePayload(
|
||||||
|
groupID: payload.groupID,
|
||||||
|
name: payload.name,
|
||||||
|
key: payload.key,
|
||||||
|
epoch: payload.epoch,
|
||||||
|
members: payload.members + [outsider.member],
|
||||||
|
creatorFingerprint: payload.creatorFingerprint,
|
||||||
|
signature: payload.signature
|
||||||
|
)
|
||||||
|
#expect(!rosterTampered.verifyCreatorSignature())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func creatorMissingFromRosterIsRejected() throws {
|
||||||
|
// State claiming a creator whose fingerprint is not in the roster has
|
||||||
|
// no key to verify against and must fail closed.
|
||||||
|
let group = BitchatGroup(
|
||||||
|
groupID: groupID,
|
||||||
|
name: "orphan",
|
||||||
|
epoch: 1,
|
||||||
|
members: [member.member],
|
||||||
|
creatorFingerprint: creator.fingerprint
|
||||||
|
)
|
||||||
|
guard let rosterBlob = GroupRosterCoding.encode(group.members) else {
|
||||||
|
Issue.record("roster should encode")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let content = GroupStatePayload.signingContent(groupID: groupID, epoch: 1, key: key, rosterBlob: rosterBlob, name: group.name)
|
||||||
|
let payload = GroupStatePayload(
|
||||||
|
groupID: groupID,
|
||||||
|
name: group.name,
|
||||||
|
key: key,
|
||||||
|
epoch: 1,
|
||||||
|
members: group.members,
|
||||||
|
creatorFingerprint: creator.fingerprint,
|
||||||
|
signature: creator.sign(content) ?? Data()
|
||||||
|
)
|
||||||
|
#expect(!payload.verifyCreatorSignature())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rosterCapIsEnforcedOnTheWire() {
|
||||||
|
// 17 members cannot be encoded (hard cap is 16)…
|
||||||
|
let seventeen = (0..<17).map { TestIdentity(seed: UInt8($0 + 1)).member }
|
||||||
|
#expect(GroupRosterCoding.encode(seventeen) == nil)
|
||||||
|
|
||||||
|
// …and a hand-built blob claiming 17 members fails to decode.
|
||||||
|
let sixteen = (0..<16).map { TestIdentity(seed: UInt8($0 + 1)).member }
|
||||||
|
guard var blob = GroupRosterCoding.encode(sixteen) else {
|
||||||
|
Issue.record("16-member roster should encode")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(GroupRosterCoding.decode(blob)?.count == 16)
|
||||||
|
blob[blob.startIndex] = 17
|
||||||
|
#expect(GroupRosterCoding.decode(blob) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Message seal / open
|
||||||
|
|
||||||
|
@Test func messageRoundTrip() throws {
|
||||||
|
let group = makeGroup()
|
||||||
|
let timestampMs: UInt64 = 1_750_000_000_000
|
||||||
|
let sealed = try GroupCrypto.sealMessage(
|
||||||
|
content: "summit at noon",
|
||||||
|
messageID: "msg-1",
|
||||||
|
senderNickname: "alice",
|
||||||
|
senderSigningKey: member.member.signingKey,
|
||||||
|
timestampMs: timestampMs,
|
||||||
|
groupID: groupID,
|
||||||
|
epoch: group.epoch,
|
||||||
|
key: key,
|
||||||
|
sign: member.sign
|
||||||
|
)
|
||||||
|
|
||||||
|
let envelope = try #require(GroupMessageEnvelope.decode(sealed))
|
||||||
|
#expect(envelope.groupID == groupID)
|
||||||
|
#expect(envelope.epoch == group.epoch)
|
||||||
|
|
||||||
|
let plaintext = try GroupCrypto.openMessage(envelope, key: key)
|
||||||
|
#expect(plaintext.messageID == "msg-1")
|
||||||
|
#expect(plaintext.content == "summit at noon")
|
||||||
|
#expect(plaintext.senderNickname == "alice")
|
||||||
|
#expect(plaintext.timestampMs == timestampMs)
|
||||||
|
#expect(plaintext.senderSigningKey == member.member.signingKey)
|
||||||
|
|
||||||
|
// The roster resolves the sender; an outsider's key would not.
|
||||||
|
#expect(group.member(withSigningKey: plaintext.senderSigningKey) != nil)
|
||||||
|
#expect(group.member(withSigningKey: outsider.member.signingKey) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func wrongKeyFailsToDecrypt() throws {
|
||||||
|
let sealed = try GroupCrypto.sealMessage(
|
||||||
|
content: "hi",
|
||||||
|
messageID: "msg-2",
|
||||||
|
senderNickname: "alice",
|
||||||
|
senderSigningKey: member.member.signingKey,
|
||||||
|
timestampMs: 1,
|
||||||
|
groupID: groupID,
|
||||||
|
epoch: 1,
|
||||||
|
key: key,
|
||||||
|
sign: member.sign
|
||||||
|
)
|
||||||
|
let envelope = try #require(GroupMessageEnvelope.decode(sealed))
|
||||||
|
#expect(throws: GroupCryptoError.decryptionFailed) {
|
||||||
|
_ = try GroupCrypto.openMessage(envelope, key: Data(repeating: 0x7F, count: 32))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func epochIsBoundIntoTheCiphertext() throws {
|
||||||
|
// Re-labeling an epoch-1 envelope as epoch 2 must break the AEAD:
|
||||||
|
// a rotated-out member cannot replay old ciphertext into a new epoch.
|
||||||
|
let sealed = try GroupCrypto.sealMessage(
|
||||||
|
content: "hi",
|
||||||
|
messageID: "msg-3",
|
||||||
|
senderNickname: "alice",
|
||||||
|
senderSigningKey: member.member.signingKey,
|
||||||
|
timestampMs: 1,
|
||||||
|
groupID: groupID,
|
||||||
|
epoch: 1,
|
||||||
|
key: key,
|
||||||
|
sign: member.sign
|
||||||
|
)
|
||||||
|
let envelope = try #require(GroupMessageEnvelope.decode(sealed))
|
||||||
|
let relabeled = GroupMessageEnvelope(
|
||||||
|
groupID: envelope.groupID,
|
||||||
|
epoch: 2,
|
||||||
|
nonce: envelope.nonce,
|
||||||
|
ciphertext: envelope.ciphertext
|
||||||
|
)
|
||||||
|
#expect(throws: GroupCryptoError.decryptionFailed) {
|
||||||
|
_ = try GroupCrypto.openMessage(relabeled, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func badSenderSignatureIsRejected() throws {
|
||||||
|
// A key-holder who signs with a key other than the one they claim
|
||||||
|
// (or garbage) is dropped even though decryption succeeds.
|
||||||
|
let sealed = try GroupCrypto.sealMessage(
|
||||||
|
content: "spoof",
|
||||||
|
messageID: "msg-4",
|
||||||
|
senderNickname: "mallory",
|
||||||
|
senderSigningKey: member.member.signingKey, // claims member's key…
|
||||||
|
timestampMs: 1,
|
||||||
|
groupID: groupID,
|
||||||
|
epoch: 1,
|
||||||
|
key: key,
|
||||||
|
sign: outsider.sign // …but signs with the outsider's
|
||||||
|
)
|
||||||
|
let envelope = try #require(GroupMessageEnvelope.decode(sealed))
|
||||||
|
#expect(throws: GroupCryptoError.badSenderSignature) {
|
||||||
|
_ = try GroupCrypto.openMessage(envelope, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func tamperedCiphertextFailsToOpen() throws {
|
||||||
|
let sealed = try GroupCrypto.sealMessage(
|
||||||
|
content: "hi",
|
||||||
|
messageID: "msg-5",
|
||||||
|
senderNickname: "alice",
|
||||||
|
senderSigningKey: member.member.signingKey,
|
||||||
|
timestampMs: 1,
|
||||||
|
groupID: groupID,
|
||||||
|
epoch: 1,
|
||||||
|
key: key,
|
||||||
|
sign: member.sign
|
||||||
|
)
|
||||||
|
let envelope = try #require(GroupMessageEnvelope.decode(sealed))
|
||||||
|
var flipped = envelope.ciphertext
|
||||||
|
flipped[flipped.startIndex] ^= 0x01
|
||||||
|
let tampered = GroupMessageEnvelope(
|
||||||
|
groupID: envelope.groupID,
|
||||||
|
epoch: envelope.epoch,
|
||||||
|
nonce: envelope.nonce,
|
||||||
|
ciphertext: flipped
|
||||||
|
)
|
||||||
|
#expect(throws: GroupCryptoError.decryptionFailed) {
|
||||||
|
_ = try GroupCrypto.openMessage(tampered, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func malformedEnvelopesAreRejected() {
|
||||||
|
#expect(GroupMessageEnvelope.decode(Data()) == nil)
|
||||||
|
#expect(GroupMessageEnvelope.decode(Data([0x01, 0x00])) == nil)
|
||||||
|
#expect(GroupStatePayload.decode(Data([0xFF, 0x00, 0x01])) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Oversize / UTF-8 safety (Codex findings)
|
||||||
|
|
||||||
|
@Test func oversizeMessageContentFailsToSealInsteadOfTruncating() {
|
||||||
|
// A content whose UTF-8 exceeds the 16-bit TLV length must fail to
|
||||||
|
// seal (surfacing send_failed) rather than silently truncate into a
|
||||||
|
// ciphertext recipients would drop.
|
||||||
|
let oversize = String(repeating: "a", count: 70_000)
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try GroupCrypto.sealMessage(
|
||||||
|
content: oversize,
|
||||||
|
messageID: "big",
|
||||||
|
senderNickname: "alice",
|
||||||
|
senderSigningKey: member.member.signingKey,
|
||||||
|
timestampMs: 1,
|
||||||
|
groupID: groupID,
|
||||||
|
epoch: 1,
|
||||||
|
key: key,
|
||||||
|
sign: member.sign
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func multiByteNicknameTruncatesOnScalarBoundary() throws {
|
||||||
|
// 40 euro signs = 120 UTF-8 bytes; a raw 64-byte prefix would split
|
||||||
|
// the 21st scalar and make the roster undecodable. Truncation must
|
||||||
|
// land on a Character boundary so the blob round-trips.
|
||||||
|
let euros = String(repeating: "€", count: 40)
|
||||||
|
let wide = GroupMember(
|
||||||
|
fingerprint: creator.fingerprint,
|
||||||
|
signingKey: creator.member.signingKey,
|
||||||
|
nickname: euros
|
||||||
|
)
|
||||||
|
let blob = try #require(GroupRosterCoding.encode([wide]))
|
||||||
|
let decoded = try #require(GroupRosterCoding.decode(blob))
|
||||||
|
#expect(decoded.count == 1)
|
||||||
|
#expect(Data(decoded[0].nickname.utf8).count <= 64)
|
||||||
|
#expect(decoded[0].nickname.allSatisfy { $0 == "€" })
|
||||||
|
#expect(!decoded[0].nickname.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Signable-bytes forward-proofing
|
||||||
|
|
||||||
|
@Test func creatorSignatureCoversName() throws {
|
||||||
|
let group = makeGroup()
|
||||||
|
let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign))
|
||||||
|
#expect(payload.verifyCreatorSignature())
|
||||||
|
|
||||||
|
// Swapping only the display name must invalidate the creator signature.
|
||||||
|
let renamed = GroupStatePayload(
|
||||||
|
groupID: payload.groupID,
|
||||||
|
name: "totally different name",
|
||||||
|
key: payload.key,
|
||||||
|
epoch: payload.epoch,
|
||||||
|
members: payload.members,
|
||||||
|
creatorFingerprint: payload.creatorFingerprint,
|
||||||
|
signature: payload.signature
|
||||||
|
)
|
||||||
|
#expect(!renamed.verifyCreatorSignature())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func messageSignatureCoversEpoch() {
|
||||||
|
// The signed bytes differ by epoch, so a signature captured at one
|
||||||
|
// epoch cannot verify when re-sealed under a later epoch key.
|
||||||
|
let atEpoch1 = GroupCrypto.messageSigningContent(
|
||||||
|
groupID: groupID, epoch: 1, messageID: "m", timestampMs: 1, content: "x"
|
||||||
|
)
|
||||||
|
let atEpoch2 = GroupCrypto.messageSigningContent(
|
||||||
|
groupID: groupID, epoch: 2, messageID: "m", timestampMs: 1, content: "x"
|
||||||
|
)
|
||||||
|
#expect(atEpoch1 != atEpoch2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
//
|
||||||
|
// GroupStoreTests.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
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]))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,8 +25,10 @@ public enum MessageType: UInt8 {
|
|||||||
case fragment = 0x20 // Single fragment type for large messages
|
case fragment = 0x20 // Single fragment type for large messages
|
||||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||||
|
|
||||||
// 0x23, 0x25-0x28 reserved for in-flight protocol work.
|
// 0x23/0x26-0x28 reserved by other in-flight features.
|
||||||
case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped)
|
case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped)
|
||||||
|
case groupMessage = 0x25 // Group-encrypted broadcast (cleartext group ID, ChaChaPoly body)
|
||||||
|
|
||||||
|
|
||||||
public var description: String {
|
public var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -40,6 +42,7 @@ public enum MessageType: UInt8 {
|
|||||||
case .fragment: return "fragment"
|
case .fragment: return "fragment"
|
||||||
case .fileTransfer: return "fileTransfer"
|
case .fileTransfer: return "fileTransfer"
|
||||||
case .prekeyBundle: return "prekeyBundle"
|
case .prekeyBundle: return "prekeyBundle"
|
||||||
|
case .groupMessage: return "groupMessage"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ public struct PeerID: Equatable, Hashable, Sendable {
|
|||||||
case geoDM = "nostr_"
|
case geoDM = "nostr_"
|
||||||
/// `"nostr:"` (+ 8 characters hex)
|
/// `"nostr:"` (+ 8 characters hex)
|
||||||
case geoChat = "nostr:"
|
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
|
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
|
// MARK: - Noise Public Key Helpers
|
||||||
|
|
||||||
public extension PeerID {
|
public extension PeerID {
|
||||||
@@ -143,6 +162,11 @@ public extension PeerID {
|
|||||||
prefix == .geoDM
|
prefix == .geoDM
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true if `id` starts with "`group_`"
|
||||||
|
var isGroup: Bool {
|
||||||
|
prefix == .group
|
||||||
|
}
|
||||||
|
|
||||||
func toPercentEncoded() -> String {
|
func toPercentEncoded() -> String {
|
||||||
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
|
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user