mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-11-01 07:53:26 +00:00
Pin the stored creator on incoming private-group state (#1720)
applyGroupState authorized state only against the creator named in the state itself, not the creator of the group already stored under that groupID, and handled removal before any existing-group check. - applyGroupState: for a group already held, require the stored creator's fingerprint and signing key, and a non-stale epoch, before the removal branch. - GroupStore.upsert refuses to change an existing group's creator; updateRoster keeps the creator's entry pinned; loadFromDisk re-checks the same invariants and keeps one entry per groupID. - First ChatGroupCoordinator tests. Ports the approach from #1587 by @areebahmeddd. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0b5cae856d
commit
3257ced77e
@ -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
|
||||
|
||||
@ -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<Data>()
|
||||
groups = stored.filter { group in
|
||||
Self.isWellFormed(group)
|
||||
&& seenGroupIDs.insert(group.groupID).inserted
|
||||
&& key(forGroupID: group.groupID) != nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func defaultFileURL() -> URL? {
|
||||
|
||||
@ -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)
|
||||
|
||||
302
bitchatTests/ChatGroupCoordinatorContextTests.swift
Normal file
302
bitchatTests/ChatGroupCoordinatorContextTests.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user