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>
This commit is contained in:
jack
2026-07-06 20:22:38 +02:00
co-authored by Claude Fable 5
parent 688b954fb8
commit 7ad19ab4c3
30 changed files with 2731 additions and 34 deletions
@@ -24,7 +24,10 @@ public enum MessageType: UInt8 {
// Fragmentation (simplified)
case fragment = 0x20 // Single fragment type for large messages
case fileTransfer = 0x22 // Binary file/audio/image payloads
// Private groups (0x23/0x24/0x26/0x27/0x28 reserved by other features)
case groupMessage = 0x25 // Group-encrypted broadcast (cleartext group ID, ChaChaPoly body)
public var description: String {
switch self {
case .announce: return "announce"
@@ -36,6 +39,7 @@ public enum MessageType: UInt8 {
case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment"
case .fileTransfer: return "fileTransfer"
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
}