mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25: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
@@ -1394,6 +1394,48 @@ final class BLEService: NSObject {
|
||||
|
||||
// MARK: QR Verification over Noise
|
||||
|
||||
// MARK: Private Groups
|
||||
|
||||
/// Sends creator-signed group state (invite) 1:1 over the Noise session,
|
||||
/// queueing behind a handshake when none is established yet.
|
||||
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {
|
||||
sendNoisePayload(NoisePayload(type: .groupInvite, data: statePayload).encode(), to: peerID)
|
||||
}
|
||||
|
||||
/// Sends creator-signed group state (key rotation / roster update) 1:1
|
||||
/// over the Noise session.
|
||||
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {
|
||||
sendNoisePayload(NoisePayload(type: .groupKeyUpdate, data: statePayload).encode(), to: peerID)
|
||||
}
|
||||
|
||||
/// Broadcasts a sealed group message (MessageType 0x25) like a public
|
||||
/// message: fire-and-flood with gossip-sync backfill. The outer packet is
|
||||
/// intentionally unsigned — receivers authenticate the sender's Ed25519
|
||||
/// signature inside the ciphertext, which still verifies for backfilled
|
||||
/// copies long after the sender's announce has expired.
|
||||
func broadcastGroupMessage(_ envelope: Data) {
|
||||
guard !envelope.isEmpty else { return }
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.groupMessage.rawValue,
|
||||
senderID: Data(hexString: self.myPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: envelope,
|
||||
signature: nil,
|
||||
ttl: self.messageTTL
|
||||
)
|
||||
// Pre-mark our own broadcast as processed to avoid handling a
|
||||
// relayed self copy.
|
||||
let dedupID = BLESelfBroadcastTracker.dedupID(for: packet)
|
||||
self.messageDeduplicator.markProcessed(dedupID)
|
||||
self.broadcastPacket(packet)
|
||||
// Track our own broadcast for gossip sync
|
||||
self.gossipSyncManager?.onPublicPacketSeen(packet)
|
||||
}
|
||||
}
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||
let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA)
|
||||
sendNoisePayload(payload, to: peerID)
|
||||
@@ -3758,6 +3800,9 @@ extension BLEService {
|
||||
case .courierEnvelope:
|
||||
handleCourierEnvelope(packet, from: peerID)
|
||||
|
||||
case .groupMessage:
|
||||
handleGroupMessage(packet, from: senderID)
|
||||
|
||||
case .prekeyBundle:
|
||||
handlePrekeyBundle(packet, from: senderID)
|
||||
|
||||
@@ -4095,6 +4140,26 @@ extension BLEService {
|
||||
)
|
||||
}
|
||||
|
||||
/// Group broadcasts are opaque ciphertext to this layer: track them for
|
||||
/// gossip backfill and hand the payload to the UI layer, where the group
|
||||
/// coordinator decrypts and authenticates against the roster. Non-members
|
||||
/// still relay (generic broadcast relay path) but never decode.
|
||||
private func handleGroupMessage(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
let isBroadcastRecipient: Bool = {
|
||||
guard let recipient = packet.recipientID else { return true }
|
||||
return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }
|
||||
}()
|
||||
guard isBroadcastRecipient, !packet.payload.isEmpty else { return }
|
||||
|
||||
gossipSyncManager?.onPublicPacketSeen(packet)
|
||||
|
||||
let payload = packet.payload
|
||||
let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
self?.deliverTransportEvent(.groupMessageReceived(payload: payload, timestamp: timestamp))
|
||||
}
|
||||
}
|
||||
|
||||
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
noisePacketHandler.handleHandshake(packet, from: peerID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user