mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 07:25:19 +00:00
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>
171 lines
6.7 KiB
Swift
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)
|
|
}
|
|
}
|