mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 10:05:20 +00:00
Private groups: creator-managed encrypted group chat over the mesh (#1383)
* Add capability bits to announce TLV Announces now carry an optional capabilities TLV (0x05): a little-endian bitfield with named bits for upcoming features (prekeys, wifiBulk, gateway, groups, board, vouch, meshDiagnostics). Old clients skip the unknown TLV; peers without it decode as nil so features can distinguish "legacy peer" from "advertises nothing". PeerCapabilities lives in BitFoundation with a minimal-length encoding that preserves unknown bits for forward compatibility. Peer capabilities are stored in the BLE peer registry on verified announce and exposed via BLEService.peerCapabilities(_:). The local advertisement set is empty until each feature ships its bit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Private groups: creator-managed encrypted group chat over the mesh Small encrypted crews (hard cap 16) between public broadcast and 1:1 DMs: Protocol - MessageType.groupMessage = 0x25: broadcast packets with a cleartext 16-byte group ID + epoch, ChaCha20-Poly1305 ciphertext (epoch bound as AEAD AAD), inner Ed25519 sender signature over "bitchat-group-msg-v1"|groupID|messageID|timestamp|content - NoisePayloadType.groupInvite = 0x06 / .groupKeyUpdate = 0x07: creator-signed group state (key, epoch, roster) 1:1 over Noise; signature over "bitchat-group-v1"|groupID|epoch|key-hash|roster-hash and the Noise session peer must BE the creator - SyncTypeFlags bit 10 (groupMessage): variable-length LE bitfield widens 1 -> 2 bytes inside the length-prefixed REQUEST_SYNC TLV; old clients ignore unknown bits and answer with types they know - PeerCapabilities.localSupported now advertises .groups Storage - GroupStore: symmetric keys in the keychain, roster/name/epoch as protected JSON in Application Support; wiped in panicClearAllData() Behavior - Non-members relay 0x25 like any broadcast but cannot read it; group messages join gossip-sync backfill with the public-message window - Receivers drop wrong-epoch envelopes, bad sender signatures, and senders missing from the creator-signed roster - Fire-and-flood delivery (no per-member acks in v1) UI - Groups open as chat windows through the private-chat sheet (virtual "group_" peer IDs); groups section in the people sheet; /group create/invite/remove/leave/list commands; invitees get a system message + notification and the group appears in their people sheet Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Private groups: fix TLV truncation, roster downgrade, removal notice, block, media, signable bytes Addresses the Codex review and adversarial-review findings on #1383: - TLV encoding now throws GroupTLVError.valueTooLong instead of clamping to 65535 and truncating, so an oversize group message fails to seal and surfaces send_failed rather than shipping ciphertext recipients drop. - Roster nicknames truncate on a Character boundary (never mid-scalar), so a multi-byte nickname can no longer make the whole signed roster undecodable. - Invites now bump the epoch (rotate the key) like removals, giving every roster change a strictly-increasing epoch so out-of-order invite states no longer last-writer-wins a just-added member back out. - Removing a member now sends them a creator-signed roster-without-them under a throwaway all-zero key (never the rotated key), so their client deactivates the group and surfaces "removed" instead of going silently dark. - /block is enforced in the group receive path: a blocked member's messages are dropped from display and notifications, consistent with every other inbound path. - Media affordances are disabled in group chats (both computed sites) so the composer can't strand a media placeholder that never sends; media-in-groups is a documented v2 item. - Creator signature now covers the group name and the sender signature covers the epoch (wire-format-affecting; needs Android parity before ship). - Explicit isGroup guard in markPrivateMessagesAsRead so read/delivered receipts can never leak into group conversations under a future refactor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
jack
Claude Fable 5
parent
87910541ef
commit
81a10f73f0
@@ -73,6 +73,7 @@ final class GossipSyncManager {
|
||||
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||
var fragmentCapacity: Int = 600
|
||||
var fileTransferCapacity: Int = 200
|
||||
var groupMessageCapacity: Int = 200
|
||||
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
|
||||
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
|
||||
var messageSyncIntervalSeconds: TimeInterval = 15.0
|
||||
@@ -106,6 +107,7 @@ final class GossipSyncManager {
|
||||
private var messages = PacketStore()
|
||||
private var fragments = PacketStore()
|
||||
private var fileTransfers = PacketStore()
|
||||
private var groupMessages = PacketStore()
|
||||
private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:]
|
||||
// Latest verified prekey bundle per owner. Unlike announces, bundles are
|
||||
// NOT dropped on leave/stale peer: their whole purpose is reaching a
|
||||
@@ -131,7 +133,13 @@ final class GossipSyncManager {
|
||||
)
|
||||
var schedules: [SyncSchedule] = []
|
||||
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
||||
// Group messages ride the public-message cadence; old clients
|
||||
// ignore the extended bit and answer with announces/messages only.
|
||||
var messageTypes: SyncTypeFlags = .publicMessages
|
||||
if config.groupMessageCapacity > 0 {
|
||||
messageTypes.formUnion(.groupMessage)
|
||||
}
|
||||
schedules.append(SyncSchedule(types: messageTypes, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
||||
}
|
||||
if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))
|
||||
@@ -175,6 +183,9 @@ final class GossipSyncManager {
|
||||
guard let self = self else { return }
|
||||
|
||||
var types: SyncTypeFlags = .publicMessages
|
||||
if self.config.groupMessageCapacity > 0 {
|
||||
types.formUnion(.groupMessage)
|
||||
}
|
||||
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
|
||||
types.formUnion(.fragment)
|
||||
}
|
||||
@@ -201,9 +212,11 @@ final class GossipSyncManager {
|
||||
// messages get the long town-crier window; fragments, file transfers and
|
||||
// announces keep the short one.
|
||||
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
||||
// Group messages share the whole-message window: members off the mesh
|
||||
// for a while should backfill their crew's history like public chat.
|
||||
let maxAgeSeconds: TimeInterval
|
||||
switch packet.type {
|
||||
case MessageType.message.rawValue:
|
||||
case MessageType.message.rawValue, MessageType.groupMessage.rawValue:
|
||||
maxAgeSeconds = config.publicMessageMaxAgeSeconds
|
||||
case MessageType.prekeyBundle.rawValue:
|
||||
maxAgeSeconds = config.prekeyBundleMaxAgeSeconds
|
||||
@@ -262,6 +275,13 @@ final class GossipSyncManager {
|
||||
guard isPacketFresh(packet) else { return }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
|
||||
case .groupMessage:
|
||||
// Opaque ciphertext to non-members; carried and served like any
|
||||
// other broadcast so members get backfill from any relay.
|
||||
guard isBroadcastRecipient else { return }
|
||||
guard isPacketFresh(packet) else { return }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
groupMessages.insert(idHex: idHex, packet: packet, capacity: max(1, config.groupMessageCapacity))
|
||||
case .prekeyBundle:
|
||||
// Callers only feed verified bundles here (own bundles at send
|
||||
// time, peers' after signature verification), so gossip never
|
||||
@@ -454,6 +474,19 @@ final class GossipSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
if requestedTypes.contains(.groupMessage) {
|
||||
let groupPkts = groupMessages.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in groupPkts {
|
||||
if let since, pkt.timestamp < since { continue }
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Like announces, prekey bundles are exempt from the since-cursor:
|
||||
// there is at most one per owner (newer replaces older), so the
|
||||
// resend cost is bounded and a joining peer must be able to learn
|
||||
@@ -505,6 +538,9 @@ final class GossipSyncManager {
|
||||
if types.contains(.fileTransfer) {
|
||||
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
|
||||
}
|
||||
if types.contains(.groupMessage) {
|
||||
candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh))
|
||||
}
|
||||
if types.contains(.prekeyBundle) {
|
||||
for (_, pair) in latestPrekeyBundleByPeer where isPacketFresh(pair.packet) {
|
||||
candidates.append(pair.packet)
|
||||
@@ -574,6 +610,7 @@ final class GossipSyncManager {
|
||||
}
|
||||
fragments.removeExpired(isFresh: isPacketFresh)
|
||||
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
||||
groupMessages.removeExpired(isFresh: isPacketFresh)
|
||||
latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in
|
||||
isPacketFresh(pair.packet)
|
||||
}
|
||||
@@ -677,6 +714,7 @@ final class GossipSyncManager {
|
||||
}
|
||||
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
groupMessages.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user