diff --git a/bitchat/Services/Groups/GroupStore.swift b/bitchat/Services/Groups/GroupStore.swift index a9aa7faa..7e570ede 100644 --- a/bitchat/Services/Groups/GroupStore.swift +++ b/bitchat/Services/Groups/GroupStore.swift @@ -77,14 +77,17 @@ final class GroupStore: ObservableObject { !group.members.isEmpty, group.members.count <= BitchatGroup.maxMembers, group.creator != nil else { return false } - // A group keeps the creator it was created with. `applyGroupState` - // checks the sender is the creator the state NAMES, but never against - // the creator already stored, so a peer naming themselves creator of a - // known groupID at a higher epoch replaces the roster and key wholesale. - // Pinned here, the one point every write goes through. + // A group keeps the creator it was created with. A peer naming + // themselves creator of a known groupID at a higher epoch would + // otherwise replace the roster and key wholesale. `applyGroupState` + // pins this before its removal branch; here is the one point every + // write goes through. if let existing = groups.first(where: { $0.groupID == group.groupID }), existing.creatorFingerprint != group.creatorFingerprint { - SecureLogger.warning("Refusing group state that changes the creator of an existing group", category: .security) + 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 { diff --git a/bitchat/ViewModels/ChatGroupCoordinator.swift b/bitchat/ViewModels/ChatGroupCoordinator.swift index b8a2b2a6..131cbe94 100644 --- a/bitchat/ViewModels/ChatGroupCoordinator.swift +++ b/bitchat/ViewModels/ChatGroupCoordinator.swift @@ -558,6 +558,18 @@ private extension ChatGroupCoordinator { let myFingerprint = context.myNoiseFingerprint() let existing = context.groupStore.group(withID: state.groupID) + // A group keeps the creator it was created with. The checks above only + // prove the sender is the creator the state names, which an attacker + // satisfies by naming themselves. Checked before the removal branch, + // which drops the group without ever reaching `upsert`. + if let existing, existing.creatorFingerprint != state.creatorFingerprint { + SecureLogger.warning( + "Dropping group state claiming creator \(state.creatorFingerprint.prefix(8))… for a group created by \(existing.creatorFingerprint.prefix(8))…", + 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 {