mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 01:25:20 +00:00
Merge remote-tracking branch 'origin/feat/private-groups' into feat/integration-all
# Conflicts: # bitchat/Protocols/BitchatProtocol.swift # bitchat/Protocols/PeerCapabilities+Local.swift # bitchat/Services/BLE/BLEOutboundPacketPolicy.swift # bitchat/Services/BLE/BLEService.swift # bitchat/Services/Transport.swift # bitchat/Sync/GossipSyncManager.swift # bitchat/Sync/SyncTypeFlags.swift # bitchat/ViewModels/ChatTransportEventCoordinator.swift # bitchat/ViewModels/ChatViewModel.swift # bitchat/ViewModels/NostrInboundPipeline.swift # bitchatTests/ChatTransportEventCoordinatorContextTests.swift # bitchatTests/GossipSyncManagerTests.swift # localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift
This commit is contained in:
@@ -162,6 +162,18 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
|
||||
func handleVouchPayload(from peerID: PeerID, payload: Data) {
|
||||
vouchPayloads.append((peerID, payload))
|
||||
}
|
||||
|
||||
// Group payloads
|
||||
private(set) var groupInvitePayloads: [(peerID: PeerID, payload: Data)] = []
|
||||
private(set) var groupKeyUpdatePayloads: [(peerID: PeerID, payload: Data)] = []
|
||||
|
||||
func handleGroupInvitePayload(from peerID: PeerID, payload: Data) {
|
||||
groupInvitePayloads.append((peerID, payload))
|
||||
}
|
||||
|
||||
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
|
||||
groupKeyUpdatePayloads.append((peerID, payload))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@@ -492,4 +492,32 @@ private final class MockCommandContextProvider: CommandContextProvider {
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
favoriteNotifications.append((peerID, isFavorite))
|
||||
}
|
||||
|
||||
// Groups: record the parsed subcommand + argument the processor forwarded.
|
||||
private(set) var groupCommands: [(subcommand: String, argument: String)] = []
|
||||
|
||||
func groupCreate(named name: String) -> CommandResult {
|
||||
groupCommands.append(("create", name))
|
||||
return .handled
|
||||
}
|
||||
|
||||
func groupInvite(nickname: String) -> CommandResult {
|
||||
groupCommands.append(("invite", nickname))
|
||||
return .handled
|
||||
}
|
||||
|
||||
func groupRemove(nickname: String) -> CommandResult {
|
||||
groupCommands.append(("remove", nickname))
|
||||
return .handled
|
||||
}
|
||||
|
||||
func groupLeave() -> CommandResult {
|
||||
groupCommands.append(("leave", ""))
|
||||
return .handled
|
||||
}
|
||||
|
||||
func groupList() -> CommandResult {
|
||||
groupCommands.append(("list", ""))
|
||||
return .handled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +208,10 @@ struct GossipSyncManagerTests {
|
||||
#expect(allTypes.contains(.fragment))
|
||||
#expect(allTypes.contains(.fileTransfer))
|
||||
#expect(allTypes.contains(.prekeyBundle))
|
||||
#expect(decoded.contains { $0.types == .publicMessages })
|
||||
#expect(allTypes.contains(.groupMessage))
|
||||
// The message schedule also asks for group messages (bit 10);
|
||||
// responders that don't know the bit just ignore it.
|
||||
#expect(decoded.contains { $0.types == SyncTypeFlags.publicMessages.union(.groupMessage) })
|
||||
#expect(decoded.contains { $0.types == .fragment })
|
||||
#expect(decoded.contains { $0.types == .fileTransfer })
|
||||
#expect(decoded.contains { $0.types == .prekeyBundle })
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//
|
||||
// GroupProtocolTests.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct GroupProtocolTests {
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// Deterministic member identity: an Ed25519 keypair plus the 64-hex
|
||||
/// fingerprint the roster pins.
|
||||
private struct TestIdentity {
|
||||
let signingKey: Curve25519.Signing.PrivateKey
|
||||
let fingerprint: String
|
||||
|
||||
init(seed: UInt8) {
|
||||
signingKey = Curve25519.Signing.PrivateKey()
|
||||
fingerprint = Data(repeating: seed, count: 32).hexEncodedString()
|
||||
}
|
||||
|
||||
var member: GroupMember {
|
||||
GroupMember(
|
||||
fingerprint: fingerprint,
|
||||
signingKey: signingKey.publicKey.rawRepresentation,
|
||||
nickname: "peer-\(fingerprint.prefix(4))"
|
||||
)
|
||||
}
|
||||
|
||||
func sign(_ data: Data) -> Data? {
|
||||
try? signingKey.signature(for: data)
|
||||
}
|
||||
}
|
||||
|
||||
private let creator = TestIdentity(seed: 0xC1)
|
||||
private let member = TestIdentity(seed: 0xA2)
|
||||
private let outsider = TestIdentity(seed: 0xE3)
|
||||
|
||||
private let groupID = Data((0..<16).map { UInt8($0) })
|
||||
private let key = Data(repeating: 0x42, count: 32)
|
||||
|
||||
private func makeGroup(extraMembers: [GroupMember] = [], epoch: UInt32 = 1) -> BitchatGroup {
|
||||
BitchatGroup(
|
||||
groupID: groupID,
|
||||
name: "trail crew",
|
||||
epoch: epoch,
|
||||
members: [creator.member, member.member] + extraMembers,
|
||||
creatorFingerprint: creator.fingerprint
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - State payload (invite / key update)
|
||||
|
||||
@Test func statePayloadRoundTripAndSignatureVerify() throws {
|
||||
let group = makeGroup()
|
||||
let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign))
|
||||
let encoded = try #require(payload.encode())
|
||||
|
||||
let decoded = try #require(GroupStatePayload.decode(encoded))
|
||||
#expect(decoded == payload)
|
||||
#expect(decoded.groupID == groupID)
|
||||
#expect(decoded.name == "trail crew")
|
||||
#expect(decoded.key == key)
|
||||
#expect(decoded.epoch == 1)
|
||||
#expect(decoded.members == group.members)
|
||||
#expect(decoded.creatorFingerprint == creator.fingerprint)
|
||||
#expect(decoded.verifyCreatorSignature())
|
||||
#expect(decoded.asGroup == group)
|
||||
}
|
||||
|
||||
@Test func forgedCreatorSignatureIsRejected() throws {
|
||||
let group = makeGroup()
|
||||
// Signed by a member who is in the roster but is NOT the creator.
|
||||
let forged = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: member.sign))
|
||||
#expect(!forged.verifyCreatorSignature())
|
||||
|
||||
// An outsider signing is equally rejected.
|
||||
let outsiderForged = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: outsider.sign))
|
||||
#expect(!outsiderForged.verifyCreatorSignature())
|
||||
}
|
||||
|
||||
@Test func tamperedStateFailsSignature() throws {
|
||||
let group = makeGroup()
|
||||
let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign))
|
||||
|
||||
// Bumping the epoch invalidates the signature.
|
||||
let epochTampered = GroupStatePayload(
|
||||
groupID: payload.groupID,
|
||||
name: payload.name,
|
||||
key: payload.key,
|
||||
epoch: payload.epoch + 1,
|
||||
members: payload.members,
|
||||
creatorFingerprint: payload.creatorFingerprint,
|
||||
signature: payload.signature
|
||||
)
|
||||
#expect(!epochTampered.verifyCreatorSignature())
|
||||
|
||||
// So does swapping the key.
|
||||
let keyTampered = GroupStatePayload(
|
||||
groupID: payload.groupID,
|
||||
name: payload.name,
|
||||
key: Data(repeating: 0x99, count: 32),
|
||||
epoch: payload.epoch,
|
||||
members: payload.members,
|
||||
creatorFingerprint: payload.creatorFingerprint,
|
||||
signature: payload.signature
|
||||
)
|
||||
#expect(!keyTampered.verifyCreatorSignature())
|
||||
|
||||
// And so does adding a member to the roster.
|
||||
let rosterTampered = GroupStatePayload(
|
||||
groupID: payload.groupID,
|
||||
name: payload.name,
|
||||
key: payload.key,
|
||||
epoch: payload.epoch,
|
||||
members: payload.members + [outsider.member],
|
||||
creatorFingerprint: payload.creatorFingerprint,
|
||||
signature: payload.signature
|
||||
)
|
||||
#expect(!rosterTampered.verifyCreatorSignature())
|
||||
}
|
||||
|
||||
@Test func creatorMissingFromRosterIsRejected() throws {
|
||||
// State claiming a creator whose fingerprint is not in the roster has
|
||||
// no key to verify against and must fail closed.
|
||||
let group = BitchatGroup(
|
||||
groupID: groupID,
|
||||
name: "orphan",
|
||||
epoch: 1,
|
||||
members: [member.member],
|
||||
creatorFingerprint: creator.fingerprint
|
||||
)
|
||||
guard let rosterBlob = GroupRosterCoding.encode(group.members) else {
|
||||
Issue.record("roster should encode")
|
||||
return
|
||||
}
|
||||
let content = GroupStatePayload.signingContent(groupID: groupID, epoch: 1, key: key, rosterBlob: rosterBlob, name: group.name)
|
||||
let payload = GroupStatePayload(
|
||||
groupID: groupID,
|
||||
name: group.name,
|
||||
key: key,
|
||||
epoch: 1,
|
||||
members: group.members,
|
||||
creatorFingerprint: creator.fingerprint,
|
||||
signature: creator.sign(content) ?? Data()
|
||||
)
|
||||
#expect(!payload.verifyCreatorSignature())
|
||||
}
|
||||
|
||||
@Test func rosterCapIsEnforcedOnTheWire() {
|
||||
// 17 members cannot be encoded (hard cap is 16)…
|
||||
let seventeen = (0..<17).map { TestIdentity(seed: UInt8($0 + 1)).member }
|
||||
#expect(GroupRosterCoding.encode(seventeen) == nil)
|
||||
|
||||
// …and a hand-built blob claiming 17 members fails to decode.
|
||||
let sixteen = (0..<16).map { TestIdentity(seed: UInt8($0 + 1)).member }
|
||||
guard var blob = GroupRosterCoding.encode(sixteen) else {
|
||||
Issue.record("16-member roster should encode")
|
||||
return
|
||||
}
|
||||
#expect(GroupRosterCoding.decode(blob)?.count == 16)
|
||||
blob[blob.startIndex] = 17
|
||||
#expect(GroupRosterCoding.decode(blob) == nil)
|
||||
}
|
||||
|
||||
// MARK: - Message seal / open
|
||||
|
||||
@Test func messageRoundTrip() throws {
|
||||
let group = makeGroup()
|
||||
let timestampMs: UInt64 = 1_750_000_000_000
|
||||
let sealed = try GroupCrypto.sealMessage(
|
||||
content: "summit at noon",
|
||||
messageID: "msg-1",
|
||||
senderNickname: "alice",
|
||||
senderSigningKey: member.member.signingKey,
|
||||
timestampMs: timestampMs,
|
||||
groupID: groupID,
|
||||
epoch: group.epoch,
|
||||
key: key,
|
||||
sign: member.sign
|
||||
)
|
||||
|
||||
let envelope = try #require(GroupMessageEnvelope.decode(sealed))
|
||||
#expect(envelope.groupID == groupID)
|
||||
#expect(envelope.epoch == group.epoch)
|
||||
|
||||
let plaintext = try GroupCrypto.openMessage(envelope, key: key)
|
||||
#expect(plaintext.messageID == "msg-1")
|
||||
#expect(plaintext.content == "summit at noon")
|
||||
#expect(plaintext.senderNickname == "alice")
|
||||
#expect(plaintext.timestampMs == timestampMs)
|
||||
#expect(plaintext.senderSigningKey == member.member.signingKey)
|
||||
|
||||
// The roster resolves the sender; an outsider's key would not.
|
||||
#expect(group.member(withSigningKey: plaintext.senderSigningKey) != nil)
|
||||
#expect(group.member(withSigningKey: outsider.member.signingKey) == nil)
|
||||
}
|
||||
|
||||
@Test func wrongKeyFailsToDecrypt() throws {
|
||||
let sealed = try GroupCrypto.sealMessage(
|
||||
content: "hi",
|
||||
messageID: "msg-2",
|
||||
senderNickname: "alice",
|
||||
senderSigningKey: member.member.signingKey,
|
||||
timestampMs: 1,
|
||||
groupID: groupID,
|
||||
epoch: 1,
|
||||
key: key,
|
||||
sign: member.sign
|
||||
)
|
||||
let envelope = try #require(GroupMessageEnvelope.decode(sealed))
|
||||
#expect(throws: GroupCryptoError.decryptionFailed) {
|
||||
_ = try GroupCrypto.openMessage(envelope, key: Data(repeating: 0x7F, count: 32))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func epochIsBoundIntoTheCiphertext() throws {
|
||||
// Re-labeling an epoch-1 envelope as epoch 2 must break the AEAD:
|
||||
// a rotated-out member cannot replay old ciphertext into a new epoch.
|
||||
let sealed = try GroupCrypto.sealMessage(
|
||||
content: "hi",
|
||||
messageID: "msg-3",
|
||||
senderNickname: "alice",
|
||||
senderSigningKey: member.member.signingKey,
|
||||
timestampMs: 1,
|
||||
groupID: groupID,
|
||||
epoch: 1,
|
||||
key: key,
|
||||
sign: member.sign
|
||||
)
|
||||
let envelope = try #require(GroupMessageEnvelope.decode(sealed))
|
||||
let relabeled = GroupMessageEnvelope(
|
||||
groupID: envelope.groupID,
|
||||
epoch: 2,
|
||||
nonce: envelope.nonce,
|
||||
ciphertext: envelope.ciphertext
|
||||
)
|
||||
#expect(throws: GroupCryptoError.decryptionFailed) {
|
||||
_ = try GroupCrypto.openMessage(relabeled, key: key)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func badSenderSignatureIsRejected() throws {
|
||||
// A key-holder who signs with a key other than the one they claim
|
||||
// (or garbage) is dropped even though decryption succeeds.
|
||||
let sealed = try GroupCrypto.sealMessage(
|
||||
content: "spoof",
|
||||
messageID: "msg-4",
|
||||
senderNickname: "mallory",
|
||||
senderSigningKey: member.member.signingKey, // claims member's key…
|
||||
timestampMs: 1,
|
||||
groupID: groupID,
|
||||
epoch: 1,
|
||||
key: key,
|
||||
sign: outsider.sign // …but signs with the outsider's
|
||||
)
|
||||
let envelope = try #require(GroupMessageEnvelope.decode(sealed))
|
||||
#expect(throws: GroupCryptoError.badSenderSignature) {
|
||||
_ = try GroupCrypto.openMessage(envelope, key: key)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func tamperedCiphertextFailsToOpen() throws {
|
||||
let sealed = try GroupCrypto.sealMessage(
|
||||
content: "hi",
|
||||
messageID: "msg-5",
|
||||
senderNickname: "alice",
|
||||
senderSigningKey: member.member.signingKey,
|
||||
timestampMs: 1,
|
||||
groupID: groupID,
|
||||
epoch: 1,
|
||||
key: key,
|
||||
sign: member.sign
|
||||
)
|
||||
let envelope = try #require(GroupMessageEnvelope.decode(sealed))
|
||||
var flipped = envelope.ciphertext
|
||||
flipped[flipped.startIndex] ^= 0x01
|
||||
let tampered = GroupMessageEnvelope(
|
||||
groupID: envelope.groupID,
|
||||
epoch: envelope.epoch,
|
||||
nonce: envelope.nonce,
|
||||
ciphertext: flipped
|
||||
)
|
||||
#expect(throws: GroupCryptoError.decryptionFailed) {
|
||||
_ = try GroupCrypto.openMessage(tampered, key: key)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func malformedEnvelopesAreRejected() {
|
||||
#expect(GroupMessageEnvelope.decode(Data()) == nil)
|
||||
#expect(GroupMessageEnvelope.decode(Data([0x01, 0x00])) == nil)
|
||||
#expect(GroupStatePayload.decode(Data([0xFF, 0x00, 0x01])) == nil)
|
||||
}
|
||||
|
||||
// MARK: - Oversize / UTF-8 safety (Codex findings)
|
||||
|
||||
@Test func oversizeMessageContentFailsToSealInsteadOfTruncating() {
|
||||
// A content whose UTF-8 exceeds the 16-bit TLV length must fail to
|
||||
// seal (surfacing send_failed) rather than silently truncate into a
|
||||
// ciphertext recipients would drop.
|
||||
let oversize = String(repeating: "a", count: 70_000)
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try GroupCrypto.sealMessage(
|
||||
content: oversize,
|
||||
messageID: "big",
|
||||
senderNickname: "alice",
|
||||
senderSigningKey: member.member.signingKey,
|
||||
timestampMs: 1,
|
||||
groupID: groupID,
|
||||
epoch: 1,
|
||||
key: key,
|
||||
sign: member.sign
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func multiByteNicknameTruncatesOnScalarBoundary() throws {
|
||||
// 40 euro signs = 120 UTF-8 bytes; a raw 64-byte prefix would split
|
||||
// the 21st scalar and make the roster undecodable. Truncation must
|
||||
// land on a Character boundary so the blob round-trips.
|
||||
let euros = String(repeating: "€", count: 40)
|
||||
let wide = GroupMember(
|
||||
fingerprint: creator.fingerprint,
|
||||
signingKey: creator.member.signingKey,
|
||||
nickname: euros
|
||||
)
|
||||
let blob = try #require(GroupRosterCoding.encode([wide]))
|
||||
let decoded = try #require(GroupRosterCoding.decode(blob))
|
||||
#expect(decoded.count == 1)
|
||||
#expect(Data(decoded[0].nickname.utf8).count <= 64)
|
||||
#expect(decoded[0].nickname.allSatisfy { $0 == "€" })
|
||||
#expect(!decoded[0].nickname.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Signable-bytes forward-proofing
|
||||
|
||||
@Test func creatorSignatureCoversName() throws {
|
||||
let group = makeGroup()
|
||||
let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign))
|
||||
#expect(payload.verifyCreatorSignature())
|
||||
|
||||
// Swapping only the display name must invalidate the creator signature.
|
||||
let renamed = GroupStatePayload(
|
||||
groupID: payload.groupID,
|
||||
name: "totally different name",
|
||||
key: payload.key,
|
||||
epoch: payload.epoch,
|
||||
members: payload.members,
|
||||
creatorFingerprint: payload.creatorFingerprint,
|
||||
signature: payload.signature
|
||||
)
|
||||
#expect(!renamed.verifyCreatorSignature())
|
||||
}
|
||||
|
||||
@Test func messageSignatureCoversEpoch() {
|
||||
// The signed bytes differ by epoch, so a signature captured at one
|
||||
// epoch cannot verify when re-sealed under a later epoch key.
|
||||
let atEpoch1 = GroupCrypto.messageSigningContent(
|
||||
groupID: groupID, epoch: 1, messageID: "m", timestampMs: 1, content: "x"
|
||||
)
|
||||
let atEpoch2 = GroupCrypto.messageSigningContent(
|
||||
groupID: groupID, epoch: 2, messageID: "m", timestampMs: 1, content: "x"
|
||||
)
|
||||
#expect(atEpoch1 != atEpoch2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// SyncTypeFlagsGroupTests.swift
|
||||
// bitchat
|
||||
//
|
||||
// Wire-compat proof for the groupMessage sync bit (bit 10): the types
|
||||
// bitfield widens from 1 to 2 bytes, and clients that don't know the bit
|
||||
// simply ignore it.
|
||||
//
|
||||
// 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
|
||||
|
||||
struct SyncTypeFlagsGroupTests {
|
||||
|
||||
@Test func groupMessageOccupiesBitTen() {
|
||||
#expect(SyncTypeFlags.groupMessage.rawValue == 1 << 10)
|
||||
#expect(SyncTypeFlags.groupMessage.contains(.groupMessage))
|
||||
#expect(!SyncTypeFlags.publicMessages.contains(.groupMessage))
|
||||
}
|
||||
|
||||
@Test func extendedBitfieldWidensToTwoBytes() throws {
|
||||
// Legacy flags fit one byte…
|
||||
#expect(SyncTypeFlags.publicMessages.toData() == Data([0x03]))
|
||||
|
||||
// …the group bit widens the little-endian encoding to two bytes.
|
||||
let combined = SyncTypeFlags.publicMessages.union(.groupMessage)
|
||||
let encoded = try #require(combined.toData())
|
||||
#expect(encoded == Data([0x03, 0x04]))
|
||||
|
||||
let decoded = try #require(SyncTypeFlags.decode(encoded))
|
||||
#expect(decoded == combined)
|
||||
#expect(Set(decoded.toMessageTypes()) == Set([.announce, .message, .groupMessage]))
|
||||
}
|
||||
|
||||
@Test func unknownBitsAreIgnoredNotRejected() throws {
|
||||
// An "old client" reading a 2-byte field keeps the raw bits but maps
|
||||
// unknown bit indices to no message type — it answers with the types
|
||||
// it knows instead of dropping the request.
|
||||
let futuristic = try #require(SyncTypeFlags.decode(Data([0x03, 0xFC])))
|
||||
#expect(Set(futuristic.toMessageTypes()) == Set([.announce, .message, .groupMessage]))
|
||||
#expect(futuristic.contains(.announce))
|
||||
#expect(futuristic.contains(.message))
|
||||
}
|
||||
|
||||
@Test func requestSyncPacketRoundTripsGroupFlag() throws {
|
||||
let types = SyncTypeFlags.publicMessages.union(.groupMessage)
|
||||
let packet = RequestSyncPacket(p: 8, m: 1024, data: Data([0xAB, 0xCD]), types: types)
|
||||
let encoded = packet.encode()
|
||||
|
||||
let decoded = try #require(RequestSyncPacket.decode(from: encoded))
|
||||
#expect(decoded.types == types)
|
||||
#expect(decoded.types?.contains(.groupMessage) == true)
|
||||
#expect(decoded.p == 8)
|
||||
#expect(decoded.m == 1024)
|
||||
#expect(decoded.data == Data([0xAB, 0xCD]))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user