diff --git a/bitchat/Services/Groups/GroupProtocol.swift b/bitchat/Services/Groups/GroupProtocol.swift index 8f4a09d1..e34c5459 100644 --- a/bitchat/Services/Groups/GroupProtocol.swift +++ b/bitchat/Services/Groups/GroupProtocol.swift @@ -59,6 +59,16 @@ struct BitchatGroup: Codable, Equatable { func member(withSigningKey signingKey: Data) -> GroupMember? { members.first { $0.signingKey == signingKey } } + + /// Whether `other` names the same creator — fingerprint AND signing key — + /// as this group. A group keeps the creator it was created with: state + /// for a known groupID naming anyone else is a takeover attempt, however + /// validly it is self-signed. + func hasSameCreator(as other: BitchatGroup) -> Bool { + guard let creator, let otherCreator = other.creator else { return false } + return creator.fingerprint == otherCreator.fingerprint + && creator.signingKey == otherCreator.signingKey + } } // MARK: - TLV helpers @@ -192,7 +202,9 @@ enum GroupRosterCoding { /// 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. +/// Noise session peer to BE the creator before accepting any state. Both +/// checks are self-referential (the state names its own creator), so for a +/// group already held the creator must also match the stored one. struct GroupStatePayload: Equatable { let groupID: Data let name: String diff --git a/bitchat/Services/Groups/GroupStore.swift b/bitchat/Services/Groups/GroupStore.swift index 3a45dcb7..ce7f443f 100644 --- a/bitchat/Services/Groups/GroupStore.swift +++ b/bitchat/Services/Groups/GroupStore.swift @@ -68,14 +68,20 @@ final class GroupStore: ObservableObject { } /// Inserts or replaces a group and its current key. Rejects rosters over - /// the hard cap or groups whose creator is missing from the roster. + /// the hard cap, groups whose creator is missing from the roster, and any + /// change to the creator (fingerprint or signing key) of a group already + /// held, so no caller can hand a known groupID to a different creator. @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 key.count == BitchatGroup.keyLength, + Self.isWellFormed(group) else { return false } + if let existing = self.group(withID: group.groupID), !existing.hasSameCreator(as: group) { + SecureLogger.warning( + "Refusing group state: creator \(group.creatorFingerprint.prefix(8))… does not match stored creator \(existing.creatorFingerprint.prefix(8))…", + category: .security + ) + 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 @@ -90,16 +96,17 @@ final class GroupStore: ObservableObject { } /// Updates the roster of an existing group without changing key or epoch - /// (creator-side invite). Enforces the member cap. + /// (creator-side invite). Enforces the member cap and keeps the creator's + /// roster entry (fingerprint and signing key) pinned. @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 + guard let index = groups.firstIndex(where: { $0.groupID == groupID }) else { return nil } + var updated = groups[index] + updated.members = members + guard Self.isWellFormed(updated), groups[index].hasSameCreator(as: updated) else { return nil } + groups[index] = updated persist() - return groups[index] + return updated } /// Rotates the group key (creator-side removal/rotation): new random key, @@ -139,6 +146,15 @@ final class GroupStore: ObservableObject { "groupKey-\(groupID.hexEncodedString())" } + /// Structural invariants every stored group satisfies: a well-sized ID, + /// a non-empty roster within the cap, and the creator in that roster. + private static func isWellFormed(_ group: BitchatGroup) -> Bool { + group.groupID.count == BitchatGroup.groupIDLength + && !group.members.isEmpty + && group.members.count <= BitchatGroup.maxMembers + && group.creator != nil + } + private static func randomBytes(_ count: Int) -> Data? { var bytes = Data(count: count) let status = bytes.withUnsafeMutableBytes { buffer -> OSStatus in @@ -176,8 +192,15 @@ final class GroupStore: ObservableObject { 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 } + // Only groups whose key survived in the keychain are usable. Loading + // bypasses `upsert`, so re-check its invariants here too, and keep one + // entry per groupID so a group can never carry two creators. + var seenGroupIDs = Set() + groups = stored.filter { group in + Self.isWellFormed(group) + && seenGroupIDs.insert(group.groupID).inserted + && key(forGroupID: group.groupID) != nil + } } private static func defaultFileURL() -> URL? { diff --git a/bitchat/ViewModels/ChatGroupCoordinator.swift b/bitchat/ViewModels/ChatGroupCoordinator.swift index b8a2b2a6..296337b3 100644 --- a/bitchat/ViewModels/ChatGroupCoordinator.swift +++ b/bitchat/ViewModels/ChatGroupCoordinator.swift @@ -466,12 +466,14 @@ final class ChatGroupCoordinator { /// Accepts creator-signed group state arriving as an invite. The Noise /// session peer must BE the creator, the signature must verify against /// the creator key pinned in the roster, and we must be in the roster. + /// For a group we already hold, the creator must be the stored one. func handleGroupInvitePayload(from peerID: PeerID, payload: Data) { applyGroupState(from: peerID, payload: payload, isInvite: true) } - /// Accepts creator-signed state updates (rotation/roster). A state whose - /// roster no longer includes us means we were removed: drop the group. + /// Accepts creator-signed state updates (rotation/roster). A state from + /// the stored creator whose roster no longer includes us means we were + /// removed: drop the group. func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) { applyGroupState(from: peerID, payload: payload, isInvite: false) } @@ -558,6 +560,30 @@ private extension ChatGroupCoordinator { let myFingerprint = context.myNoiseFingerprint() let existing = context.groupStore.group(withID: state.groupID) + if let existing { + // A group keeps the creator it was created with. The checks above + // only prove the sender is the creator the state names — anyone + // passes them by naming themselves and self-signing — so pin the + // creator's fingerprint AND signing key to the stored group. + // Both existing-group checks run before the removal branch, which + // drops the group without ever reaching `upsert`'s own pin. + guard existing.hasSameCreator(as: state.asGroup) else { + SecureLogger.warning( + "Dropping group state claiming creator \(state.creatorFingerprint.prefix(8))… for a group created by \(existing.creatorFingerprint.prefix(8))…", + category: .security + ) + return + } + // Never regress the epoch: state travels over live Noise sessions, + // so an older epoch here is a stale (or misbehaving) creator + // device. That includes a stale removal, which must not undo a + // later re-invite. + guard state.epoch >= existing.epoch else { + SecureLogger.warning("Dropping stale group state (epoch \(state.epoch) < \(existing.epoch))", category: .security) + return + } + } + // A creator-signed roster that no longer includes us is a removal. guard state.members.contains(where: { $0.fingerprint == myFingerprint }) else { if let existing { @@ -576,13 +602,6 @@ private extension ChatGroupCoordinator { return } - // Never regress the epoch: state travels over live Noise sessions, - // so an older epoch here is a stale (or misbehaving) creator device. - if let existing, state.epoch < existing.epoch { - SecureLogger.warning("Dropping stale group state (epoch \(state.epoch) < \(existing.epoch))", category: .security) - return - } - let isNewMembership = existing == nil guard context.groupStore.upsert(state.asGroup, key: state.key) else { SecureLogger.error("Failed to store group state for \(state.name)", category: .session) diff --git a/bitchatTests/ChatGroupCoordinatorContextTests.swift b/bitchatTests/ChatGroupCoordinatorContextTests.swift new file mode 100644 index 00000000..67cf8b00 --- /dev/null +++ b/bitchatTests/ChatGroupCoordinatorContextTests.swift @@ -0,0 +1,302 @@ +// +// ChatGroupCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatGroupCoordinator`'s group-state authorization against a +// mock `ChatGroupContext` and a real in-memory `GroupStore`, with real +// Ed25519 creator signatures. The Noise session binding is modelled by +// `MockChatGroupContext.fingerprintsByPeerID` (what `meshFingerprint(for:)` +// reports for the authenticated session peer). Store-level pinning is +// covered by `GroupStoreTests`; wire/crypto by `GroupProtocolTests`. +// + +import CryptoKit +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +// MARK: - Mock Context + +@MainActor +private final class MockChatGroupContext: ChatGroupContext { + // Identity & state + let nickname = "me" + let myPeerID: PeerID + var selectedPrivateChatPeer: PeerID? + let groupStore = GroupStore(keychain: MockKeychain(), persistsToDisk: false) + + private let identity: TestIdentity + + init(identity: TestIdentity) { + self.identity = identity + self.myPeerID = identity.peerID + } + + func myNoiseFingerprint() -> String { identity.fingerprint } + func mySigningPublicKey() -> Data { identity.member.signingKey } + func signWithNoiseKey(_ data: Data) -> Data? { identity.sign(data) } + + // Peers: the fingerprint each Noise session authenticated. + var fingerprintsByPeerID: [PeerID: String] = [:] + + func getPeerIDForNickname(_ nickname: String) -> PeerID? { nil } + func isPeerConnected(_ peerID: PeerID) -> Bool { false } + func peerNickname(for peerID: PeerID) -> String? { nil } + func meshFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] } + func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)? { nil } + func connectedPeerID(forFingerprint fingerprint: String) -> PeerID? { nil } + func isFingerprintBlocked(_ fingerprint: String) -> Bool { false } + + // Transport (outbound state is not exercised by these tests) + func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) {} + func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) {} + func broadcastGroupMessagePayload(_ payload: Data) {} + + // Conversation intents + private(set) var removedPrivateChats: [PeerID] = [] + private(set) var endPrivateChatCount = 0 + private(set) var systemMessages: [String] = [] + + @discardableResult + func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool { true } + func markPrivateChatUnread(_ peerID: PeerID) {} + func removePrivateChat(_ peerID: PeerID) { removedPrivateChats.append(peerID) } + func startPrivateChat(with peerID: PeerID) {} + func endPrivateChat() { endPrivateChatCount += 1 } + func addSystemMessage(_ content: String) { systemMessages.append(content) } + func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {} + func notifyUIChanged() {} + func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) {} + + func clearRecordedEffects() { + removedPrivateChats.removeAll() + endPrivateChatCount = 0 + systemMessages.removeAll() + } +} + +// MARK: - Fixtures + +/// A Noise identity (fingerprint → short peer ID) plus a real Ed25519 +/// signing key, so creator signatures verify for real. +private struct TestIdentity { + let fingerprint: String + let nickname: String + private let signingKey = Curve25519.Signing.PrivateKey() + + init(seed: UInt8, nickname: String) { + fingerprint = Data(repeating: seed, count: 32).hexEncodedString() + self.nickname = nickname + } + + var peerID: PeerID { PeerID(str: String(fingerprint.prefix(16))) } + + var member: GroupMember { + GroupMember(fingerprint: fingerprint, signingKey: signingKey.publicKey.rawRepresentation, nickname: nickname) + } + + /// A roster entry claiming this identity's fingerprint and nickname but + /// pinning `forger`'s signing key — how a forged roster impersonates + /// real members. + func impersonated(by forger: TestIdentity) -> GroupMember { + GroupMember(fingerprint: fingerprint, signingKey: forger.member.signingKey, nickname: nickname) + } + + func sign(_ data: Data) -> Data? { try? signingKey.signature(for: data) } +} + +// MARK: - Tests + +@MainActor +struct ChatGroupCoordinatorContextTests { + private let me = TestIdentity(seed: 0x0E, nickname: "me") + private let creator = TestIdentity(seed: 0xC1, nickname: "creator") + private let member = TestIdentity(seed: 0xA2, nickname: "member") + private let attacker = TestIdentity(seed: 0xEE, nickname: "creator") + + private let groupID = Data((0..<16).map { UInt8($0) }) + private let groupKey = Data(repeating: 0x42, count: 32) + private let joinedEpoch: UInt32 = 3 + + /// A creator-signed state payload, encoded for the wire. + private func statePayload( + epoch: UInt32, + members: [GroupMember], + creatorFingerprint: String, + key: Data, + signedBy signer: TestIdentity + ) throws -> Data { + let group = BitchatGroup( + groupID: groupID, + name: "trail crew", + epoch: epoch, + members: members, + creatorFingerprint: creatorFingerprint + ) + let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: signer.sign)) + return try #require(payload.encode()) + } + + /// State from the real creator over their authenticated session. + private func creatorState(epoch: UInt32, members: [GroupMember], key: Data) throws -> Data { + try statePayload(epoch: epoch, members: members, creatorFingerprint: creator.fingerprint, key: key, signedBy: creator) + } + + /// Joins the group through the coordinator (the creator's invite), opens + /// its chat, and clears the join side effects. Both the creator and the + /// attacker hold Noise sessions with us. + private func makeJoinedGroup() throws -> (MockChatGroupContext, ChatGroupCoordinator, BitchatGroup) { + let context = MockChatGroupContext(identity: me) + let coordinator = ChatGroupCoordinator(context: context) + context.fingerprintsByPeerID[creator.peerID] = creator.fingerprint + context.fingerprintsByPeerID[attacker.peerID] = attacker.fingerprint + + let invite = try creatorState(epoch: joinedEpoch, members: [creator.member, member.member, me.member], key: groupKey) + coordinator.handleGroupInvitePayload(from: creator.peerID, payload: invite) + + let joined = try #require(context.groupStore.group(withID: groupID)) + #expect(joined.creatorFingerprint == creator.fingerprint) + #expect(context.systemMessages.count == 1) // the join notice + context.selectedPrivateChatPeer = joined.peerID + context.clearRecordedEffects() + return (context, coordinator, joined) + } + + private func expectUnchanged(_ context: MockChatGroupContext, joined: BitchatGroup) { + #expect(context.groupStore.group(withID: groupID) == joined) + #expect(context.groupStore.key(forGroupID: groupID) == groupKey) + #expect(context.removedPrivateChats.isEmpty) + #expect(context.endPrivateChatCount == 0) + #expect(context.systemMessages.isEmpty) + } + + // MARK: Takeover + + @Test + func nonCreatorNamingItselfCreatorAtHigherEpochIsRejected() throws { + let (context, coordinator, joined) = try makeJoinedGroup() + + // Self-named creator, self-signed, at the highest epoch, with every + // real member re-pinned to the attacker's key. Passes the sender and + // signature checks; only the stored-creator pin stops it. + let hijack = try statePayload( + epoch: .max, + members: [attacker.member, me.member, creator.impersonated(by: attacker), member.impersonated(by: attacker)], + creatorFingerprint: attacker.fingerprint, + key: Data(repeating: 0xAB, count: 32), + signedBy: attacker + ) + coordinator.handleGroupKeyUpdatePayload(from: attacker.peerID, payload: hijack) + expectUnchanged(context, joined: joined) + coordinator.handleGroupInvitePayload(from: attacker.peerID, payload: hijack) + expectUnchanged(context, joined: joined) + + // The attacker's epoch did not lock the real creator out. + let rotatedKey = Data(repeating: 0x5A, count: 32) + let rotation = try creatorState(epoch: joinedEpoch + 1, members: [creator.member, member.member, me.member], key: rotatedKey) + coordinator.handleGroupKeyUpdatePayload(from: creator.peerID, payload: rotation) + #expect(context.groupStore.group(withID: groupID)?.epoch == joinedEpoch + 1) + #expect(context.groupStore.key(forGroupID: groupID) == rotatedKey) + } + + @Test + func stateNamingTheStoredCreatorWithAnotherSigningKeyIsRejected() throws { + let (context, coordinator, joined) = try makeJoinedGroup() + // Even a session that resolves to the stored creator's fingerprint + // cannot re-pin the creator's signing key. + let impostorPeerID = PeerID(str: "00112233aabbccdd") + context.fingerprintsByPeerID[impostorPeerID] = creator.fingerprint + + let repin = try statePayload( + epoch: joinedEpoch + 1, + members: [creator.impersonated(by: attacker), member.member, me.member], + creatorFingerprint: creator.fingerprint, + key: Data(repeating: 0xAB, count: 32), + signedBy: attacker + ) + coordinator.handleGroupKeyUpdatePayload(from: impostorPeerID, payload: repin) + + expectUnchanged(context, joined: joined) + } + + // MARK: Deletion + + @Test + func nonCreatorRosterOmittingUsDoesNotDeleteTheGroup() throws { + let (context, coordinator, joined) = try makeJoinedGroup() + + let eviction = try statePayload( + epoch: joinedEpoch + 1, + members: [attacker.member, creator.impersonated(by: attacker)], + creatorFingerprint: attacker.fingerprint, + key: Data(repeating: 0xAB, count: 32), + signedBy: attacker + ) + coordinator.handleGroupKeyUpdatePayload(from: attacker.peerID, payload: eviction) + + expectUnchanged(context, joined: joined) + } + + // MARK: Legitimate creator + + @Test + func creatorRotationIsAccepted() throws { + let (context, coordinator, joined) = try makeJoinedGroup() + + let rotatedKey = Data(repeating: 0x5A, count: 32) + let rotation = try creatorState(epoch: joinedEpoch + 1, members: [creator.member, me.member], key: rotatedKey) + coordinator.handleGroupKeyUpdatePayload(from: creator.peerID, payload: rotation) + + let stored = try #require(context.groupStore.group(withID: groupID)) + #expect(stored.epoch == joinedEpoch + 1) + #expect(stored.members == [creator.member, me.member]) + #expect(stored.creator == joined.creator) + #expect(context.groupStore.key(forGroupID: groupID) == rotatedKey) + #expect(context.removedPrivateChats.isEmpty) + #expect(context.systemMessages.isEmpty) // not a new membership + } + + @Test + func creatorRemovalStillRemovesTheGroup() throws { + let (context, coordinator, joined) = try makeJoinedGroup() + + // What `notifyRemovedMember` sends: the rotated roster without us, + // under a throwaway all-zero key. + let removal = try creatorState( + epoch: joinedEpoch + 1, + members: [creator.member, member.member], + key: Data(count: BitchatGroup.keyLength) + ) + coordinator.handleGroupKeyUpdatePayload(from: creator.peerID, payload: removal) + + #expect(context.groupStore.group(withID: groupID) == nil) + #expect(context.groupStore.key(forGroupID: groupID) == nil) + #expect(context.removedPrivateChats == [joined.peerID]) + #expect(context.endPrivateChatCount == 1) + #expect(context.systemMessages.count == 1) // "removed from group" + } + + @Test + func staleCreatorStateIsRejectedIncludingStaleRemoval() throws { + let (context, coordinator, joined) = try makeJoinedGroup() + + let staleRotation = try creatorState( + epoch: joinedEpoch - 1, + members: [creator.member, me.member], + key: Data(repeating: 0x5A, count: 32) + ) + coordinator.handleGroupKeyUpdatePayload(from: creator.peerID, payload: staleRotation) + expectUnchanged(context, joined: joined) + + // An older removal (e.g. delivered after a re-invite) must not undo + // the newer membership. + let staleRemoval = try creatorState( + epoch: joinedEpoch - 1, + members: [creator.member, member.member], + key: Data(count: BitchatGroup.keyLength) + ) + coordinator.handleGroupKeyUpdatePayload(from: creator.peerID, payload: staleRemoval) + expectUnchanged(context, joined: joined) + } +} diff --git a/bitchatTests/Services/GroupStoreTests.swift b/bitchatTests/Services/GroupStoreTests.swift index adf04806..a5130bff 100644 --- a/bitchatTests/Services/GroupStoreTests.swift +++ b/bitchatTests/Services/GroupStoreTests.swift @@ -81,6 +81,58 @@ struct GroupStoreTests { #expect(store.group(withID: group.groupID)?.members == [creator]) } + // MARK: - Creator pinning + + @Test func upsertRefusesToChangeTheCreatorOfAnExistingGroup() throws { + let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) + let creator = makeMember(seed: 0xC1, nickname: "creator") + let me = makeMember(seed: 0x0E, nickname: "me") + let attacker = makeMember(seed: 0xEE, nickname: "creator") + let group = try #require(store.createGroup(named: "trip", creator: creator)) + #expect(store.updateRoster(groupID: group.groupID, members: [creator, me]) != nil) + let stored = try #require(store.group(withID: group.groupID)) + let realKey = try #require(store.key(forGroupID: group.groupID)) + + // Same groupID at a higher epoch with the attacker as creator… + let hijack = BitchatGroup( + groupID: group.groupID, + name: group.name, + epoch: .max, + members: [attacker, me], + creatorFingerprint: attacker.fingerprint + ) + #expect(!store.upsert(hijack, key: Data(repeating: 0xAB, count: 32))) + + // …or the creator's fingerprint re-pinned to the attacker's signing key. + let repinned = GroupMember(fingerprint: creator.fingerprint, signingKey: attacker.signingKey, nickname: creator.nickname) + var repin = stored + repin.epoch = .max + repin.members = [repinned, me] + #expect(!store.upsert(repin, key: Data(repeating: 0xAB, count: 32))) + + #expect(store.group(withID: group.groupID) == stored) + #expect(store.key(forGroupID: group.groupID) == realKey) + + // The boundary: the same creator still moves the group forward. + var rotated = stored + rotated.epoch = stored.epoch + 1 + let newKey = Data(repeating: 0x5A, count: 32) + #expect(store.upsert(rotated, key: newKey)) + #expect(store.group(withID: group.groupID)?.epoch == stored.epoch + 1) + #expect(store.key(forGroupID: group.groupID) == newKey) + } + + @Test func updateRosterRefusesToRepinTheCreatorSigningKey() throws { + let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) + let creator = makeMember(seed: 0xC1, nickname: "creator") + let me = makeMember(seed: 0x0E, nickname: "me") + let group = try #require(store.createGroup(named: "trip", creator: creator)) + + let repinned = GroupMember(fingerprint: creator.fingerprint, signingKey: Data(repeating: 0xEF, count: 32), nickname: creator.nickname) + #expect(store.updateRoster(groupID: group.groupID, members: [repinned, me]) == nil) + #expect(store.group(withID: group.groupID) == group) + } + // MARK: - Rotation @Test func rotateKeyBumpsEpochAndReplacesKey() throws { @@ -136,6 +188,42 @@ struct GroupStoreTests { #expect(reloaded.group(withID: group.groupID) == nil) } + @Test func loadEnforcesTheUpsertInvariants() throws { + let keychain = MockKeychain() + let fileURL = tempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let creator = makeMember(seed: 0xC1, nickname: "creator") + let other = makeMember(seed: 0xEE, nickname: "other") + let kept: BitchatGroup + let orphaned: BitchatGroup + do { + let store = GroupStore(keychain: keychain, fileURL: fileURL) + kept = try #require(store.createGroup(named: "kept", creator: creator)) + orphaned = try #require(store.createGroup(named: "orphaned", creator: creator)) + } + // Loading bypasses `upsert`: a second entry handing `kept` to another + // creator, and an entry whose creator is missing from its roster. + let rehomed = BitchatGroup( + groupID: kept.groupID, + name: kept.name, + epoch: kept.epoch + 1, + members: [other], + creatorFingerprint: other.fingerprint + ) + let creatorless = BitchatGroup( + groupID: orphaned.groupID, + name: orphaned.name, + epoch: orphaned.epoch, + members: orphaned.members, + creatorFingerprint: other.fingerprint + ) + try JSONEncoder().encode([kept, rehomed, creatorless]).write(to: fileURL) + + let reloaded = GroupStore(keychain: keychain, fileURL: fileURL) + #expect(reloaded.groups == [kept]) + } + // MARK: - Panic wipe @Test func wipeRemovesMetadataAndKeys() throws {