Merge remote-tracking branch 'origin/feat/private-groups' into feat/integration-all

# Conflicts:
#	bitchat/Protocols/BitchatProtocol.swift
#	bitchat/Protocols/PeerCapabilities+Local.swift
#	bitchat/Services/BLE/BLEOutboundPacketPolicy.swift
#	bitchat/Services/BLE/BLEService.swift
#	bitchat/Services/Transport.swift
#	bitchat/Sync/GossipSyncManager.swift
#	bitchat/Sync/SyncTypeFlags.swift
#	bitchat/ViewModels/ChatTransportEventCoordinator.swift
#	bitchat/ViewModels/ChatViewModel.swift
#	bitchat/ViewModels/NostrInboundPipeline.swift
#	bitchatTests/ChatTransportEventCoordinatorContextTests.swift
#	bitchatTests/GossipSyncManagerTests.swift
#	localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift
This commit is contained in:
jack
2026-07-06 22:13:31 +02:00
32 changed files with 2887 additions and 36 deletions
+3 -1
View File
@@ -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
}
+36
View File
@@ -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() })
+34 -1
View File
@@ -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)
+385
View File
@@ -9348,6 +9348,17 @@
}
}
},
"content.accessibility.group_chat" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Group chat"
}
}
}
},
"content.accessibility.jump_to_latest" : {
"comment" : "Accessibility label for the jump to latest messages button",
"extractionState" : "manual",
@@ -15055,6 +15066,17 @@
}
}
},
"content.commands.group" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "create or manage private groups"
}
}
}
},
"content.commands.help" : {
"comment" : "Description of the /help command in the suggestions panel",
"extractionState" : "manual",
@@ -18230,6 +18252,17 @@
}
}
},
"content.input.group_placeholder" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "create|invite|leave|list"
}
}
}
},
"content.input.placeholder.location" : {
"comment" : "Composer placeholder for a public geohash channel, naming it",
"extractionState" : "manual",
@@ -19913,6 +19946,17 @@
}
}
},
"content.private.caption_group" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "encrypted group · members only"
}
}
}
},
"encryption.accessibility.establishing" : {
"extractionState" : "manual",
"localizations" : {
@@ -24470,6 +24514,50 @@
}
}
},
"groups.accessibility.open_group_hint" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Opens the group chat"
}
}
}
},
"groups.member_count %@" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "(%@)"
}
}
}
},
"groups.section.header" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "groups"
}
}
}
},
"groups.state.creator" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Creator"
}
}
}
},
"location_channels.accessibility.add_bookmark" : {
"comment" : "Accessibility action name for bookmarking a channel",
"extractionState" : "manual",
@@ -33963,6 +34051,303 @@
}
}
},
"system.group.already_member" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ is already a member"
}
}
}
},
"system.group.cannot_remove_creator" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "the creator cannot be removed"
}
}
}
},
"system.group.create_failed" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "could not create the group"
}
}
}
},
"system.group.created" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "created group '%@' — use /group invite @name to add people"
}
}
}
},
"system.group.creator_only" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "only the group creator can do that"
}
}
}
},
"system.group.full" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "group is full (max %@ members)"
}
}
}
},
"system.group.identity_unavailable" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "your identity keys are not ready yet"
}
}
}
},
"system.group.invite_failed" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "could not build the group invite"
}
}
}
},
"system.group.invited" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "invited %1$@ to '%2$@'"
}
}
}
},
"system.group.joined" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "you were added to group '%1$@' by %2$@ — it now appears in your people list"
}
}
}
},
"system.group.left" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "left group '%@'"
}
}
}
},
"system.group.list_header" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "your groups:"
}
}
}
},
"system.group.member_not_found" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "'%@' is not a member of this group"
}
}
}
},
"system.group.name_too_long" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "group names are limited to 40 characters"
}
}
}
},
"system.group.none" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "you are not in any groups — /group create <name> to start one"
}
}
}
},
"system.group.not_in_group" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "open a group chat first"
}
}
}
},
"system.group.peer_identity_unknown" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "cannot verify %@'s identity yet — wait for their announce"
}
}
}
},
"system.group.peer_not_connected" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ must be connected over mesh to be invited"
}
}
}
},
"system.group.peer_not_found" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "'%@' not found"
}
}
}
},
"system.group.removed_from" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "you were removed from group '%@'"
}
}
}
},
"system.group.removed_member" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "removed %@ and rotated the group key"
}
}
}
},
"system.group.rotate_failed" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "could not rotate the group key"
}
}
}
},
"system.group.send_failed" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "could not encrypt the message"
}
}
}
},
"system.group.unknown" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "you are no longer in this group"
}
}
}
},
"system.group.usage_create" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "usage: /group create <name>"
}
}
}
},
"system.group.usage_invite" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "usage: /group invite @name"
}
}
}
},
"system.group.usage_remove" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "usage: /group remove @name"
}
}
}
},
"system.location.not_in_channel" : {
"extractionState" : "manual",
"localizations" : {
+7 -3
View File
@@ -16,6 +16,7 @@ enum CommandInfo: String, Identifiable {
// suggesting a spelling the processor rejects teaches users dead ends.
case block
case clear
case group
case help
case hug
case message = "msg"
@@ -33,6 +34,8 @@ enum CommandInfo: String, Identifiable {
switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .group:
return "<" + String(localized: "content.input.group_placeholder") + ">"
case .clear, .help, .who:
return nil
}
@@ -42,6 +45,7 @@ enum CommandInfo: String, Identifiable {
switch self {
case .block: String(localized: "content.commands.block")
case .clear: String(localized: "content.commands.clear")
case .group: String(localized: "content.commands.group")
case .help: String(localized: "content.commands.help")
case .hug: String(localized: "content.commands.hug")
case .message: String(localized: "content.commands.message")
@@ -55,11 +59,11 @@ enum CommandInfo: String, Identifiable {
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
// The processor rejects favorites in geohash contexts, so only
// suggest them where they actually work: mesh.
// The processor rejects favorites and groups in geohash contexts, so
// only suggest them where they actually work: mesh.
if isGeoPublic || isGeoDM {
return baseCommands
}
return baseCommands + [.favorite, .unfavorite]
return baseCommands + [.favorite, .unfavorite, .group]
}
}
+12
View File
@@ -72,6 +72,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
@@ -83,6 +86,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"
@@ -117,6 +122,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?)
@@ -136,6 +144,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 = [.prekeys, .vouch]
static let localSupported: PeerCapabilities = [.prekeys, .vouch, .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, .prekeyBundle:
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .prekeyBundle, .groupMessage:
return false
}
}
+65
View File
@@ -1330,6 +1330,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)
@@ -3447,6 +3489,9 @@ extension BLEService {
case .prekeyBundle:
handlePrekeyBundle(packet, from: senderID)
case .groupMessage:
handleGroupMessage(packet, from: senderID)
case .leave:
handleLeave(packet, from: senderID)
@@ -3709,6 +3754,26 @@ extension BLEService {
)
}
/// Group broadcasts are opaque ciphertext to this layer: track them for
/// gossip backfill and hand the payload to the UI layer, where the group
/// coordinator decrypts and authenticates against the roster. Non-members
/// still relay (generic broadcast relay path) but never decode.
private func handleGroupMessage(_ packet: BitchatPacket, from peerID: PeerID) {
let isBroadcastRecipient: Bool = {
guard let recipient = packet.recipientID else { return true }
return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }
}()
guard isBroadcastRecipient, !packet.payload.isEmpty else { return }
gossipSyncManager?.onPublicPacketSeen(packet)
let payload = packet.payload
let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000)
notifyUI { [weak self] in
self?.deliverTransportEvent(.groupMessageReceived(payload: payload, timestamp: timestamp))
}
}
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
noisePacketHandler.handleHandshake(packet, from: peerID)
}
+41
View File
@@ -54,6 +54,15 @@ protocol CommandContextProvider: AnyObject {
/// Toggles the favorite via the unified peer flow, which persists by the
/// real noise key and notifies the peer over mesh or Nostr.
func toggleFavorite(peerID: PeerID)
// MARK: - Groups
// Group logic lives in `ChatGroupCoordinator`; these forward the parsed
// /group subcommands.
func groupCreate(named name: String) -> CommandResult
func groupInvite(nickname: String) -> CommandResult
func groupRemove(nickname: String) -> CommandResult
func groupLeave() -> CommandResult
func groupList() -> CommandResult
}
/// Processes chat commands in a focused, efficient way
@@ -100,6 +109,9 @@ final class CommandProcessor {
return handleBlock(args)
case "/unblock":
return handleUnblock(args)
case "/group":
if inGeoPublic || inGeoDM { return .error(message: "groups are only for mesh peers in #mesh") }
return handleGroup(args)
case "/fav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: true)
@@ -125,6 +137,9 @@ final class CommandProcessor {
/slap @name — slap with a large trout
/block @name · /unblock @name
/fav @name · /unfav @name — favorites (mesh only)
/group create <name> — start an encrypted group
/group invite @name · /group remove @name — manage members (creator)
/group leave · /group list — leave or list your groups
/help — this list
"""
@@ -331,6 +346,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)
}
}
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
let targetName = args.trimmed
guard !targetName.isEmpty else {
+569
View File
@@ -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
)
}
}
+194
View File
@@ -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")
}
}
+14
View File
@@ -35,6 +35,9 @@ enum TransportEvent: @unchecked Sendable {
case messageReceived(BitchatMessage)
case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
case noisePayloadReceived(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
/// Encrypted group broadcast (MessageType 0x25). Opaque here the group
/// coordinator decrypts and authenticates against the roster.
case groupMessageReceived(payload: Data, timestamp: Date)
case peerConnected(PeerID)
case peerDisconnected(PeerID)
case peerListUpdated([PeerID])
@@ -124,6 +127,12 @@ protocol Transport: AnyObject {
// transport cannot courier (no connected courier, or unsupported).
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
// Private groups (mesh transports only): creator-signed state travels
// 1:1 over Noise sessions; group messages flood like public broadcasts.
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID)
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID)
func broadcastGroupMessage(_ envelope: Data)
// QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
@@ -168,6 +177,9 @@ extension Transport {
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {}
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
func broadcastGroupMessage(_ envelope: Data) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
@@ -201,6 +213,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):
+41 -2
View File
@@ -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
@@ -95,6 +96,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
@@ -120,7 +122,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))
@@ -161,6 +169,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)
}
@@ -186,7 +197,9 @@ final class GossipSyncManager {
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
let maxAgeSeconds: TimeInterval
switch packet.type {
case MessageType.message.rawValue:
// Group messages share the whole-message window: members off the mesh
// for a while should backfill their crew's history like public chat.
case MessageType.message.rawValue, MessageType.groupMessage.rawValue:
maxAgeSeconds = config.publicMessageMaxAgeSeconds
case MessageType.prekeyBundle.rawValue:
maxAgeSeconds = config.prekeyBundleMaxAgeSeconds
@@ -268,6 +281,13 @@ final class GossipSyncManager {
|| latestPrekeyBundleByPeer.count < max(1, config.prekeyBundleCapacity) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
latestPrekeyBundleByPeer[owner] = (id: idHex, packet: packet)
case .groupMessage:
// Opaque ciphertext to non-members; carried and served like any
// other broadcast so members get backfill from any relay.
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
groupMessages.insert(idHex: idHex, packet: packet, capacity: max(1, config.groupMessageCapacity))
default:
break
}
@@ -426,6 +446,20 @@ final class GossipSyncManager {
}
}
}
if requestedTypes.contains(.groupMessage) {
let groupPkts = groupMessages.allPackets(isFresh: isPacketFresh)
for pkt in groupPkts {
if let since, pkt.timestamp < since { continue }
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
}
}
// Build REQUEST_SYNC payload using current candidates and GCS params
@@ -450,6 +484,9 @@ final class GossipSyncManager {
candidates.append(pair.packet)
}
}
if types.contains(.groupMessage) {
candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh))
}
if candidates.isEmpty {
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
@@ -512,6 +549,7 @@ final class GossipSyncManager {
latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in
isPacketFresh(pair.packet)
}
groupMessages.removeExpired(isFresh: isPacketFresh)
}
// MARK: - Archive (public message persistence)
@@ -609,6 +647,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 }
}
}
+10
View File
@@ -36,6 +36,14 @@ struct SyncTypeFlags: OptionSet {
case .fragment: return 5
case .requestSync: return 6
case .fileTransfer: return 7
// Bits 8/9 are reserved by other in-flight features.
// Extended bits are compat-safe by construction: `toData()` encodes
// the bitfield little-endian with trailing zero bytes trimmed (bit 10
// widens the wire form from 1 to 2 bytes inside the length-prefixed
// REQUEST_SYNC TLV 0x04), and `decode(_:)` accepts 1...8 bytes while
// `type(forBit:)` maps unknown bits to nil so old clients simply
// ignore the group bit and answer with the types they know.
case .groupMessage: return 10
// Courier envelopes are directed deposits between trusted peers and
// must never spread via gossip sync.
case .courierEnvelope: return nil
@@ -59,6 +67,7 @@ struct SyncTypeFlags: OptionSet {
case 7: return .fileTransfer
// Bit 8 reserved (board).
case 9: return .prekeyBundle
case 10: return .groupMessage
default:
return nil
}
@@ -69,6 +78,7 @@ struct SyncTypeFlags: OptionSet {
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
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) {
@@ -72,6 +72,10 @@ protocol ChatTransportEventContext: AnyObject {
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data)
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data)
func handleVouchPayload(from peerID: PeerID, payload: Data)
// MARK: Group payloads (creator-signed state over Noise)
func handleGroupInvitePayload(from peerID: PeerID, payload: Data)
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data)
}
extension ChatViewModel: ChatTransportEventContext {
@@ -134,6 +138,14 @@ extension ChatViewModel: ChatTransportEventContext {
func handleVouchPayload(from peerID: PeerID, payload: Data) {
vouchCoordinator.handleVouchPayload(from: peerID, payload: payload)
}
func handleGroupInvitePayload(from peerID: PeerID, payload: Data) {
groupCoordinator.handleGroupInvitePayload(from: peerID, payload: payload)
}
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
groupCoordinator.handleGroupKeyUpdatePayload(from: peerID, payload: payload)
}
}
final class ChatTransportEventCoordinator {
@@ -379,6 +391,12 @@ private extension ChatTransportEventCoordinator {
case .vouch:
context.handleVouchPayload(from: peerID, payload: payload)
case .groupInvite:
context.handleGroupInvitePayload(from: peerID, payload: payload)
case .groupKeyUpdate:
context.handleGroupKeyUpdatePayload(from: peerID, payload: payload)
}
}
+16 -1
View File
@@ -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
@@ -178,6 +180,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
// Computed properties for compatibility
@MainActor
@@ -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 {
@@ -810,6 +815,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
)
self.keychain = keychain
self.groupStore = GroupStore(keychain: keychain)
self.idBridge = idBridge
self.identityManager = identityManager
self.conversations = conversations
@@ -1211,6 +1217,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// with deleteAllKeychainData above plus the identity reset below.
PrekeyBundleStore.shared.wipe()
// Drop private group keys and rosters (keychain + disk)
groupStore.wipe()
// Identity manager has cleared persisted identity data above
// Clear autocomplete state
@@ -1574,6 +1583,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
)
}
func didReceiveGroupMessage(payload: Data, timestamp: Date) {
Task { @MainActor [weak self] in
self?.groupCoordinator.handleGroupMessagePayload(payload, timestamp: timestamp)
}
}
// MARK: - QR Verification API
@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)
}
@@ -297,7 +297,9 @@ final class NostrInboundPipeline {
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse, .vouch:
// Group state travels only over mesh Noise sessions in v1; anything
// claiming to be group traffic over Nostr is ignored.
case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate:
break
}
}
@@ -349,7 +351,9 @@ final class NostrInboundPipeline {
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse, .vouch:
// Group state travels only over mesh Noise sessions in v1; anything
// claiming to be group traffic over Nostr is ignored.
case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate:
break
}
}
@@ -428,7 +432,9 @@ final class NostrInboundPipeline {
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .verifyChallenge, .verifyResponse, .vouch:
// Group state travels only over mesh Noise sessions
// in v1; group traffic over Nostr is ignored.
case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate:
break
}
}
+42 -20
View File
@@ -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)
}
+86
View File
@@ -0,0 +1,86 @@
import BitFoundation
import SwiftUI
/// Compact "groups" section for the people sheet: one row per private group
/// this device belongs to, tappable to open the group chat window.
struct GroupChatList: View {
@ThemedPalette private var palette
let groups: [GroupChatRow]
let onTapGroup: (PeerID) -> Void
private enum Strings {
static let header = String(localized: "groups.section.header", comment: "Section header above the private groups list")
static let creator = String(localized: "groups.state.creator", comment: "State label for a group the user created")
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
static let openGroupHint = String(localized: "groups.accessibility.open_group_hint", comment: "Accessibility hint on a group row explaining activation opens the group chat")
static let memberCountFormat = String(localized: "groups.member_count %@", comment: "Member count shown next to a group name; placeholder is the count")
}
var body: some View {
if !groups.isEmpty {
VStack(alignment: .leading, spacing: 0) {
Text(Strings.header)
.bitchatFont(size: 11, weight: .medium)
.foregroundColor(palette.secondary)
.padding(.horizontal)
.padding(.top, 10)
.padding(.bottom, 2)
.accessibilityAddTraits(.isHeader)
ForEach(groups) { group in
HStack(spacing: 4) {
Image(systemName: "person.3.fill")
.font(.bitchatSystem(size: 10))
.foregroundColor(palette.primary)
Text("#\(group.name)")
.bitchatFont(size: 14)
.foregroundColor(palette.primary)
.lineLimit(1)
.truncationMode(.tail)
Text(String(format: Strings.memberCountFormat, locale: .current, "\(group.memberCount)"))
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
if group.isCreator {
Image(systemName: "crown.fill")
.font(.bitchatSystem(size: 9))
.foregroundColor(.yellow)
.help(Strings.creator)
}
Spacer()
if group.hasUnread {
Image(systemName: "envelope.fill")
.font(.bitchatSystem(size: 10))
.foregroundColor(.orange)
.help(Strings.newMessagesTooltip)
}
}
.padding(.horizontal)
.padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture { onTapGroup(group.peerID) }
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription(for: group))
.accessibilityAddTraits(.isButton)
.accessibilityHint(Strings.openGroupHint)
}
}
}
}
private func accessibilityDescription(for group: GroupChatRow) -> String {
var parts: [String] = [
group.name,
String(format: Strings.memberCountFormat, locale: .current, "\(group.memberCount)")
]
if group.isCreator { parts.append(Strings.creator) }
if group.hasUnread { parts.append(Strings.unread) }
return parts.joined(separator: ", ")
}
}
@@ -162,6 +162,18 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
func handleVouchPayload(from peerID: PeerID, payload: Data) {
vouchPayloads.append((peerID, payload))
}
// Group payloads
private(set) var groupInvitePayloads: [(peerID: PeerID, payload: Data)] = []
private(set) var groupKeyUpdatePayloads: [(peerID: PeerID, payload: Data)] = []
func handleGroupInvitePayload(from peerID: PeerID, payload: Data) {
groupInvitePayloads.append((peerID, payload))
}
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
groupKeyUpdatePayloads.append((peerID, payload))
}
}
// MARK: - Helpers
+28
View File
@@ -492,4 +492,32 @@ private final class MockCommandContextProvider: CommandContextProvider {
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
favoriteNotifications.append((peerID, isFavorite))
}
// Groups: record the parsed subcommand + argument the processor forwarded.
private(set) var groupCommands: [(subcommand: String, argument: String)] = []
func groupCreate(named name: String) -> CommandResult {
groupCommands.append(("create", name))
return .handled
}
func groupInvite(nickname: String) -> CommandResult {
groupCommands.append(("invite", nickname))
return .handled
}
func groupRemove(nickname: String) -> CommandResult {
groupCommands.append(("remove", nickname))
return .handled
}
func groupLeave() -> CommandResult {
groupCommands.append(("leave", ""))
return .handled
}
func groupList() -> CommandResult {
groupCommands.append(("list", ""))
return .handled
}
}
+4 -1
View File
@@ -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)
}
}
+170
View File
@@ -0,0 +1,170 @@
//
// GroupStoreTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Testing
import BitFoundation
@testable import bitchat
@MainActor
struct GroupStoreTests {
private func makeMember(seed: UInt8, nickname: String = "peer") -> GroupMember {
GroupMember(
fingerprint: Data(repeating: seed, count: 32).hexEncodedString(),
signingKey: Data(repeating: seed &+ 1, count: 32),
nickname: nickname
)
}
private func tempFileURL() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("group-store-tests-\(UUID().uuidString)", isDirectory: true)
.appendingPathComponent("groups.json")
}
// MARK: - Create / read
@Test func createGroupStoresMetadataAndKey() throws {
let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false)
let creator = makeMember(seed: 0xC1, nickname: "me")
let group = try #require(store.createGroup(named: "ops", creator: creator))
#expect(group.groupID.count == BitchatGroup.groupIDLength)
#expect(group.epoch == 1)
#expect(group.members == [creator])
#expect(group.creatorFingerprint == creator.fingerprint)
#expect(store.group(withID: group.groupID) == group)
#expect(store.group(for: group.peerID) == group)
let key = try #require(store.key(forGroupID: group.groupID))
#expect(key.count == BitchatGroup.keyLength)
#expect(group.peerID.isGroup)
#expect(group.peerID.groupIDData == group.groupID)
}
// MARK: - Roster cap
@Test func rosterCapIsEnforced() throws {
let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false)
let creator = makeMember(seed: 0xC1)
let group = try #require(store.createGroup(named: "big", creator: creator))
// Filling to the cap works
let fifteen = (1...15).map { makeMember(seed: UInt8($0)) }
#expect(store.updateRoster(groupID: group.groupID, members: [creator] + fifteen) != nil)
#expect(store.group(withID: group.groupID)?.members.count == BitchatGroup.maxMembers)
// one more is rejected.
let overflow = [creator] + fifteen + [makeMember(seed: 0x99)]
#expect(store.updateRoster(groupID: group.groupID, members: overflow) == nil)
#expect(store.group(withID: group.groupID)?.members.count == BitchatGroup.maxMembers)
// Direct upsert past the cap is rejected too.
var oversized = group
oversized.members = overflow
#expect(!store.upsert(oversized, key: Data(repeating: 1, count: 32)))
}
@Test func rosterMustRetainCreator() throws {
let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false)
let creator = makeMember(seed: 0xC1)
let other = makeMember(seed: 0xA1)
let group = try #require(store.createGroup(named: "crew", creator: creator))
#expect(store.updateRoster(groupID: group.groupID, members: [other]) == nil)
#expect(store.group(withID: group.groupID)?.members == [creator])
}
// MARK: - Rotation
@Test func rotateKeyBumpsEpochAndReplacesKey() throws {
let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false)
let creator = makeMember(seed: 0xC1)
let removed = makeMember(seed: 0xA1)
let group = try #require(store.createGroup(named: "crew", creator: creator))
#expect(store.updateRoster(groupID: group.groupID, members: [creator, removed]) != nil)
let oldKey = try #require(store.key(forGroupID: group.groupID))
let rotation = try #require(store.rotateKey(groupID: group.groupID, members: [creator]))
#expect(rotation.group.epoch == 2)
#expect(rotation.group.members == [creator])
#expect(rotation.key != oldKey)
#expect(store.key(forGroupID: group.groupID) == rotation.key)
#expect(store.group(withID: group.groupID)?.epoch == 2)
}
// MARK: - Persistence
@Test func persistsAcrossInstances() throws {
let keychain = MockKeychain()
let fileURL = tempFileURL()
defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) }
let creator = makeMember(seed: 0xC1, nickname: "me")
let group: BitchatGroup
do {
let store = GroupStore(keychain: keychain, fileURL: fileURL)
group = try #require(store.createGroup(named: "hike", creator: creator))
}
let reloaded = GroupStore(keychain: keychain, fileURL: fileURL)
#expect(reloaded.groups == [group])
#expect(reloaded.key(forGroupID: group.groupID) != nil)
}
@Test func groupsWithoutKeysAreDroppedOnLoad() throws {
let keychain = MockKeychain()
let fileURL = tempFileURL()
defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) }
let group: BitchatGroup
do {
let store = GroupStore(keychain: keychain, fileURL: fileURL)
group = try #require(store.createGroup(named: "stale", creator: makeMember(seed: 0xC1)))
}
// Simulate a keychain wipe without the metadata file being removed.
_ = keychain.deleteAllKeychainData()
let reloaded = GroupStore(keychain: keychain, fileURL: fileURL)
#expect(reloaded.groups.isEmpty)
#expect(reloaded.group(withID: group.groupID) == nil)
}
// MARK: - Panic wipe
@Test func wipeRemovesMetadataAndKeys() throws {
let keychain = MockKeychain()
let fileURL = tempFileURL()
defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) }
let store = GroupStore(keychain: keychain, fileURL: fileURL)
let group = try #require(store.createGroup(named: "gone", creator: makeMember(seed: 0xC1)))
#expect(FileManager.default.fileExists(atPath: fileURL.path))
store.wipe()
#expect(store.groups.isEmpty)
#expect(store.key(forGroupID: group.groupID) == nil)
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
// A fresh instance sees nothing either.
let reloaded = GroupStore(keychain: keychain, fileURL: fileURL)
#expect(reloaded.groups.isEmpty)
}
@Test func removeGroupDeletesItsKey() throws {
let keychain = MockKeychain()
let store = GroupStore(keychain: keychain, persistsToDisk: false)
let group = try #require(store.createGroup(named: "bye", creator: makeMember(seed: 0xC1)))
store.removeGroup(withID: group.groupID)
#expect(store.groups.isEmpty)
#expect(store.key(forGroupID: group.groupID) == nil)
}
}
@@ -0,0 +1,62 @@
//
// SyncTypeFlagsGroupTests.swift
// bitchat
//
// Wire-compat proof for the groupMessage sync bit (bit 10): the types
// bitfield widens from 1 to 2 bytes, and clients that don't know the bit
// simply ignore it.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Testing
import BitFoundation
@testable import bitchat
struct SyncTypeFlagsGroupTests {
@Test func groupMessageOccupiesBitTen() {
#expect(SyncTypeFlags.groupMessage.rawValue == 1 << 10)
#expect(SyncTypeFlags.groupMessage.contains(.groupMessage))
#expect(!SyncTypeFlags.publicMessages.contains(.groupMessage))
}
@Test func extendedBitfieldWidensToTwoBytes() throws {
// Legacy flags fit one byte
#expect(SyncTypeFlags.publicMessages.toData() == Data([0x03]))
// the group bit widens the little-endian encoding to two bytes.
let combined = SyncTypeFlags.publicMessages.union(.groupMessage)
let encoded = try #require(combined.toData())
#expect(encoded == Data([0x03, 0x04]))
let decoded = try #require(SyncTypeFlags.decode(encoded))
#expect(decoded == combined)
#expect(Set(decoded.toMessageTypes()) == Set([.announce, .message, .groupMessage]))
}
@Test func unknownBitsAreIgnoredNotRejected() throws {
// An "old client" reading a 2-byte field keeps the raw bits but maps
// unknown bit indices to no message type it answers with the types
// it knows instead of dropping the request.
let futuristic = try #require(SyncTypeFlags.decode(Data([0x03, 0xFC])))
#expect(Set(futuristic.toMessageTypes()) == Set([.announce, .message, .groupMessage]))
#expect(futuristic.contains(.announce))
#expect(futuristic.contains(.message))
}
@Test func requestSyncPacketRoundTripsGroupFlag() throws {
let types = SyncTypeFlags.publicMessages.union(.groupMessage)
let packet = RequestSyncPacket(p: 8, m: 1024, data: Data([0xAB, 0xCD]), types: types)
let encoded = packet.encode()
let decoded = try #require(RequestSyncPacket.decode(from: encoded))
#expect(decoded.types == types)
#expect(decoded.types?.contains(.groupMessage) == true)
#expect(decoded.p == 8)
#expect(decoded.m == 1024)
#expect(decoded.data == Data([0xAB, 0xCD]))
}
}
@@ -25,9 +25,11 @@ public enum MessageType: UInt8 {
case fragment = 0x20 // Single fragment type for large messages
case fileTransfer = 0x22 // Binary file/audio/image payloads
// 0x23, 0x25-0x28 reserved for in-flight protocol work.
// 0x23/0x26-0x28 reserved by other in-flight features.
case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped)
case groupMessage = 0x25 // Group-encrypted broadcast (cleartext group ID, ChaChaPoly body)
public var description: String {
switch self {
case .announce: return "announce"
@@ -40,6 +42,7 @@ public enum MessageType: UInt8 {
case .fragment: return "fragment"
case .fileTransfer: return "fileTransfer"
case .prekeyBundle: return "prekeyBundle"
case .groupMessage: return "groupMessage"
}
}
}
@@ -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
}