Files
bitchat/bitchatTests/Services/GroupStoreTests.swift
T
81a10f73f0 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>
2026-07-07 15:59:55 +02:00

171 lines
6.7 KiB
Swift

//
// 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)
}
}