mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 20:05:22 +00:00
Private groups: creator-managed encrypted group chat over the mesh (#1383)
* Add capability bits to announce TLV Announces now carry an optional capabilities TLV (0x05): a little-endian bitfield with named bits for upcoming features (prekeys, wifiBulk, gateway, groups, board, vouch, meshDiagnostics). Old clients skip the unknown TLV; peers without it decode as nil so features can distinguish "legacy peer" from "advertises nothing". PeerCapabilities lives in BitFoundation with a minimal-length encoding that preserves unknown bits for forward compatibility. Peer capabilities are stored in the BLE peer registry on verified announce and exposed via BLEService.peerCapabilities(_:). The local advertisement set is empty until each feature ships its bit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Private groups: creator-managed encrypted group chat over the mesh Small encrypted crews (hard cap 16) between public broadcast and 1:1 DMs: Protocol - MessageType.groupMessage = 0x25: broadcast packets with a cleartext 16-byte group ID + epoch, ChaCha20-Poly1305 ciphertext (epoch bound as AEAD AAD), inner Ed25519 sender signature over "bitchat-group-msg-v1"|groupID|messageID|timestamp|content - NoisePayloadType.groupInvite = 0x06 / .groupKeyUpdate = 0x07: creator-signed group state (key, epoch, roster) 1:1 over Noise; signature over "bitchat-group-v1"|groupID|epoch|key-hash|roster-hash and the Noise session peer must BE the creator - SyncTypeFlags bit 10 (groupMessage): variable-length LE bitfield widens 1 -> 2 bytes inside the length-prefixed REQUEST_SYNC TLV; old clients ignore unknown bits and answer with types they know - PeerCapabilities.localSupported now advertises .groups Storage - GroupStore: symmetric keys in the keychain, roster/name/epoch as protected JSON in Application Support; wiped in panicClearAllData() Behavior - Non-members relay 0x25 like any broadcast but cannot read it; group messages join gossip-sync backfill with the public-message window - Receivers drop wrong-epoch envelopes, bad sender signatures, and senders missing from the creator-signed roster - Fire-and-flood delivery (no per-member acks in v1) UI - Groups open as chat windows through the private-chat sheet (virtual "group_" peer IDs); groups section in the people sheet; /group create/invite/remove/leave/list commands; invitees get a system message + notification and the group appears in their people sheet Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Private groups: fix TLV truncation, roster downgrade, removal notice, block, media, signable bytes Addresses the Codex review and adversarial-review findings on #1383: - TLV encoding now throws GroupTLVError.valueTooLong instead of clamping to 65535 and truncating, so an oversize group message fails to seal and surfaces send_failed rather than shipping ciphertext recipients drop. - Roster nicknames truncate on a Character boundary (never mid-scalar), so a multi-byte nickname can no longer make the whole signed roster undecodable. - Invites now bump the epoch (rotate the key) like removals, giving every roster change a strictly-increasing epoch so out-of-order invite states no longer last-writer-wins a just-added member back out. - Removing a member now sends them a creator-signed roster-without-them under a throwaway all-zero key (never the rotated key), so their client deactivates the group and surfaces "removed" instead of going silently dark. - /block is enforced in the group receive path: a blocked member's messages are dropped from display and notifications, consistent with every other inbound path. - Media affordances are disabled in group chats (both computed sites) so the composer can't strand a media placeholder that never sends; media-in-groups is a documented v2 item. - Creator signature now covers the group name and the sender signature covers the epoch (wire-format-affecting; needs Android parity before ship). - Explicit isGroup guard in markPrivateMessagesAsRead so read/delivered receipts can never leak into group conversations under a future refactor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
jack
Claude Fable 5
parent
87910541ef
commit
81a10f73f0
@@ -193,7 +193,9 @@ final class ConversationUIModel: ObservableObject {
|
||||
|
||||
private func refreshComputedState() {
|
||||
if let selectedPeerID = privateConversationModel.selectedPeerID {
|
||||
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat)
|
||||
// Media transfer is not wired for groups in v1; keep it off so the
|
||||
// composer can't strand a media placeholder that never sends.
|
||||
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat || selectedPeerID.isGroup)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -29,11 +29,22 @@ struct GeohashPersonRow: Identifiable, Equatable {
|
||||
let isBlocked: Bool
|
||||
}
|
||||
|
||||
struct GroupChatRow: Identifiable, Equatable {
|
||||
let peerID: PeerID
|
||||
let name: String
|
||||
let memberCount: Int
|
||||
let isCreator: Bool
|
||||
let hasUnread: Bool
|
||||
|
||||
var id: String { peerID.id }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PeerListModel: ObservableObject {
|
||||
@Published private(set) var allPeers: [BitchatPeer] = []
|
||||
@Published private(set) var meshRows: [MeshPeerRow] = []
|
||||
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
||||
@Published private(set) var groupRows: [GroupChatRow] = []
|
||||
@Published private(set) var reachableMeshPeerCount = 0
|
||||
@Published private(set) var connectedMeshPeerCount = 0
|
||||
@Published private(set) var visibleGeohashPeerCount = 0
|
||||
@@ -132,6 +143,13 @@ final class PeerListModel: ObservableObject {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.groupStore.$groups
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
peerIdentityStore.$encryptionStatuses
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
@@ -220,22 +238,40 @@ final class PeerListModel: ObservableObject {
|
||||
}
|
||||
|
||||
let geohashPeople = buildGeohashPeople()
|
||||
let groupRows = buildGroupRows()
|
||||
|
||||
self.meshRows = meshRows
|
||||
reachableMeshPeerCount = meshCounts.reachable
|
||||
connectedMeshPeerCount = meshCounts.connected
|
||||
self.geohashPeople = geohashPeople
|
||||
visibleGeohashPeerCount = geohashPeople.count
|
||||
self.groupRows = groupRows
|
||||
renderID = (
|
||||
meshRows.map {
|
||||
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
||||
} +
|
||||
geohashPeople.map {
|
||||
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
|
||||
} +
|
||||
groupRows.map {
|
||||
"group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)"
|
||||
}
|
||||
).joined(separator: "|")
|
||||
}
|
||||
|
||||
private func buildGroupRows() -> [GroupChatRow] {
|
||||
let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint()
|
||||
return chatViewModel.groupStore.groups.map { group in
|
||||
GroupChatRow(
|
||||
peerID: group.peerID,
|
||||
name: group.name,
|
||||
memberCount: group.members.count,
|
||||
isCreator: group.creatorFingerprint == myFingerprint,
|
||||
hasUnread: chatViewModel.hasUnreadMessages(for: group.peerID)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildGeohashPeople() -> [GeohashPersonRow] {
|
||||
let myHex = currentGeohashIdentityHex()
|
||||
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
|
||||
|
||||
@@ -108,7 +108,13 @@ struct PrivateConversationHeaderState: Equatable {
|
||||
let encryptionStatus: EncryptionStatus?
|
||||
|
||||
var supportsFavoriteToggle: Bool {
|
||||
!conversationPeerID.isGeoDM
|
||||
!conversationPeerID.isGeoDM && !conversationPeerID.isGroup
|
||||
}
|
||||
|
||||
/// Group chats have no single peer identity behind the header: no
|
||||
/// fingerprint screen, no per-peer encryption badge.
|
||||
var isGroupConversation: Bool {
|
||||
conversationPeerID.isGroup
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +212,13 @@ final class PrivateConversationModel: ObservableObject {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.groupStore.$groups
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
@@ -229,6 +242,26 @@ final class PrivateConversationModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
|
||||
// Group chats: the "peer" is the whole crew. Name + member count in
|
||||
// the header; availability reads as mesh since group traffic floods
|
||||
// the local mesh, and the per-peer encryption badge does not apply.
|
||||
if conversationPeerID.isGroup {
|
||||
let displayName: String
|
||||
if let group = chatViewModel.groupStore.group(for: conversationPeerID) {
|
||||
displayName = "#\(group.name) (\(group.members.count))"
|
||||
} else {
|
||||
displayName = String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
||||
}
|
||||
return PrivateConversationHeaderState(
|
||||
conversationPeerID: conversationPeerID,
|
||||
headerPeerID: conversationPeerID,
|
||||
displayName: displayName,
|
||||
availability: .meshReachable,
|
||||
isFavorite: false,
|
||||
encryptionStatus: nil
|
||||
)
|
||||
}
|
||||
|
||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||
|
||||
Reference in New Issue
Block a user