mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +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)
|
||||
|
||||
@@ -9408,6 +9408,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.accessibility.group_chat" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Group chat"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.accessibility.jump_to_latest" : {
|
||||
"comment" : "Accessibility label for the jump to latest messages button",
|
||||
"extractionState" : "manual",
|
||||
@@ -15115,6 +15126,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.commands.group" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "create or manage private groups"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.commands.help" : {
|
||||
"comment" : "Description of the /help command in the suggestions panel",
|
||||
"extractionState" : "manual",
|
||||
@@ -18338,6 +18360,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.input.group_placeholder" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "create|invite|leave|list"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.input.placeholder.location" : {
|
||||
"comment" : "Composer placeholder for a public geohash channel, naming it",
|
||||
"extractionState" : "manual",
|
||||
@@ -20069,6 +20102,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.private.caption_group" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "encrypted group · members only"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"encryption.accessibility.establishing" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -24626,6 +24670,50 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups.accessibility.open_group_hint" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Opens the group chat"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups.member_count %@" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "(%@)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups.section.header" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "groups"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups.state.creator" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Creator"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"location_channels.accessibility.add_bookmark" : {
|
||||
"comment" : "Accessibility action name for bookmarking a channel",
|
||||
"extractionState" : "manual",
|
||||
@@ -34155,6 +34243,303 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.already_member" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%@ is already a member"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.cannot_remove_creator" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "the creator cannot be removed"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.create_failed" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "could not create the group"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.created" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "created group '%@' — use /group invite @name to add people"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.creator_only" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "only the group creator can do that"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.full" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "group is full (max %@ members)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.identity_unavailable" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "your identity keys are not ready yet"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.invite_failed" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "could not build the group invite"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.invited" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "invited %1$@ to '%2$@'"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.joined" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "you were added to group '%1$@' by %2$@ — it now appears in your people list"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.left" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "left group '%@'"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.list_header" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "your groups:"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.member_not_found" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "'%@' is not a member of this group"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.name_too_long" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "group names are limited to 40 characters"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.none" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "you are not in any groups — /group create <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" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
|
||||
@@ -16,6 +16,7 @@ enum CommandInfo: String, Identifiable {
|
||||
// suggesting a spelling the processor rejects teaches users dead ends.
|
||||
case block
|
||||
case clear
|
||||
case group
|
||||
case help
|
||||
case hug
|
||||
case message = "msg"
|
||||
@@ -36,6 +37,8 @@ enum CommandInfo: String, Identifiable {
|
||||
switch self {
|
||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace:
|
||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||
case .group:
|
||||
return "<" + String(localized: "content.input.group_placeholder") + ">"
|
||||
case .pay:
|
||||
return "<" + String(localized: "content.input.token_placeholder") + ">"
|
||||
case .clear, .help, .who:
|
||||
@@ -47,6 +50,7 @@ enum CommandInfo: String, Identifiable {
|
||||
switch self {
|
||||
case .block: String(localized: "content.commands.block")
|
||||
case .clear: String(localized: "content.commands.clear")
|
||||
case .group: String(localized: "content.commands.group")
|
||||
case .help: String(localized: "content.commands.help")
|
||||
case .hug: String(localized: "content.commands.hug")
|
||||
case .message: String(localized: "content.commands.message")
|
||||
@@ -70,11 +74,11 @@ enum CommandInfo: String, Identifiable {
|
||||
if !isGeoPublic {
|
||||
commands.append(.pay)
|
||||
}
|
||||
// The processor rejects favorites and mesh diagnostics in geohash
|
||||
// contexts, so only suggest them where they actually work: mesh.
|
||||
// The processor rejects favorites, groups, and mesh diagnostics in
|
||||
// geohash contexts, so only suggest them where they work: mesh.
|
||||
if isGeoPublic || isGeoDM {
|
||||
return commands
|
||||
}
|
||||
return commands + [.favorite, .unfavorite, .ping, .trace]
|
||||
return commands + [.favorite, .unfavorite, .ping, .trace, .group]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,9 @@ enum NoisePayloadType: UInt8 {
|
||||
case privateMessage = 0x01 // Private chat message
|
||||
case readReceipt = 0x02 // Message was read
|
||||
case delivered = 0x03 // Message was delivered
|
||||
// Private groups (0x04/0x05 reserved by other features)
|
||||
case groupInvite = 0x06 // Creator-signed group state (invite)
|
||||
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
||||
// Verification (QR-based OOB binding)
|
||||
case verifyChallenge = 0x10 // Verification challenge
|
||||
case verifyResponse = 0x11 // Verification response
|
||||
@@ -85,6 +88,8 @@ enum NoisePayloadType: UInt8 {
|
||||
case .privateMessage: return "privateMessage"
|
||||
case .readReceipt: return "readReceipt"
|
||||
case .delivered: return "delivered"
|
||||
case .groupInvite: return "groupInvite"
|
||||
case .groupKeyUpdate: return "groupKeyUpdate"
|
||||
case .verifyChallenge: return "verifyChallenge"
|
||||
case .verifyResponse: return "verifyResponse"
|
||||
case .vouch: return "vouch"
|
||||
@@ -119,6 +124,9 @@ protocol BitchatDelegate: AnyObject {
|
||||
// Low-level events for better separation of concerns
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||
|
||||
// Encrypted group broadcast (opaque envelope; decrypted by the group coordinator)
|
||||
func didReceiveGroupMessage(payload: Data, timestamp: Date)
|
||||
|
||||
// Bluetooth state updates for user notifications
|
||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||
@@ -138,6 +146,10 @@ extension BitchatDelegate {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceiveGroupMessage(payload: Data, timestamp: Date) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@ import BitFoundation
|
||||
extension PeerCapabilities {
|
||||
/// Capabilities this build advertises in its announce packets.
|
||||
/// Each feature adds its bit here when it ships.
|
||||
static let localSupported: PeerCapabilities = [.vouch, .prekeys]
|
||||
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
|
||||
switch MessageType(rawValue: packetType) {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
return true
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle:
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1394,6 +1394,48 @@ final class BLEService: NSObject {
|
||||
|
||||
// MARK: QR Verification over Noise
|
||||
|
||||
// MARK: Private Groups
|
||||
|
||||
/// Sends creator-signed group state (invite) 1:1 over the Noise session,
|
||||
/// queueing behind a handshake when none is established yet.
|
||||
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {
|
||||
sendNoisePayload(NoisePayload(type: .groupInvite, data: statePayload).encode(), to: peerID)
|
||||
}
|
||||
|
||||
/// Sends creator-signed group state (key rotation / roster update) 1:1
|
||||
/// over the Noise session.
|
||||
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {
|
||||
sendNoisePayload(NoisePayload(type: .groupKeyUpdate, data: statePayload).encode(), to: peerID)
|
||||
}
|
||||
|
||||
/// Broadcasts a sealed group message (MessageType 0x25) like a public
|
||||
/// message: fire-and-flood with gossip-sync backfill. The outer packet is
|
||||
/// intentionally unsigned — receivers authenticate the sender's Ed25519
|
||||
/// signature inside the ciphertext, which still verifies for backfilled
|
||||
/// copies long after the sender's announce has expired.
|
||||
func broadcastGroupMessage(_ envelope: Data) {
|
||||
guard !envelope.isEmpty else { return }
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.groupMessage.rawValue,
|
||||
senderID: Data(hexString: self.myPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: envelope,
|
||||
signature: nil,
|
||||
ttl: self.messageTTL
|
||||
)
|
||||
// Pre-mark our own broadcast as processed to avoid handling a
|
||||
// relayed self copy.
|
||||
let dedupID = BLESelfBroadcastTracker.dedupID(for: packet)
|
||||
self.messageDeduplicator.markProcessed(dedupID)
|
||||
self.broadcastPacket(packet)
|
||||
// Track our own broadcast for gossip sync
|
||||
self.gossipSyncManager?.onPublicPacketSeen(packet)
|
||||
}
|
||||
}
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||
let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA)
|
||||
sendNoisePayload(payload, to: peerID)
|
||||
@@ -3758,6 +3800,9 @@ extension BLEService {
|
||||
case .courierEnvelope:
|
||||
handleCourierEnvelope(packet, from: peerID)
|
||||
|
||||
case .groupMessage:
|
||||
handleGroupMessage(packet, from: senderID)
|
||||
|
||||
case .prekeyBundle:
|
||||
handlePrekeyBundle(packet, from: senderID)
|
||||
|
||||
@@ -4095,6 +4140,26 @@ extension BLEService {
|
||||
)
|
||||
}
|
||||
|
||||
/// Group broadcasts are opaque ciphertext to this layer: track them for
|
||||
/// gossip backfill and hand the payload to the UI layer, where the group
|
||||
/// coordinator decrypts and authenticates against the roster. Non-members
|
||||
/// still relay (generic broadcast relay path) but never decode.
|
||||
private func handleGroupMessage(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
let isBroadcastRecipient: Bool = {
|
||||
guard let recipient = packet.recipientID else { return true }
|
||||
return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }
|
||||
}()
|
||||
guard isBroadcastRecipient, !packet.payload.isEmpty else { return }
|
||||
|
||||
gossipSyncManager?.onPublicPacketSeen(packet)
|
||||
|
||||
let payload = packet.payload
|
||||
let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
self?.deliverTransportEvent(.groupMessageReceived(payload: payload, timestamp: timestamp))
|
||||
}
|
||||
}
|
||||
|
||||
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
noisePacketHandler.handleHandshake(packet, from: peerID)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,15 @@ protocol CommandContextProvider: AnyObject {
|
||||
/// Toggles the favorite via the unified peer flow, which persists by the
|
||||
/// real noise key and notifies the peer over mesh or Nostr.
|
||||
func toggleFavorite(peerID: PeerID)
|
||||
|
||||
// MARK: - Groups
|
||||
// Group logic lives in `ChatGroupCoordinator`; these forward the parsed
|
||||
// /group subcommands.
|
||||
func groupCreate(named name: String) -> CommandResult
|
||||
func groupInvite(nickname: String) -> CommandResult
|
||||
func groupRemove(nickname: String) -> CommandResult
|
||||
func groupLeave() -> CommandResult
|
||||
func groupList() -> CommandResult
|
||||
}
|
||||
|
||||
/// Processes chat commands in a focused, efficient way
|
||||
@@ -120,6 +129,9 @@ final class CommandProcessor {
|
||||
return handleBlock(args)
|
||||
case "/unblock":
|
||||
return handleUnblock(args)
|
||||
case "/group":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "groups are only for mesh peers in #mesh") }
|
||||
return handleGroup(args)
|
||||
case "/fav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
return handleFavorite(args, add: true)
|
||||
@@ -153,6 +165,9 @@ final class CommandProcessor {
|
||||
/slap @name — slap with a large trout
|
||||
/block @name · /unblock @name
|
||||
/fav @name · /unfav @name — favorites (mesh only)
|
||||
/group create <name> — start an encrypted group
|
||||
/group invite @name · /group remove @name — manage members (creator)
|
||||
/group leave · /group list — leave or list your groups
|
||||
/ping @name — measure round-trip time (mesh only)
|
||||
/trace @name — estimated mesh path (mesh only)
|
||||
/pay <token> — send a cashu ecash token in this chat
|
||||
@@ -362,6 +377,32 @@ final class CommandProcessor {
|
||||
return .error(message: "cannot unblock \(nickname): not found")
|
||||
}
|
||||
|
||||
private static let groupUsage = "usage: /group create <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)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mesh Diagnostics
|
||||
|
||||
private enum MeshPeerResolution {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,9 @@ enum TransportEvent: @unchecked Sendable {
|
||||
case messageReceived(BitchatMessage)
|
||||
case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||
case noisePayloadReceived(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||
/// Encrypted group broadcast (MessageType 0x25). Opaque here — the group
|
||||
/// coordinator decrypts and authenticates against the roster.
|
||||
case groupMessageReceived(payload: Data, timestamp: Date)
|
||||
case peerConnected(PeerID)
|
||||
case peerDisconnected(PeerID)
|
||||
case peerListUpdated([PeerID])
|
||||
@@ -158,6 +161,12 @@ protocol Transport: AnyObject {
|
||||
// transport cannot courier (no connected courier, or unsupported).
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
|
||||
|
||||
// Private groups (mesh transports only): creator-signed state travels
|
||||
// 1:1 over Noise sessions; group messages flood like public broadcasts.
|
||||
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID)
|
||||
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID)
|
||||
func broadcastGroupMessage(_ envelope: Data)
|
||||
|
||||
// Bulletin board (mesh transports only): broadcast a pre-signed board
|
||||
// payload (post or tombstone) so it spreads over relay and gossip sync.
|
||||
func sendBoardPayload(_ payload: Data)
|
||||
@@ -214,6 +223,9 @@ extension Transport {
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {}
|
||||
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
|
||||
func broadcastGroupMessage(_ envelope: Data) {}
|
||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
||||
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
|
||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
|
||||
@@ -259,6 +271,8 @@ extension BitchatDelegate {
|
||||
)
|
||||
case let .noisePayloadReceived(peerID, type, payload, timestamp):
|
||||
didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp)
|
||||
case let .groupMessageReceived(payload, timestamp):
|
||||
didReceiveGroupMessage(payload: payload, timestamp: timestamp)
|
||||
case .peerConnected(let peerID):
|
||||
didConnectToPeer(peerID)
|
||||
case .peerDisconnected(let peerID):
|
||||
|
||||
@@ -73,6 +73,7 @@ final class GossipSyncManager {
|
||||
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||
var fragmentCapacity: Int = 600
|
||||
var fileTransferCapacity: Int = 200
|
||||
var groupMessageCapacity: Int = 200
|
||||
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
|
||||
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
|
||||
var messageSyncIntervalSeconds: TimeInterval = 15.0
|
||||
@@ -106,6 +107,7 @@ final class GossipSyncManager {
|
||||
private var messages = PacketStore()
|
||||
private var fragments = PacketStore()
|
||||
private var fileTransfers = PacketStore()
|
||||
private var groupMessages = PacketStore()
|
||||
private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:]
|
||||
// Latest verified prekey bundle per owner. Unlike announces, bundles are
|
||||
// NOT dropped on leave/stale peer: their whole purpose is reaching a
|
||||
@@ -131,7 +133,13 @@ final class GossipSyncManager {
|
||||
)
|
||||
var schedules: [SyncSchedule] = []
|
||||
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
||||
// Group messages ride the public-message cadence; old clients
|
||||
// ignore the extended bit and answer with announces/messages only.
|
||||
var messageTypes: SyncTypeFlags = .publicMessages
|
||||
if config.groupMessageCapacity > 0 {
|
||||
messageTypes.formUnion(.groupMessage)
|
||||
}
|
||||
schedules.append(SyncSchedule(types: messageTypes, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
||||
}
|
||||
if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))
|
||||
@@ -175,6 +183,9 @@ final class GossipSyncManager {
|
||||
guard let self = self else { return }
|
||||
|
||||
var types: SyncTypeFlags = .publicMessages
|
||||
if self.config.groupMessageCapacity > 0 {
|
||||
types.formUnion(.groupMessage)
|
||||
}
|
||||
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
|
||||
types.formUnion(.fragment)
|
||||
}
|
||||
@@ -201,9 +212,11 @@ final class GossipSyncManager {
|
||||
// messages get the long town-crier window; fragments, file transfers and
|
||||
// announces keep the short one.
|
||||
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
||||
// Group messages share the whole-message window: members off the mesh
|
||||
// for a while should backfill their crew's history like public chat.
|
||||
let maxAgeSeconds: TimeInterval
|
||||
switch packet.type {
|
||||
case MessageType.message.rawValue:
|
||||
case MessageType.message.rawValue, MessageType.groupMessage.rawValue:
|
||||
maxAgeSeconds = config.publicMessageMaxAgeSeconds
|
||||
case MessageType.prekeyBundle.rawValue:
|
||||
maxAgeSeconds = config.prekeyBundleMaxAgeSeconds
|
||||
@@ -262,6 +275,13 @@ final class GossipSyncManager {
|
||||
guard isPacketFresh(packet) else { return }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
|
||||
case .groupMessage:
|
||||
// Opaque ciphertext to non-members; carried and served like any
|
||||
// other broadcast so members get backfill from any relay.
|
||||
guard isBroadcastRecipient else { return }
|
||||
guard isPacketFresh(packet) else { return }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
groupMessages.insert(idHex: idHex, packet: packet, capacity: max(1, config.groupMessageCapacity))
|
||||
case .prekeyBundle:
|
||||
// Callers only feed verified bundles here (own bundles at send
|
||||
// time, peers' after signature verification), so gossip never
|
||||
@@ -454,6 +474,19 @@ final class GossipSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
if requestedTypes.contains(.groupMessage) {
|
||||
let groupPkts = groupMessages.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in groupPkts {
|
||||
if let since, pkt.timestamp < since { continue }
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Like announces, prekey bundles are exempt from the since-cursor:
|
||||
// there is at most one per owner (newer replaces older), so the
|
||||
// resend cost is bounded and a joining peer must be able to learn
|
||||
@@ -505,6 +538,9 @@ final class GossipSyncManager {
|
||||
if types.contains(.fileTransfer) {
|
||||
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
|
||||
}
|
||||
if types.contains(.groupMessage) {
|
||||
candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh))
|
||||
}
|
||||
if types.contains(.prekeyBundle) {
|
||||
for (_, pair) in latestPrekeyBundleByPeer where isPacketFresh(pair.packet) {
|
||||
candidates.append(pair.packet)
|
||||
@@ -574,6 +610,7 @@ final class GossipSyncManager {
|
||||
}
|
||||
fragments.removeExpired(isFresh: isPacketFresh)
|
||||
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
||||
groupMessages.removeExpired(isFresh: isPacketFresh)
|
||||
latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in
|
||||
isPacketFresh(pair.packet)
|
||||
}
|
||||
@@ -677,6 +714,7 @@ final class GossipSyncManager {
|
||||
}
|
||||
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
groupMessages.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,13 @@ struct SyncTypeFlags: OptionSet {
|
||||
case .requestSync: return 6
|
||||
case .fileTransfer: return 7
|
||||
case .boardPost: return 8
|
||||
// Extended bits are compat-safe by construction: `toData()` encodes
|
||||
// the bitfield little-endian with trailing zero bytes trimmed (bit 10
|
||||
// widens the wire form from 1 to 2 bytes inside the length-prefixed
|
||||
// REQUEST_SYNC TLV 0x04), and `decode(_:)` accepts 1...8 bytes while
|
||||
// `type(forBit:)` maps unknown bits to nil — so old clients simply
|
||||
// ignore the group bit and answer with the types they know.
|
||||
case .groupMessage: return 10
|
||||
// Courier envelopes are directed deposits between trusted peers and
|
||||
// must never spread via gossip sync.
|
||||
case .courierEnvelope: return nil
|
||||
@@ -70,6 +77,7 @@ struct SyncTypeFlags: OptionSet {
|
||||
// known type, so old clients ignore board rounds instead of choking.
|
||||
case 8: return .boardPost
|
||||
case 9: return .prekeyBundle
|
||||
case 10: return .groupMessage
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -81,6 +89,7 @@ struct SyncTypeFlags: OptionSet {
|
||||
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
|
||||
static let board = SyncTypeFlags(messageTypes: [.boardPost])
|
||||
static let prekeyBundle = SyncTypeFlags(messageTypes: [.prekeyBundle])
|
||||
static let groupMessage = SyncTypeFlags(messageTypes: [.groupMessage])
|
||||
|
||||
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
|
||||
|
||||
|
||||
@@ -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) {
|
||||
context.markChatAsRead(from: peerID)
|
||||
|
||||
// Group chats are keyed under a virtual group_ peerID; no member IS the
|
||||
// conversation peer, so the receipt loops below (which gate on
|
||||
// senderPeerID == peerID) must never emit a read/delivered receipt for
|
||||
// one. This guard makes that explicit so a future refactor of the
|
||||
// receipt matching can't silently start leaking receipts into groups.
|
||||
guard !peerID.isGroup else { return }
|
||||
|
||||
if peerID.isGeoDM,
|
||||
let recipientHex = context.nostrKeyMapping[peerID],
|
||||
case .location(let channel) = context.activeChannel,
|
||||
|
||||
@@ -323,6 +323,15 @@ final class ChatPeerIdentityCoordinator {
|
||||
func startPrivateChat(with peerID: PeerID) {
|
||||
guard peerID != context.myPeerID else { return }
|
||||
|
||||
// Group chats are virtual conversations: no peer identity, favorites,
|
||||
// handshake, or message consolidation applies — just select the chat.
|
||||
if peerID.isGroup {
|
||||
context.selectedPrivateChatFingerprint = nil
|
||||
context.beginPrivateChatSession(with: peerID)
|
||||
context.markPrivateChatRead(peerID)
|
||||
return
|
||||
}
|
||||
|
||||
let peerNickname = context.peerNickname(for: peerID) ?? "unknown"
|
||||
|
||||
if context.unifiedIsBlocked(peerID) {
|
||||
|
||||
@@ -71,6 +71,10 @@ protocol ChatTransportEventContext: AnyObject {
|
||||
// MARK: Verification payloads
|
||||
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data)
|
||||
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data)
|
||||
|
||||
// MARK: Group payloads (creator-signed state over Noise)
|
||||
func handleGroupInvitePayload(from peerID: PeerID, payload: Data)
|
||||
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data)
|
||||
func handleVouchPayload(from peerID: PeerID, payload: Data)
|
||||
}
|
||||
|
||||
@@ -131,6 +135,14 @@ extension ChatViewModel: ChatTransportEventContext {
|
||||
verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload)
|
||||
}
|
||||
|
||||
func handleGroupInvitePayload(from peerID: PeerID, payload: Data) {
|
||||
groupCoordinator.handleGroupInvitePayload(from: peerID, payload: payload)
|
||||
}
|
||||
|
||||
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
|
||||
groupCoordinator.handleGroupKeyUpdatePayload(from: peerID, payload: payload)
|
||||
}
|
||||
|
||||
func handleVouchPayload(from peerID: PeerID, payload: Data) {
|
||||
vouchCoordinator.handleVouchPayload(from: peerID, payload: payload)
|
||||
}
|
||||
@@ -377,6 +389,12 @@ private extension ChatTransportEventCoordinator {
|
||||
case .verifyResponse:
|
||||
context.handleVerifyResponsePayload(from: peerID, payload: payload)
|
||||
|
||||
case .groupInvite:
|
||||
context.handleGroupInvitePayload(from: peerID, payload: payload)
|
||||
|
||||
case .groupKeyUpdate:
|
||||
context.handleGroupKeyUpdatePayload(from: peerID, payload: payload)
|
||||
|
||||
case .vouch:
|
||||
context.handleVouchPayload(from: peerID, payload: payload)
|
||||
}
|
||||
|
||||
@@ -102,7 +102,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
@MainActor
|
||||
var canSendMediaInCurrentContext: Bool {
|
||||
if let peer = selectedPrivateChatPeer {
|
||||
return !(peer.isGeoDM || peer.isGeoChat)
|
||||
// Media transfer is not wired for groups in v1 (sendFilePrivate
|
||||
// rejects the virtual group_ recipient), so keep the affordance off.
|
||||
return !(peer.isGeoDM || peer.isGeoChat || peer.isGroup)
|
||||
}
|
||||
switch activeChannel {
|
||||
case .mesh: return true
|
||||
@@ -177,6 +179,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
||||
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
|
||||
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
|
||||
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
|
||||
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
||||
|
||||
// Computed properties for compatibility
|
||||
@@ -306,6 +309,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
var nostrRelayManager: NostrRelayManager?
|
||||
private let userDefaults = UserDefaults.standard
|
||||
let keychain: KeychainManagerProtocol
|
||||
/// Private group membership: keys in the keychain, metadata on disk.
|
||||
let groupStore: GroupStore
|
||||
private let nicknameKey = "bitchat.nickname"
|
||||
// Location channel state (macOS supports manual geohash selection)
|
||||
var activeChannel: ChannelID {
|
||||
@@ -813,6 +818,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
)
|
||||
|
||||
self.keychain = keychain
|
||||
self.groupStore = GroupStore(keychain: keychain)
|
||||
self.idBridge = idBridge
|
||||
self.identityManager = identityManager
|
||||
self.conversations = conversations
|
||||
@@ -1209,6 +1215,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
GossipMessageArchive.wipeDefault()
|
||||
StoreAndForwardMetrics.shared.reset()
|
||||
|
||||
// Drop private group keys and rosters (keychain + disk)
|
||||
groupStore.wipe()
|
||||
// Drop cached peers' prekey bundles (who we could write to is
|
||||
// metadata too). Our own prekey privates are keychain-backed and go
|
||||
// with deleteAllKeychainData above plus the identity reset below.
|
||||
@@ -1609,6 +1617,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
)
|
||||
}
|
||||
|
||||
func didReceiveGroupMessage(payload: Data, timestamp: Date) {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.groupCoordinator.handleGroupMessagePayload(payload, timestamp: timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - QR Verification API
|
||||
@MainActor
|
||||
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
||||
|
||||
@@ -13,6 +13,12 @@ extension ChatViewModel {
|
||||
|
||||
@MainActor
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
|
||||
// Group chats reuse the private-chat surface but broadcast a sealed
|
||||
// envelope instead of routing to a single peer.
|
||||
if peerID.isGroup {
|
||||
groupCoordinator.sendGroupMessage(content, to: peerID)
|
||||
return
|
||||
}
|
||||
privateConversationCoordinator.sendPrivateMessage(content, to: peerID)
|
||||
}
|
||||
|
||||
|
||||
@@ -303,7 +303,9 @@ final class NostrInboundPipeline {
|
||||
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .readReceipt:
|
||||
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .verifyChallenge, .verifyResponse, .vouch:
|
||||
// Group state travels only over mesh Noise sessions in v1; anything
|
||||
// claiming to be group traffic over Nostr is ignored.
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -355,7 +357,9 @@ final class NostrInboundPipeline {
|
||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .readReceipt:
|
||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .verifyChallenge, .verifyResponse, .vouch:
|
||||
// Group state travels only over mesh Noise sessions in v1; anything
|
||||
// claiming to be group traffic over Nostr is ignored.
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -434,7 +438,9 @@ final class NostrInboundPipeline {
|
||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .readReceipt:
|
||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .verifyChallenge, .verifyResponse, .vouch:
|
||||
// Group state travels only over mesh Noise sessions
|
||||
// in v1; group traffic over Nostr is ignored.
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +221,13 @@ private struct ContentPeopleListView: View {
|
||||
}
|
||||
)
|
||||
} else {
|
||||
GroupChatList(
|
||||
groups: peerListModel.groupRows,
|
||||
onTapGroup: { peerID in
|
||||
peerListModel.startConversation(with: peerID)
|
||||
showSidebar = true
|
||||
}
|
||||
)
|
||||
MeshPeerList(
|
||||
onTapPeer: { peerID in
|
||||
peerListModel.startConversation(with: peerID)
|
||||
@@ -455,6 +462,10 @@ private struct ContentPrivateChatSheetView: View {
|
||||
}
|
||||
|
||||
private var privacyCaptionText: String {
|
||||
// Group chats are ChaCha20-Poly1305 sealed to the roster's shared key.
|
||||
if privateConversationModel.selectedPeerID?.isGroup == true {
|
||||
return String(localized: "content.private.caption_group", comment: "Caption above the group chat composer noting messages are encrypted to group members")
|
||||
}
|
||||
// Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted,
|
||||
// even though they carry no Noise session status. Mesh DMs earn the
|
||||
// "encrypted" claim only once the Noise handshake has secured.
|
||||
@@ -503,30 +514,39 @@ private struct ContentPrivateHeaderInfoButton: View {
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
// A group has no single fingerprint to show.
|
||||
guard !headerState.isGroupConversation else { return }
|
||||
appChromeModel.showFingerprint(for: headerState.headerPeerID)
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
switch headerState.availability {
|
||||
case .bluetoothConnected:
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
if headerState.isGroupConversation {
|
||||
Image(systemName: "person.3.fill")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(palette.primary)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
|
||||
case .meshReachable:
|
||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(palette.primary)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
|
||||
case .nostrAvailable:
|
||||
Image(systemName: "globe")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(.purple)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
||||
case .offline:
|
||||
// Absence of a glyph was the only offline signal; say it.
|
||||
Text("mesh_peers.state.offline")
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.group_chat", comment: "Accessibility label for the group chat indicator"))
|
||||
} else {
|
||||
switch headerState.availability {
|
||||
case .bluetoothConnected:
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(palette.primary)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
|
||||
case .meshReachable:
|
||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(palette.primary)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
|
||||
case .nostrAvailable:
|
||||
Image(systemName: "globe")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(.purple)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
||||
case .offline:
|
||||
// Absence of a glyph was the only offline signal; say it.
|
||||
Text("mesh_peers.state.offline")
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Text(headerState.displayName)
|
||||
@@ -571,7 +591,9 @@ private struct ContentPrivateHeaderInfoButton: View {
|
||||
)
|
||||
)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
|
||||
headerState.isGroupConversation
|
||||
? ""
|
||||
: String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
|
||||
)
|
||||
.frame(minHeight: headerHeight)
|
||||
}
|
||||
|
||||
@@ -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: ", ")
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,18 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
|
||||
verifyResponsePayloads.append((peerID, payload))
|
||||
}
|
||||
|
||||
// Group payloads
|
||||
private(set) var groupInvitePayloads: [(peerID: PeerID, payload: Data)] = []
|
||||
private(set) var groupKeyUpdatePayloads: [(peerID: PeerID, payload: Data)] = []
|
||||
|
||||
func handleGroupInvitePayload(from peerID: PeerID, payload: Data) {
|
||||
groupInvitePayloads.append((peerID, payload))
|
||||
}
|
||||
|
||||
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
|
||||
groupKeyUpdatePayloads.append((peerID, payload))
|
||||
}
|
||||
|
||||
private(set) var vouchPayloads: [(peerID: PeerID, payload: Data)] = []
|
||||
|
||||
func handleVouchPayload(from peerID: PeerID, payload: Data) {
|
||||
|
||||
@@ -678,4 +678,32 @@ private final class MockCommandContextProvider: CommandContextProvider {
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
favoriteNotifications.append((peerID, isFavorite))
|
||||
}
|
||||
|
||||
// Groups: record the parsed subcommand + argument the processor forwarded.
|
||||
private(set) var groupCommands: [(subcommand: String, argument: String)] = []
|
||||
|
||||
func groupCreate(named name: String) -> CommandResult {
|
||||
groupCommands.append(("create", name))
|
||||
return .handled
|
||||
}
|
||||
|
||||
func groupInvite(nickname: String) -> CommandResult {
|
||||
groupCommands.append(("invite", nickname))
|
||||
return .handled
|
||||
}
|
||||
|
||||
func groupRemove(nickname: String) -> CommandResult {
|
||||
groupCommands.append(("remove", nickname))
|
||||
return .handled
|
||||
}
|
||||
|
||||
func groupLeave() -> CommandResult {
|
||||
groupCommands.append(("leave", ""))
|
||||
return .handled
|
||||
}
|
||||
|
||||
func groupList() -> CommandResult {
|
||||
groupCommands.append(("list", ""))
|
||||
return .handled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +208,10 @@ struct GossipSyncManagerTests {
|
||||
#expect(allTypes.contains(.fragment))
|
||||
#expect(allTypes.contains(.fileTransfer))
|
||||
#expect(allTypes.contains(.prekeyBundle))
|
||||
#expect(decoded.contains { $0.types == .publicMessages })
|
||||
#expect(allTypes.contains(.groupMessage))
|
||||
// The message schedule also asks for group messages (bit 10);
|
||||
// responders that don't know the bit just ignore it.
|
||||
#expect(decoded.contains { $0.types == SyncTypeFlags.publicMessages.union(.groupMessage) })
|
||||
#expect(decoded.contains { $0.types == .fragment })
|
||||
#expect(decoded.contains { $0.types == .fileTransfer })
|
||||
#expect(decoded.contains { $0.types == .prekeyBundle })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -277,6 +277,11 @@ private final class DiagnosticsMockContext: CommandContextProvider {
|
||||
func clearPrivateChat(_ peerID: PeerID) {}
|
||||
func sendPublicRaw(_ content: String) {}
|
||||
func sendPublicMessage(_ content: String) {}
|
||||
func groupCreate(named name: String) -> CommandResult { .handled }
|
||||
func groupInvite(nickname: String) -> CommandResult { .handled }
|
||||
func groupRemove(nickname: String) -> CommandResult { .handled }
|
||||
func groupLeave() -> CommandResult { .handled }
|
||||
func groupList() -> CommandResult { .handled }
|
||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {}
|
||||
func addPublicSystemMessage(_ content: String) {}
|
||||
func toggleFavorite(peerID: PeerID) {}
|
||||
|
||||
@@ -38,19 +38,19 @@ struct SyncTypeFlagsBoardTests {
|
||||
/// decode path accepts the bytes and simply maps unknown bits to no
|
||||
/// message type, so a board-only request reads as "nothing I can serve".
|
||||
@Test func unknownBitsDecodeToNoTypes() throws {
|
||||
// Bits 10-15 are unassigned (bit 8 = board, bit 9 = prekeyBundle); a
|
||||
// future (or unknown) two-byte bitfield must decode without error and
|
||||
// yield no known types.
|
||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xFC])))
|
||||
// Bits 11-15 are unassigned (bit 8 = board, bit 9 = prekeyBundle,
|
||||
// bit 10 = groupMessage); a future (or unknown) two-byte bitfield must
|
||||
// decode without error and yield no known types.
|
||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xF8])))
|
||||
#expect(decoded.toMessageTypes().isEmpty)
|
||||
for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost, .prekeyBundle] {
|
||||
for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost, .prekeyBundle, .groupMessage] {
|
||||
#expect(!decoded.contains(type))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func mixedKnownAndUnknownBitsKeepKnownTypes() throws {
|
||||
// Known low-byte flags survive alongside unknown high bits (10-15).
|
||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xFC])))
|
||||
// Known low-byte flags survive alongside unknown high bits (11-15).
|
||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xF8])))
|
||||
#expect(decoded.contains(.announce))
|
||||
#expect(decoded.contains(.message))
|
||||
#expect(Set(decoded.toMessageTypes()) == Set([.announce, .message]))
|
||||
|
||||
@@ -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]))
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,10 @@ struct SyncTypeFlagsTests {
|
||||
}
|
||||
|
||||
@Test func decodeDropsPhantomBits() {
|
||||
// Bits 10+ map to no message type (bit 8 = boardPost, bit 9 =
|
||||
// prekeyBundle). They must not survive decode as phantom membership.
|
||||
let phantom = Data([0x00, 0xFC]) // bits 10..15 set, no known type
|
||||
// Bits 11+ map to no message type (bit 8 = boardPost, bit 9 =
|
||||
// prekeyBundle, bit 10 = groupMessage). They must not survive decode
|
||||
// as phantom membership.
|
||||
let phantom = Data([0x00, 0xF8]) // bits 11..15 set, no known type
|
||||
let decoded = SyncTypeFlags.decode(phantom)
|
||||
#expect(decoded?.rawValue == 0)
|
||||
#expect(decoded?.toMessageTypes().isEmpty == true)
|
||||
@@ -23,17 +24,18 @@ struct SyncTypeFlagsTests {
|
||||
|
||||
@Test func boardBitSurvivesDecode() {
|
||||
// Bit 8 maps to boardPost and spills the field into a second byte;
|
||||
// it must survive decode while the phantom high bits (10+) are
|
||||
// stripped. Bit 9 (prekeyBundle) is cleared to isolate the board bit.
|
||||
let mixed = Data([0x00, 0xFD]) // bit 8 (board) known, bits 10..15 phantom
|
||||
// it must survive decode while the phantom high bits (11+) are
|
||||
// stripped. Bits 9 (prekeyBundle) and 10 (groupMessage) are cleared
|
||||
// to isolate the board bit.
|
||||
let mixed = Data([0x00, 0xF9]) // bit 8 (board) known, bits 11..15 phantom
|
||||
let decoded = SyncTypeFlags.decode(mixed)
|
||||
#expect(decoded?.contains(.board) == true)
|
||||
#expect(decoded?.rawValue == 0b1_0000_0000)
|
||||
}
|
||||
|
||||
@Test func phantomBitsAreStrippedButKnownBitsSurvive() {
|
||||
// Low byte = announce(0) + message(1); high byte bits 10+ are phantom.
|
||||
let mixed = Data([0b0000_0011, 0xFC])
|
||||
// Low byte = announce(0) + message(1); high byte bits 11+ are phantom.
|
||||
let mixed = Data([0b0000_0011, 0xF8])
|
||||
let decoded = SyncTypeFlags.decode(mixed)
|
||||
#expect(decoded?.contains(.announce) == true)
|
||||
#expect(decoded?.contains(.message) == true)
|
||||
|
||||
@@ -26,6 +26,7 @@ public enum MessageType: UInt8 {
|
||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||
case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone
|
||||
case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped)
|
||||
case groupMessage = 0x25 // Group-encrypted broadcast (cleartext group ID, ChaChaPoly body)
|
||||
|
||||
// Mesh diagnostics
|
||||
case ping = 0x26 // Directed echo request (nonce + origin TTL)
|
||||
@@ -48,6 +49,7 @@ public enum MessageType: UInt8 {
|
||||
case .fileTransfer: return "fileTransfer"
|
||||
case .boardPost: return "boardPost"
|
||||
case .prekeyBundle: return "prekeyBundle"
|
||||
case .groupMessage: return "groupMessage"
|
||||
case .ping: return "ping"
|
||||
case .pong: return "pong"
|
||||
case .nostrCarrier: return "nostrCarrier"
|
||||
|
||||
@@ -34,6 +34,9 @@ public struct PeerID: Equatable, Hashable, Sendable {
|
||||
case geoDM = "nostr_"
|
||||
/// `"nostr:"` (+ 8 characters hex)
|
||||
case geoChat = "nostr:"
|
||||
/// `"group_"` (+ 32 characters hex) — virtual conversation ID for a
|
||||
/// private group (16-byte group ID). Never routed to a single peer.
|
||||
case group = "group_"
|
||||
}
|
||||
|
||||
public let prefix: Prefix
|
||||
@@ -96,6 +99,22 @@ public extension PeerID {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Group Conversation Helpers
|
||||
|
||||
public extension PeerID {
|
||||
/// Convenience init to create a virtual group conversation PeerID from a
|
||||
/// 16-byte group ID ("group_" + 32 hex characters).
|
||||
init(groupID: Data) {
|
||||
self.init(str: Prefix.group.rawValue + groupID.hexEncodedString())
|
||||
}
|
||||
|
||||
/// The 16-byte group ID behind a "group_" PeerID, if this is one.
|
||||
var groupIDData: Data? {
|
||||
guard isGroup, bare.count == 32 else { return nil }
|
||||
return Data(hexString: bare)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise Public Key Helpers
|
||||
|
||||
public extension PeerID {
|
||||
@@ -143,6 +162,11 @@ public extension PeerID {
|
||||
prefix == .geoDM
|
||||
}
|
||||
|
||||
/// Returns true if `id` starts with "`group_`"
|
||||
var isGroup: Bool {
|
||||
prefix == .group
|
||||
}
|
||||
|
||||
func toPercentEncoded() -> String {
|
||||
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user