mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 09:05:19 +00:00
Private groups: creator-managed encrypted group chat over the mesh (#1383)
* Add capability bits to announce TLV Announces now carry an optional capabilities TLV (0x05): a little-endian bitfield with named bits for upcoming features (prekeys, wifiBulk, gateway, groups, board, vouch, meshDiagnostics). Old clients skip the unknown TLV; peers without it decode as nil so features can distinguish "legacy peer" from "advertises nothing". PeerCapabilities lives in BitFoundation with a minimal-length encoding that preserves unknown bits for forward compatibility. Peer capabilities are stored in the BLE peer registry on verified announce and exposed via BLEService.peerCapabilities(_:). The local advertisement set is empty until each feature ships its bit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Private groups: creator-managed encrypted group chat over the mesh Small encrypted crews (hard cap 16) between public broadcast and 1:1 DMs: Protocol - MessageType.groupMessage = 0x25: broadcast packets with a cleartext 16-byte group ID + epoch, ChaCha20-Poly1305 ciphertext (epoch bound as AEAD AAD), inner Ed25519 sender signature over "bitchat-group-msg-v1"|groupID|messageID|timestamp|content - NoisePayloadType.groupInvite = 0x06 / .groupKeyUpdate = 0x07: creator-signed group state (key, epoch, roster) 1:1 over Noise; signature over "bitchat-group-v1"|groupID|epoch|key-hash|roster-hash and the Noise session peer must BE the creator - SyncTypeFlags bit 10 (groupMessage): variable-length LE bitfield widens 1 -> 2 bytes inside the length-prefixed REQUEST_SYNC TLV; old clients ignore unknown bits and answer with types they know - PeerCapabilities.localSupported now advertises .groups Storage - GroupStore: symmetric keys in the keychain, roster/name/epoch as protected JSON in Application Support; wiped in panicClearAllData() Behavior - Non-members relay 0x25 like any broadcast but cannot read it; group messages join gossip-sync backfill with the public-message window - Receivers drop wrong-epoch envelopes, bad sender signatures, and senders missing from the creator-signed roster - Fire-and-flood delivery (no per-member acks in v1) UI - Groups open as chat windows through the private-chat sheet (virtual "group_" peer IDs); groups section in the people sheet; /group create/invite/remove/leave/list commands; invitees get a system message + notification and the group appears in their people sheet Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Private groups: fix TLV truncation, roster downgrade, removal notice, block, media, signable bytes Addresses the Codex review and adversarial-review findings on #1383: - TLV encoding now throws GroupTLVError.valueTooLong instead of clamping to 65535 and truncating, so an oversize group message fails to seal and surfaces send_failed rather than shipping ciphertext recipients drop. - Roster nicknames truncate on a Character boundary (never mid-scalar), so a multi-byte nickname can no longer make the whole signed roster undecodable. - Invites now bump the epoch (rotate the key) like removals, giving every roster change a strictly-increasing epoch so out-of-order invite states no longer last-writer-wins a just-added member back out. - Removing a member now sends them a creator-signed roster-without-them under a throwaway all-zero key (never the rotated key), so their client deactivates the group and surfaces "removed" instead of going silently dark. - /block is enforced in the group receive path: a blocked member's messages are dropped from display and notifications, consistent with every other inbound path. - Media affordances are disabled in group chats (both computed sites) so the composer can't strand a media placeholder that never sends; media-in-groups is a documented v2 item. - Creator signature now covers the group name and the sender signature covers the epoch (wire-format-affecting; needs Android parity before ship). - Explicit isGroup guard in markPrivateMessagesAsRead so read/delivered receipts can never leak into group conversations under a future refactor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
jack
Claude Fable 5
parent
87910541ef
commit
81a10f73f0
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
|
||||
switch MessageType(rawValue: packetType) {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
return true
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle:
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1394,6 +1394,48 @@ final class BLEService: NSObject {
|
||||
|
||||
// MARK: QR Verification over Noise
|
||||
|
||||
// MARK: Private Groups
|
||||
|
||||
/// Sends creator-signed group state (invite) 1:1 over the Noise session,
|
||||
/// queueing behind a handshake when none is established yet.
|
||||
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {
|
||||
sendNoisePayload(NoisePayload(type: .groupInvite, data: statePayload).encode(), to: peerID)
|
||||
}
|
||||
|
||||
/// Sends creator-signed group state (key rotation / roster update) 1:1
|
||||
/// over the Noise session.
|
||||
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {
|
||||
sendNoisePayload(NoisePayload(type: .groupKeyUpdate, data: statePayload).encode(), to: peerID)
|
||||
}
|
||||
|
||||
/// Broadcasts a sealed group message (MessageType 0x25) like a public
|
||||
/// message: fire-and-flood with gossip-sync backfill. The outer packet is
|
||||
/// intentionally unsigned — receivers authenticate the sender's Ed25519
|
||||
/// signature inside the ciphertext, which still verifies for backfilled
|
||||
/// copies long after the sender's announce has expired.
|
||||
func broadcastGroupMessage(_ envelope: Data) {
|
||||
guard !envelope.isEmpty else { return }
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.groupMessage.rawValue,
|
||||
senderID: Data(hexString: self.myPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: envelope,
|
||||
signature: nil,
|
||||
ttl: self.messageTTL
|
||||
)
|
||||
// Pre-mark our own broadcast as processed to avoid handling a
|
||||
// relayed self copy.
|
||||
let dedupID = BLESelfBroadcastTracker.dedupID(for: packet)
|
||||
self.messageDeduplicator.markProcessed(dedupID)
|
||||
self.broadcastPacket(packet)
|
||||
// Track our own broadcast for gossip sync
|
||||
self.gossipSyncManager?.onPublicPacketSeen(packet)
|
||||
}
|
||||
}
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||
let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA)
|
||||
sendNoisePayload(payload, to: peerID)
|
||||
@@ -3758,6 +3800,9 @@ extension BLEService {
|
||||
case .courierEnvelope:
|
||||
handleCourierEnvelope(packet, from: peerID)
|
||||
|
||||
case .groupMessage:
|
||||
handleGroupMessage(packet, from: senderID)
|
||||
|
||||
case .prekeyBundle:
|
||||
handlePrekeyBundle(packet, from: senderID)
|
||||
|
||||
@@ -4095,6 +4140,26 @@ extension BLEService {
|
||||
)
|
||||
}
|
||||
|
||||
/// Group broadcasts are opaque ciphertext to this layer: track them for
|
||||
/// gossip backfill and hand the payload to the UI layer, where the group
|
||||
/// coordinator decrypts and authenticates against the roster. Non-members
|
||||
/// still relay (generic broadcast relay path) but never decode.
|
||||
private func handleGroupMessage(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
let isBroadcastRecipient: Bool = {
|
||||
guard let recipient = packet.recipientID else { return true }
|
||||
return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }
|
||||
}()
|
||||
guard isBroadcastRecipient, !packet.payload.isEmpty else { return }
|
||||
|
||||
gossipSyncManager?.onPublicPacketSeen(packet)
|
||||
|
||||
let payload = packet.payload
|
||||
let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
self?.deliverTransportEvent(.groupMessageReceived(payload: payload, timestamp: timestamp))
|
||||
}
|
||||
}
|
||||
|
||||
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
noisePacketHandler.handleHandshake(packet, from: peerID)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,15 @@ protocol CommandContextProvider: AnyObject {
|
||||
/// Toggles the favorite via the unified peer flow, which persists by the
|
||||
/// real noise key and notifies the peer over mesh or Nostr.
|
||||
func toggleFavorite(peerID: PeerID)
|
||||
|
||||
// MARK: - Groups
|
||||
// Group logic lives in `ChatGroupCoordinator`; these forward the parsed
|
||||
// /group subcommands.
|
||||
func groupCreate(named name: String) -> CommandResult
|
||||
func groupInvite(nickname: String) -> CommandResult
|
||||
func groupRemove(nickname: String) -> CommandResult
|
||||
func groupLeave() -> CommandResult
|
||||
func groupList() -> CommandResult
|
||||
}
|
||||
|
||||
/// Processes chat commands in a focused, efficient way
|
||||
@@ -120,6 +129,9 @@ final class CommandProcessor {
|
||||
return handleBlock(args)
|
||||
case "/unblock":
|
||||
return handleUnblock(args)
|
||||
case "/group":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "groups are only for mesh peers in #mesh") }
|
||||
return handleGroup(args)
|
||||
case "/fav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
return handleFavorite(args, add: true)
|
||||
@@ -153,6 +165,9 @@ final class CommandProcessor {
|
||||
/slap @name — slap with a large trout
|
||||
/block @name · /unblock @name
|
||||
/fav @name · /unfav @name — favorites (mesh only)
|
||||
/group create <name> — start an encrypted group
|
||||
/group invite @name · /group remove @name — manage members (creator)
|
||||
/group leave · /group list — leave or list your groups
|
||||
/ping @name — measure round-trip time (mesh only)
|
||||
/trace @name — estimated mesh path (mesh only)
|
||||
/pay <token> — send a cashu ecash token in this chat
|
||||
@@ -362,6 +377,32 @@ final class CommandProcessor {
|
||||
return .error(message: "cannot unblock \(nickname): not found")
|
||||
}
|
||||
|
||||
private static let groupUsage = "usage: /group create <name> · invite @name · remove @name · leave · list"
|
||||
|
||||
private func handleGroup(_ args: String) -> CommandResult {
|
||||
let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
|
||||
guard let subcommand = parts.first else {
|
||||
return .error(message: Self.groupUsage)
|
||||
}
|
||||
let rest = parts.count > 1 ? String(parts[1]) : ""
|
||||
guard let provider = contextProvider else { return .handled }
|
||||
|
||||
switch subcommand {
|
||||
case "create":
|
||||
return provider.groupCreate(named: rest)
|
||||
case "invite":
|
||||
return provider.groupInvite(nickname: rest)
|
||||
case "remove":
|
||||
return provider.groupRemove(nickname: rest)
|
||||
case "leave":
|
||||
return provider.groupLeave()
|
||||
case "list":
|
||||
return provider.groupList()
|
||||
default:
|
||||
return .error(message: Self.groupUsage)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mesh Diagnostics
|
||||
|
||||
private enum MeshPeerResolution {
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
//
|
||||
// GroupProtocol.swift
|
||||
// bitchat
|
||||
//
|
||||
// Wire formats and crypto for private groups: creator-signed group state
|
||||
// (invites and key updates over Noise) and ChaCha20-Poly1305 group messages
|
||||
// broadcast as MessageType.groupMessage (0x25).
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
/// A member of a private group as pinned in the creator-signed roster.
|
||||
struct GroupMember: Codable, Equatable {
|
||||
/// SHA-256 fingerprint (64 hex chars) of the member's Noise static key.
|
||||
let fingerprint: String
|
||||
/// The member's Ed25519 signing public key (32 bytes, from their announce).
|
||||
let signingKey: Data
|
||||
/// Nickname at invite time; display fallback when the peer is offline.
|
||||
var nickname: String
|
||||
}
|
||||
|
||||
/// Creator-managed encrypted group. Metadata only — the symmetric key lives
|
||||
/// in the keychain (see `GroupStore`).
|
||||
struct BitchatGroup: Codable, Equatable {
|
||||
static let maxMembers = 16
|
||||
static let groupIDLength = 16
|
||||
static let keyLength = 32
|
||||
|
||||
/// 16 random bytes; travels in cleartext on group message packets so
|
||||
/// relays can dedup/filter without membership.
|
||||
let groupID: Data
|
||||
var name: String
|
||||
/// Bumps on every key rotation; messages are bound to the epoch they
|
||||
/// were sealed under.
|
||||
var epoch: UInt32
|
||||
var members: [GroupMember]
|
||||
/// Fingerprint of the creator — the only identity allowed to sign group
|
||||
/// state (invites, key updates) in v1.
|
||||
let creatorFingerprint: String
|
||||
|
||||
/// Virtual conversation ID this group's chat is keyed under.
|
||||
var peerID: PeerID { PeerID(groupID: groupID) }
|
||||
|
||||
var creator: GroupMember? {
|
||||
members.first { $0.fingerprint == creatorFingerprint }
|
||||
}
|
||||
|
||||
func isMember(fingerprint: String) -> Bool {
|
||||
members.contains { $0.fingerprint == fingerprint }
|
||||
}
|
||||
|
||||
func member(withSigningKey signingKey: Data) -> GroupMember? {
|
||||
members.first { $0.signingKey == signingKey }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TLV helpers
|
||||
|
||||
enum GroupTLVError: Error, Equatable {
|
||||
/// A TLV value exceeded the 16-bit length field. Encoding fails instead
|
||||
/// of silently truncating (which would ship a value the receiver drops).
|
||||
case valueTooLong
|
||||
}
|
||||
|
||||
private enum GroupTLV {
|
||||
/// Appends a (type, 16-bit length, value) triple. Throws rather than
|
||||
/// truncating when `value` does not fit the 16-bit length field, so an
|
||||
/// oversize field surfaces a send failure instead of a silently truncated
|
||||
/// blob the recipient rejects during decrypt/verify.
|
||||
static func put(_ type: UInt8, _ value: Data, into out: inout Data) throws {
|
||||
guard value.count <= Int(UInt16.max) else { throw GroupTLVError.valueTooLong }
|
||||
out.append(type)
|
||||
let length = UInt16(value.count)
|
||||
out.append(UInt8((length >> 8) & 0xFF))
|
||||
out.append(UInt8(length & 0xFF))
|
||||
out.append(value)
|
||||
}
|
||||
|
||||
/// Iterates (type, value) pairs; returns nil on malformed framing.
|
||||
static func parse(_ data: Data) -> [(type: UInt8, value: Data)]? {
|
||||
var fields: [(UInt8, Data)] = []
|
||||
var offset = data.startIndex
|
||||
while offset < data.endIndex {
|
||||
guard data.distance(from: offset, to: data.endIndex) >= 3 else { return nil }
|
||||
let type = data[offset]
|
||||
let high = Int(data[data.index(offset, offsetBy: 1)])
|
||||
let low = Int(data[data.index(offset, offsetBy: 2)])
|
||||
let length = (high << 8) | low
|
||||
let valueStart = data.index(offset, offsetBy: 3)
|
||||
guard data.distance(from: valueStart, to: data.endIndex) >= length else { return nil }
|
||||
let valueEnd = data.index(valueStart, offsetBy: length)
|
||||
fields.append((type, Data(data[valueStart..<valueEnd])))
|
||||
offset = valueEnd
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
static func epochData(_ epoch: UInt32) -> Data {
|
||||
var bigEndian = epoch.bigEndian
|
||||
return withUnsafeBytes(of: &bigEndian) { Data($0) }
|
||||
}
|
||||
|
||||
static func epoch(from data: Data) -> UInt32? {
|
||||
guard data.count == 4 else { return nil }
|
||||
return data.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||
}
|
||||
|
||||
static func timestampData(_ timestampMs: UInt64) -> Data {
|
||||
var bigEndian = timestampMs.bigEndian
|
||||
return withUnsafeBytes(of: &bigEndian) { Data($0) }
|
||||
}
|
||||
|
||||
static func timestamp(from data: Data) -> UInt64? {
|
||||
guard data.count == 8 else { return nil }
|
||||
return data.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Roster wire form
|
||||
|
||||
enum GroupRosterCoding {
|
||||
private static let fingerprintLength = 32
|
||||
private static let signingKeyLength = 32
|
||||
private static let maxNicknameBytes = 64
|
||||
|
||||
/// Deterministic roster blob: count byte, then per member the raw 32-byte
|
||||
/// fingerprint, 32-byte signing key, and length-prefixed UTF-8 nickname.
|
||||
/// The creator signature covers the SHA-256 of these exact bytes.
|
||||
static func encode(_ members: [GroupMember]) -> Data? {
|
||||
guard members.count <= BitchatGroup.maxMembers else { return nil }
|
||||
var out = Data([UInt8(members.count)])
|
||||
for member in members {
|
||||
guard let fingerprintData = Data(hexString: member.fingerprint),
|
||||
fingerprintData.count == fingerprintLength,
|
||||
member.signingKey.count == signingKeyLength else { return nil }
|
||||
out.append(fingerprintData)
|
||||
out.append(member.signingKey)
|
||||
// Truncate on a Character boundary so the byte prefix is always
|
||||
// valid UTF-8; a raw byte-prefix could split a multi-byte scalar
|
||||
// and make the whole signed roster undecodable on the recipient.
|
||||
let nickname = truncatedNicknameBytes(member.nickname)
|
||||
out.append(UInt8(nickname.count))
|
||||
out.append(nickname)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> [GroupMember]? {
|
||||
guard let count = data.first, count <= UInt8(BitchatGroup.maxMembers) else { return nil }
|
||||
var members: [GroupMember] = []
|
||||
var offset = data.index(after: data.startIndex)
|
||||
for _ in 0..<count {
|
||||
let fixed = fingerprintLength + signingKeyLength + 1
|
||||
guard data.distance(from: offset, to: data.endIndex) >= fixed else { return nil }
|
||||
let fingerprintEnd = data.index(offset, offsetBy: fingerprintLength)
|
||||
let fingerprint = Data(data[offset..<fingerprintEnd]).hexEncodedString()
|
||||
let signingKeyEnd = data.index(fingerprintEnd, offsetBy: signingKeyLength)
|
||||
let signingKey = Data(data[fingerprintEnd..<signingKeyEnd])
|
||||
let nickLength = Int(data[signingKeyEnd])
|
||||
let nickStart = data.index(after: signingKeyEnd)
|
||||
guard data.distance(from: nickStart, to: data.endIndex) >= nickLength else { return nil }
|
||||
let nickEnd = data.index(nickStart, offsetBy: nickLength)
|
||||
guard let nickname = String(data: Data(data[nickStart..<nickEnd]), encoding: .utf8) else { return nil }
|
||||
members.append(GroupMember(fingerprint: fingerprint, signingKey: signingKey, nickname: nickname))
|
||||
offset = nickEnd
|
||||
}
|
||||
guard offset == data.endIndex else { return nil }
|
||||
return members
|
||||
}
|
||||
|
||||
/// UTF-8 bytes of `nickname` trimmed to at most `maxNicknameBytes`,
|
||||
/// dropping whole Characters so the result is never split mid-scalar.
|
||||
private static func truncatedNicknameBytes(_ nickname: String) -> Data {
|
||||
var candidate = nickname
|
||||
while Data(candidate.utf8).count > maxNicknameBytes {
|
||||
candidate.removeLast()
|
||||
}
|
||||
return Data(candidate.utf8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Group state payload (groupInvite / groupKeyUpdate over Noise)
|
||||
|
||||
/// Creator-signed group state. The same wire form serves invites (0x06) and
|
||||
/// key updates (0x07); receivers verify the creator signature — computed over
|
||||
/// "bitchat-group-v1" | groupID | epoch | SHA256(key) | SHA256(roster) —
|
||||
/// against the creator's signing key pinned in the roster, and require the
|
||||
/// Noise session peer to BE the creator before accepting any state.
|
||||
struct GroupStatePayload: Equatable {
|
||||
let groupID: Data
|
||||
let name: String
|
||||
/// Symmetric ChaCha20-Poly1305 group key (32 bytes) for `epoch`.
|
||||
let key: Data
|
||||
let epoch: UInt32
|
||||
let members: [GroupMember]
|
||||
let creatorFingerprint: String
|
||||
/// Ed25519 signature by the creator.
|
||||
let signature: Data
|
||||
|
||||
private enum FieldType: UInt8 {
|
||||
case groupID = 0x01
|
||||
case name = 0x02
|
||||
case key = 0x03
|
||||
case epoch = 0x04
|
||||
case roster = 0x05
|
||||
case creatorFingerprint = 0x06
|
||||
case signature = 0x07
|
||||
}
|
||||
|
||||
static let signingDomain = Data("bitchat-group-v1".utf8)
|
||||
|
||||
/// The bytes the creator signs. Binding the key, roster, and name by hash
|
||||
/// keeps the signed content fixed-size. The name is covered so a relay
|
||||
/// that caches/replays a signed state (e.g. store-and-forward) cannot swap
|
||||
/// the display name while keeping a valid creator signature.
|
||||
static func signingContent(groupID: Data, epoch: UInt32, key: Data, rosterBlob: Data, name: String) -> Data {
|
||||
var content = signingDomain
|
||||
content.append(groupID)
|
||||
content.append(GroupTLV.epochData(epoch))
|
||||
content.append(key.sha256Hash())
|
||||
content.append(rosterBlob.sha256Hash())
|
||||
content.append(Data(name.utf8).sha256Hash())
|
||||
return content
|
||||
}
|
||||
|
||||
/// Builds a signed state payload. Returns nil when the roster cannot be
|
||||
/// encoded (over cap, malformed member) or signing fails.
|
||||
static func makeSigned(
|
||||
group: BitchatGroup,
|
||||
key: Data,
|
||||
sign: (Data) -> Data?
|
||||
) -> GroupStatePayload? {
|
||||
guard let rosterBlob = GroupRosterCoding.encode(group.members) else { return nil }
|
||||
let content = signingContent(groupID: group.groupID, epoch: group.epoch, key: key, rosterBlob: rosterBlob, name: group.name)
|
||||
guard let signature = sign(content) else { return nil }
|
||||
return GroupStatePayload(
|
||||
groupID: group.groupID,
|
||||
name: group.name,
|
||||
key: key,
|
||||
epoch: group.epoch,
|
||||
members: group.members,
|
||||
creatorFingerprint: group.creatorFingerprint,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
guard let rosterBlob = GroupRosterCoding.encode(members),
|
||||
let fingerprintData = Data(hexString: creatorFingerprint),
|
||||
fingerprintData.count == 32 else { return nil }
|
||||
var out = Data()
|
||||
do {
|
||||
try GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out)
|
||||
try GroupTLV.put(FieldType.name.rawValue, Data(name.utf8), into: &out)
|
||||
try GroupTLV.put(FieldType.key.rawValue, key, into: &out)
|
||||
try GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out)
|
||||
try GroupTLV.put(FieldType.roster.rawValue, rosterBlob, into: &out)
|
||||
try GroupTLV.put(FieldType.creatorFingerprint.rawValue, fingerprintData, into: &out)
|
||||
try GroupTLV.put(FieldType.signature.rawValue, signature, into: &out)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> GroupStatePayload? {
|
||||
guard let fields = GroupTLV.parse(data) else { return nil }
|
||||
var groupID: Data?
|
||||
var name: String?
|
||||
var key: Data?
|
||||
var epoch: UInt32?
|
||||
var rosterBlob: Data?
|
||||
var members: [GroupMember]?
|
||||
var creatorFingerprint: String?
|
||||
var signature: Data?
|
||||
|
||||
for (type, value) in fields {
|
||||
switch FieldType(rawValue: type) {
|
||||
case .groupID where value.count == BitchatGroup.groupIDLength:
|
||||
groupID = value
|
||||
case .name:
|
||||
name = String(data: value, encoding: .utf8)
|
||||
case .key where value.count == BitchatGroup.keyLength:
|
||||
key = value
|
||||
case .epoch:
|
||||
epoch = GroupTLV.epoch(from: value)
|
||||
case .roster:
|
||||
rosterBlob = value
|
||||
members = GroupRosterCoding.decode(value)
|
||||
case .creatorFingerprint where value.count == 32:
|
||||
creatorFingerprint = value.hexEncodedString()
|
||||
case .signature where value.count == 64:
|
||||
signature = value
|
||||
default:
|
||||
break // forward compatible; ignore unknown TLVs
|
||||
}
|
||||
}
|
||||
|
||||
guard let groupID, let name, let key, let epoch,
|
||||
rosterBlob != nil, let members, !members.isEmpty,
|
||||
let creatorFingerprint, let signature else { return nil }
|
||||
return GroupStatePayload(
|
||||
groupID: groupID,
|
||||
name: name,
|
||||
key: key,
|
||||
epoch: epoch,
|
||||
members: members,
|
||||
creatorFingerprint: creatorFingerprint,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
|
||||
/// Verifies the creator signature against the creator's signing key
|
||||
/// pinned in the roster, and that the creator is actually in the roster.
|
||||
func verifyCreatorSignature() -> Bool {
|
||||
guard members.count <= BitchatGroup.maxMembers,
|
||||
let creator = members.first(where: { $0.fingerprint == creatorFingerprint }),
|
||||
let rosterBlob = GroupRosterCoding.encode(members) else { return false }
|
||||
let content = GroupStatePayload.signingContent(groupID: groupID, epoch: epoch, key: key, rosterBlob: rosterBlob, name: name)
|
||||
return GroupCrypto.verify(signature: signature, for: content, publicKey: creator.signingKey)
|
||||
}
|
||||
|
||||
var asGroup: BitchatGroup {
|
||||
BitchatGroup(
|
||||
groupID: groupID,
|
||||
name: name,
|
||||
epoch: epoch,
|
||||
members: members,
|
||||
creatorFingerprint: creatorFingerprint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Group message envelope (MessageType 0x25 payload)
|
||||
|
||||
/// Cleartext framing of a group message broadcast. Only the group ID, epoch,
|
||||
/// and nonce are visible to relays; everything about the message — sender,
|
||||
/// content, timestamps — is inside the ChaCha20-Poly1305 ciphertext.
|
||||
struct GroupMessageEnvelope: Equatable {
|
||||
let groupID: Data
|
||||
let epoch: UInt32
|
||||
let nonce: Data
|
||||
/// ChaChaPoly ciphertext || 16-byte tag.
|
||||
let ciphertext: Data
|
||||
|
||||
private enum FieldType: UInt8 {
|
||||
case groupID = 0x01
|
||||
case epoch = 0x02
|
||||
case nonce = 0x03
|
||||
case ciphertext = 0x04
|
||||
}
|
||||
|
||||
func encode() throws -> Data {
|
||||
var out = Data()
|
||||
try GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out)
|
||||
try GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out)
|
||||
try GroupTLV.put(FieldType.nonce.rawValue, nonce, into: &out)
|
||||
try GroupTLV.put(FieldType.ciphertext.rawValue, ciphertext, into: &out)
|
||||
return out
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> GroupMessageEnvelope? {
|
||||
guard let fields = GroupTLV.parse(data) else { return nil }
|
||||
var groupID: Data?
|
||||
var epoch: UInt32?
|
||||
var nonce: Data?
|
||||
var ciphertext: Data?
|
||||
for (type, value) in fields {
|
||||
switch FieldType(rawValue: type) {
|
||||
case .groupID where value.count == BitchatGroup.groupIDLength:
|
||||
groupID = value
|
||||
case .epoch:
|
||||
epoch = GroupTLV.epoch(from: value)
|
||||
case .nonce where value.count == 12:
|
||||
nonce = value
|
||||
case .ciphertext where !value.isEmpty:
|
||||
ciphertext = value
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
guard let groupID, let epoch, let nonce, let ciphertext else { return nil }
|
||||
return GroupMessageEnvelope(groupID: groupID, epoch: epoch, nonce: nonce, ciphertext: ciphertext)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypted, signature-verified inner content of a group message.
|
||||
struct GroupMessagePlaintext: Equatable {
|
||||
let messageID: String
|
||||
let senderSigningKey: Data
|
||||
let senderNickname: String
|
||||
let timestampMs: UInt64
|
||||
let content: String
|
||||
}
|
||||
|
||||
// MARK: - Crypto
|
||||
|
||||
enum GroupCryptoError: Error, Equatable {
|
||||
case malformedPayload
|
||||
case signingFailed
|
||||
case sealFailed
|
||||
case wrongEpoch
|
||||
case decryptionFailed
|
||||
case badSenderSignature
|
||||
}
|
||||
|
||||
enum GroupCrypto {
|
||||
static let messageSigningDomain = Data("bitchat-group-msg-v1".utf8)
|
||||
|
||||
private enum InnerField: UInt8 {
|
||||
case messageID = 0x01
|
||||
case senderSigningKey = 0x02
|
||||
case senderNickname = 0x03
|
||||
case timestamp = 0x04
|
||||
case content = 0x05
|
||||
case signature = 0x06
|
||||
}
|
||||
|
||||
/// Bytes the sender signs: domain | groupID | epoch | messageID | timestamp | content.
|
||||
/// Covering the epoch stops a current member from re-sealing another
|
||||
/// member's decrypted inner bytes under a later epoch key (the signature
|
||||
/// would no longer verify at the new epoch).
|
||||
static func messageSigningContent(groupID: Data, epoch: UInt32, messageID: String, timestampMs: UInt64, content: String) -> Data {
|
||||
var data = messageSigningDomain
|
||||
data.append(groupID)
|
||||
data.append(GroupTLV.epochData(epoch))
|
||||
data.append(Data(messageID.utf8))
|
||||
data.append(GroupTLV.timestampData(timestampMs))
|
||||
data.append(Data(content.utf8))
|
||||
return data
|
||||
}
|
||||
|
||||
static func verify(signature: Data, for data: Data, publicKey: Data) -> Bool {
|
||||
guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else { return false }
|
||||
return key.isValidSignature(signature, for: data)
|
||||
}
|
||||
|
||||
/// Seals a group message: builds the signed inner TLV and encrypts it with
|
||||
/// the epoch key. The cleartext group ID and epoch are bound into the AEAD
|
||||
/// as additional data so ciphertext cannot be replayed across groups or
|
||||
/// epochs. Returns the encoded 0x25 packet payload.
|
||||
static func sealMessage(
|
||||
content: String,
|
||||
messageID: String,
|
||||
senderNickname: String,
|
||||
senderSigningKey: Data,
|
||||
timestampMs: UInt64,
|
||||
groupID: Data,
|
||||
epoch: UInt32,
|
||||
key: Data,
|
||||
sign: (Data) -> Data?
|
||||
) throws -> Data {
|
||||
let signingContent = messageSigningContent(
|
||||
groupID: groupID,
|
||||
epoch: epoch,
|
||||
messageID: messageID,
|
||||
timestampMs: timestampMs,
|
||||
content: content
|
||||
)
|
||||
guard let signature = sign(signingContent), signature.count == 64 else {
|
||||
throw GroupCryptoError.signingFailed
|
||||
}
|
||||
|
||||
var inner = Data()
|
||||
try GroupTLV.put(InnerField.messageID.rawValue, Data(messageID.utf8), into: &inner)
|
||||
try GroupTLV.put(InnerField.senderSigningKey.rawValue, senderSigningKey, into: &inner)
|
||||
try GroupTLV.put(InnerField.senderNickname.rawValue, Data(senderNickname.utf8), into: &inner)
|
||||
try GroupTLV.put(InnerField.timestamp.rawValue, GroupTLV.timestampData(timestampMs), into: &inner)
|
||||
try GroupTLV.put(InnerField.content.rawValue, Data(content.utf8), into: &inner)
|
||||
try GroupTLV.put(InnerField.signature.rawValue, signature, into: &inner)
|
||||
|
||||
do {
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
var aad = groupID
|
||||
aad.append(GroupTLV.epochData(epoch))
|
||||
let sealed = try ChaChaPoly.seal(inner, using: symmetricKey, authenticating: aad)
|
||||
var ciphertext = sealed.ciphertext
|
||||
ciphertext.append(sealed.tag)
|
||||
let envelope = GroupMessageEnvelope(
|
||||
groupID: groupID,
|
||||
epoch: epoch,
|
||||
nonce: Data(sealed.nonce),
|
||||
ciphertext: ciphertext
|
||||
)
|
||||
return try envelope.encode()
|
||||
} catch {
|
||||
throw GroupCryptoError.sealFailed
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a group message envelope with the epoch key: decrypts, parses the
|
||||
/// inner TLV, and verifies the sender's Ed25519 signature. Roster
|
||||
/// membership of the sender is the CALLER's check — this function only
|
||||
/// proves the payload was authored by `senderSigningKey`.
|
||||
static func openMessage(_ envelope: GroupMessageEnvelope, key: Data) throws -> GroupMessagePlaintext {
|
||||
let inner: Data
|
||||
do {
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
var aad = envelope.groupID
|
||||
aad.append(GroupTLV.epochData(envelope.epoch))
|
||||
let nonce = try ChaChaPoly.Nonce(data: envelope.nonce)
|
||||
guard envelope.ciphertext.count > 16 else { throw GroupCryptoError.decryptionFailed }
|
||||
let tag = envelope.ciphertext.suffix(16)
|
||||
let body = envelope.ciphertext.prefix(envelope.ciphertext.count - 16)
|
||||
let sealedBox = try ChaChaPoly.SealedBox(nonce: nonce, ciphertext: body, tag: tag)
|
||||
inner = try ChaChaPoly.open(sealedBox, using: symmetricKey, authenticating: aad)
|
||||
} catch {
|
||||
throw GroupCryptoError.decryptionFailed
|
||||
}
|
||||
|
||||
guard let fields = GroupTLV.parse(inner) else { throw GroupCryptoError.malformedPayload }
|
||||
var messageID: String?
|
||||
var senderSigningKey: Data?
|
||||
var senderNickname: String?
|
||||
var timestampMs: UInt64?
|
||||
var content: String?
|
||||
var signature: Data?
|
||||
for (type, value) in fields {
|
||||
switch InnerField(rawValue: type) {
|
||||
case .messageID:
|
||||
messageID = String(data: value, encoding: .utf8)
|
||||
case .senderSigningKey where value.count == 32:
|
||||
senderSigningKey = value
|
||||
case .senderNickname:
|
||||
senderNickname = String(data: value, encoding: .utf8)
|
||||
case .timestamp:
|
||||
timestampMs = GroupTLV.timestamp(from: value)
|
||||
case .content:
|
||||
content = String(data: value, encoding: .utf8)
|
||||
case .signature where value.count == 64:
|
||||
signature = value
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
guard let messageID, !messageID.isEmpty,
|
||||
let senderSigningKey,
|
||||
let senderNickname,
|
||||
let timestampMs,
|
||||
let content,
|
||||
let signature else { throw GroupCryptoError.malformedPayload }
|
||||
|
||||
let signingContent = messageSigningContent(
|
||||
groupID: envelope.groupID,
|
||||
epoch: envelope.epoch,
|
||||
messageID: messageID,
|
||||
timestampMs: timestampMs,
|
||||
content: content
|
||||
)
|
||||
guard verify(signature: signature, for: signingContent, publicKey: senderSigningKey) else {
|
||||
throw GroupCryptoError.badSenderSignature
|
||||
}
|
||||
|
||||
return GroupMessagePlaintext(
|
||||
messageID: messageID,
|
||||
senderSigningKey: senderSigningKey,
|
||||
senderNickname: senderNickname,
|
||||
timestampMs: timestampMs,
|
||||
content: content
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//
|
||||
// GroupStore.swift
|
||||
// bitchat
|
||||
//
|
||||
// Persistence for private groups: symmetric keys in the keychain, metadata
|
||||
// (roster, name, epoch) as protected JSON in Application Support. Both are
|
||||
// dropped by the panic wipe.
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import Combine
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
@MainActor
|
||||
final class GroupStore: ObservableObject {
|
||||
/// All groups this device is a member of, in creation/join order.
|
||||
@Published private(set) var groups: [BitchatGroup] = []
|
||||
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let fileURL: URL?
|
||||
|
||||
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
|
||||
/// when `persistsToDisk` is false.
|
||||
init(keychain: KeychainManagerProtocol, persistsToDisk: Bool = true, fileURL: URL? = nil) {
|
||||
self.keychain = keychain
|
||||
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
// MARK: - Reads
|
||||
|
||||
func group(withID groupID: Data) -> BitchatGroup? {
|
||||
groups.first { $0.groupID == groupID }
|
||||
}
|
||||
|
||||
func group(for peerID: PeerID) -> BitchatGroup? {
|
||||
guard let groupID = peerID.groupIDData else { return nil }
|
||||
return group(withID: groupID)
|
||||
}
|
||||
|
||||
/// Current-epoch symmetric key for the group, from the keychain.
|
||||
func key(forGroupID groupID: Data) -> Data? {
|
||||
keychain.getIdentityKey(forKey: Self.keychainKey(for: groupID))
|
||||
}
|
||||
|
||||
// MARK: - Mutations
|
||||
|
||||
/// Creates a new group with a random 16-byte ID and 32-byte key at
|
||||
/// epoch 1, with the creator as sole member. Returns nil when key
|
||||
/// generation or persistence fails.
|
||||
func createGroup(named name: String, creator: GroupMember) -> BitchatGroup? {
|
||||
guard let groupID = Self.randomBytes(BitchatGroup.groupIDLength),
|
||||
let key = Self.randomBytes(BitchatGroup.keyLength) else { return nil }
|
||||
let group = BitchatGroup(
|
||||
groupID: groupID,
|
||||
name: name,
|
||||
epoch: 1,
|
||||
members: [creator],
|
||||
creatorFingerprint: creator.fingerprint
|
||||
)
|
||||
guard upsert(group, key: key) else { return nil }
|
||||
return group
|
||||
}
|
||||
|
||||
/// Inserts or replaces a group and its current key. Rejects rosters over
|
||||
/// the hard cap or groups whose creator is missing from the roster.
|
||||
@discardableResult
|
||||
func upsert(_ group: BitchatGroup, key: Data) -> Bool {
|
||||
guard group.groupID.count == BitchatGroup.groupIDLength,
|
||||
key.count == BitchatGroup.keyLength,
|
||||
!group.members.isEmpty,
|
||||
group.members.count <= BitchatGroup.maxMembers,
|
||||
group.creator != nil else { return false }
|
||||
guard keychain.saveIdentityKey(key, forKey: Self.keychainKey(for: group.groupID)) else {
|
||||
SecureLogger.error("Failed to store group key in keychain", category: .security)
|
||||
return false
|
||||
}
|
||||
if let index = groups.firstIndex(where: { $0.groupID == group.groupID }) {
|
||||
groups[index] = group
|
||||
} else {
|
||||
groups.append(group)
|
||||
}
|
||||
persist()
|
||||
return true
|
||||
}
|
||||
|
||||
/// Updates the roster of an existing group without changing key or epoch
|
||||
/// (creator-side invite). Enforces the member cap.
|
||||
@discardableResult
|
||||
func updateRoster(groupID: Data, members: [GroupMember]) -> BitchatGroup? {
|
||||
guard let index = groups.firstIndex(where: { $0.groupID == groupID }),
|
||||
!members.isEmpty,
|
||||
members.count <= BitchatGroup.maxMembers,
|
||||
members.contains(where: { $0.fingerprint == groups[index].creatorFingerprint }) else { return nil }
|
||||
groups[index].members = members
|
||||
persist()
|
||||
return groups[index]
|
||||
}
|
||||
|
||||
/// Rotates the group key (creator-side removal/rotation): new random key,
|
||||
/// epoch + 1, and the given roster. Returns the updated group and new key.
|
||||
func rotateKey(groupID: Data, members: [GroupMember]) -> (group: BitchatGroup, key: Data)? {
|
||||
guard let existing = group(withID: groupID),
|
||||
let newKey = Self.randomBytes(BitchatGroup.keyLength) else { return nil }
|
||||
var rotated = existing
|
||||
rotated.epoch = existing.epoch &+ 1
|
||||
rotated.members = members
|
||||
guard upsert(rotated, key: newKey) else { return nil }
|
||||
return (rotated, newKey)
|
||||
}
|
||||
|
||||
func removeGroup(withID groupID: Data) {
|
||||
groups.removeAll { $0.groupID == groupID }
|
||||
_ = keychain.deleteIdentityKey(forKey: Self.keychainKey(for: groupID))
|
||||
persist()
|
||||
}
|
||||
|
||||
/// Panic wipe: drop all group keys and metadata from memory and disk.
|
||||
/// (The panic flow also nukes the whole keychain; deleting per-group keys
|
||||
/// here keeps the store safe to wipe on its own.)
|
||||
func wipe() {
|
||||
for group in groups {
|
||||
_ = keychain.deleteIdentityKey(forKey: Self.keychainKey(for: group.groupID))
|
||||
}
|
||||
groups.removeAll()
|
||||
if let fileURL {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private static func keychainKey(for groupID: Data) -> String {
|
||||
"groupKey-\(groupID.hexEncodedString())"
|
||||
}
|
||||
|
||||
private static func randomBytes(_ count: Int) -> Data? {
|
||||
var bytes = Data(count: count)
|
||||
let status = bytes.withUnsafeMutableBytes { buffer -> OSStatus in
|
||||
guard let baseAddress = buffer.baseAddress else { return errSecParam }
|
||||
return SecRandomCopyBytes(kSecRandomDefault, count, baseAddress)
|
||||
}
|
||||
return status == errSecSuccess ? bytes : nil
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
guard let fileURL else { return }
|
||||
do {
|
||||
if groups.isEmpty {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
return
|
||||
}
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let data = try JSONEncoder().encode(groups)
|
||||
var options: Data.WritingOptions = [.atomic]
|
||||
#if os(iOS)
|
||||
options.insert(.completeFileProtection)
|
||||
#endif
|
||||
try data.write(to: fileURL, options: options)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to persist group store: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFromDisk() {
|
||||
guard let fileURL,
|
||||
let data = try? Data(contentsOf: fileURL),
|
||||
let stored = try? JSONDecoder().decode([BitchatGroup].self, from: data) else {
|
||||
return
|
||||
}
|
||||
// Only groups whose key survived in the keychain are usable.
|
||||
groups = stored.filter { key(forGroupID: $0.groupID) != nil }
|
||||
}
|
||||
|
||||
private static func defaultFileURL() -> URL? {
|
||||
guard let base = try? FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
) else { return nil }
|
||||
return base
|
||||
.appendingPathComponent("groups", isDirectory: true)
|
||||
.appendingPathComponent("groups.json")
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,9 @@ enum TransportEvent: @unchecked Sendable {
|
||||
case messageReceived(BitchatMessage)
|
||||
case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||
case noisePayloadReceived(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||
/// Encrypted group broadcast (MessageType 0x25). Opaque here — the group
|
||||
/// coordinator decrypts and authenticates against the roster.
|
||||
case groupMessageReceived(payload: Data, timestamp: Date)
|
||||
case peerConnected(PeerID)
|
||||
case peerDisconnected(PeerID)
|
||||
case peerListUpdated([PeerID])
|
||||
@@ -158,6 +161,12 @@ protocol Transport: AnyObject {
|
||||
// transport cannot courier (no connected courier, or unsupported).
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
|
||||
|
||||
// Private groups (mesh transports only): creator-signed state travels
|
||||
// 1:1 over Noise sessions; group messages flood like public broadcasts.
|
||||
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID)
|
||||
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID)
|
||||
func broadcastGroupMessage(_ envelope: Data)
|
||||
|
||||
// Bulletin board (mesh transports only): broadcast a pre-signed board
|
||||
// payload (post or tombstone) so it spreads over relay and gossip sync.
|
||||
func sendBoardPayload(_ payload: Data)
|
||||
@@ -214,6 +223,9 @@ extension Transport {
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {}
|
||||
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
|
||||
func broadcastGroupMessage(_ envelope: Data) {}
|
||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
||||
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
|
||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
|
||||
@@ -259,6 +271,8 @@ extension BitchatDelegate {
|
||||
)
|
||||
case let .noisePayloadReceived(peerID, type, payload, timestamp):
|
||||
didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp)
|
||||
case let .groupMessageReceived(payload, timestamp):
|
||||
didReceiveGroupMessage(payload: payload, timestamp: timestamp)
|
||||
case .peerConnected(let peerID):
|
||||
didConnectToPeer(peerID)
|
||||
case .peerDisconnected(let peerID):
|
||||
|
||||
Reference in New Issue
Block a user