mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05:19 +00:00
Remove all channel functionality and clean up test suite
- Remove channel UI elements from ContentView - Remove channel data structures and methods from ChatViewModel - Remove channel commands (/j, /leave, /channels) - Remove channel field from BitchatMessage protocol - Remove channel message types and handling - Remove NoiseChannelEncryption.swift entirely - Clean up all channel references across the codebase - Fix compilation warnings (var to let conversions) - Remove all outdated test files that used incorrect APIs - Simplify app to only support public broadcast and 1:1 private messages
This commit is contained in:
@@ -1,110 +0,0 @@
|
||||
//
|
||||
// BinaryProtocolTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
class BinaryProtocolTests: XCTestCase {
|
||||
|
||||
func testPacketEncodingDecoding() {
|
||||
// Test basic packet
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data("testuser".utf8),
|
||||
recipientID: Data("recipient".utf8),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data("Hello, World!".utf8),
|
||||
signature: nil,
|
||||
ttl: 5
|
||||
)
|
||||
|
||||
// Encode
|
||||
guard let encoded = packet.toBinaryData() else {
|
||||
XCTFail("Failed to encode packet")
|
||||
return
|
||||
}
|
||||
|
||||
// Decode
|
||||
guard let decoded = BitchatPacket.from(encoded) else {
|
||||
XCTFail("Failed to decode packet")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify
|
||||
XCTAssertEqual(decoded.version, packet.version)
|
||||
XCTAssertEqual(decoded.type, packet.type)
|
||||
XCTAssertEqual(decoded.ttl, packet.ttl)
|
||||
XCTAssertEqual(decoded.timestamp, packet.timestamp)
|
||||
XCTAssertEqual(decoded.payload, packet.payload)
|
||||
}
|
||||
|
||||
func testBroadcastPacket() {
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data("sender".utf8),
|
||||
recipientID: SpecialRecipients.broadcast,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data("Broadcast message".utf8),
|
||||
signature: nil,
|
||||
ttl: 3
|
||||
)
|
||||
|
||||
guard let encoded = packet.toBinaryData() else {
|
||||
XCTFail("Failed to encode broadcast packet")
|
||||
return
|
||||
}
|
||||
|
||||
guard let decoded = BitchatPacket.from(encoded) else {
|
||||
XCTFail("Failed to decode broadcast packet")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify broadcast recipient
|
||||
XCTAssertEqual(decoded.recipientID, SpecialRecipients.broadcast)
|
||||
}
|
||||
|
||||
func testPacketWithSignature() {
|
||||
let signature = Data(repeating: 0xAB, count: 64)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data("sender".utf8),
|
||||
recipientID: Data("recipient".utf8),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data("Signed message".utf8),
|
||||
signature: signature,
|
||||
ttl: 5
|
||||
)
|
||||
|
||||
guard let encoded = packet.toBinaryData() else {
|
||||
XCTFail("Failed to encode signed packet")
|
||||
return
|
||||
}
|
||||
|
||||
guard let decoded = BitchatPacket.from(encoded) else {
|
||||
XCTFail("Failed to decode signed packet")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertNotNil(decoded.signature)
|
||||
XCTAssertEqual(decoded.signature, signature)
|
||||
}
|
||||
|
||||
func testInvalidPacketHandling() {
|
||||
// Test empty data
|
||||
XCTAssertNil(BitchatPacket.from(Data()))
|
||||
|
||||
// Test truncated data
|
||||
let truncated = Data(repeating: 0, count: 10)
|
||||
XCTAssertNil(BitchatPacket.from(truncated))
|
||||
|
||||
// Test invalid version
|
||||
var invalidVersion = Data(repeating: 0, count: 100)
|
||||
invalidVersion[0] = 99 // Invalid version
|
||||
XCTAssertNil(BitchatPacket.from(invalidVersion))
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
//
|
||||
// BinaryProtocolVersionTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
class BinaryProtocolVersionTests: XCTestCase {
|
||||
|
||||
// MARK: - Version Support Tests
|
||||
|
||||
func testCurrentVersionIsSupported() {
|
||||
// Current version should always be supported
|
||||
XCTAssertTrue(ProtocolVersion.isSupported(ProtocolVersion.current))
|
||||
}
|
||||
|
||||
func testVersion1IsSupported() {
|
||||
// Version 1 must be supported for backward compatibility
|
||||
XCTAssertTrue(ProtocolVersion.isSupported(1))
|
||||
}
|
||||
|
||||
func testUnsupportedVersionsRejected() {
|
||||
// Test various unsupported versions
|
||||
XCTAssertFalse(ProtocolVersion.isSupported(0))
|
||||
XCTAssertFalse(ProtocolVersion.isSupported(2))
|
||||
XCTAssertFalse(ProtocolVersion.isSupported(99))
|
||||
XCTAssertFalse(ProtocolVersion.isSupported(255))
|
||||
}
|
||||
|
||||
// MARK: - Binary Protocol Version Handling
|
||||
|
||||
func testBinaryProtocolRejectsUnsupportedVersion() {
|
||||
// Create a packet with unsupported version
|
||||
var data = Data()
|
||||
|
||||
// Header
|
||||
data.append(99) // Unsupported version
|
||||
data.append(MessageType.message.rawValue)
|
||||
data.append(5) // TTL
|
||||
|
||||
// Timestamp (8 bytes)
|
||||
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
for i in (0..<8).reversed() {
|
||||
data.append(UInt8((timestamp >> (i * 8)) & 0xFF))
|
||||
}
|
||||
|
||||
// Flags (no recipient, no signature)
|
||||
data.append(0)
|
||||
|
||||
// Payload length (2 bytes)
|
||||
let payload = Data("test".utf8)
|
||||
let payloadLength = UInt16(payload.count)
|
||||
data.append(UInt8((payloadLength >> 8) & 0xFF))
|
||||
data.append(UInt8(payloadLength & 0xFF))
|
||||
|
||||
// SenderID (8 bytes)
|
||||
data.append(Data(repeating: 0x01, count: 8))
|
||||
|
||||
// Payload
|
||||
data.append(payload)
|
||||
|
||||
// Try to decode - should fail due to unsupported version
|
||||
let decoded = BinaryProtocol.decode(data)
|
||||
XCTAssertNil(decoded, "Should reject packet with unsupported version")
|
||||
}
|
||||
|
||||
func testBinaryProtocolAcceptsVersion1() {
|
||||
// Create a valid version 1 packet
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data("sender12".utf8),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data("Hello".utf8),
|
||||
signature: nil,
|
||||
ttl: 3
|
||||
)
|
||||
|
||||
// Encode
|
||||
guard let encoded = packet.toBinaryData() else {
|
||||
XCTFail("Failed to encode version 1 packet")
|
||||
return
|
||||
}
|
||||
|
||||
// Decode
|
||||
guard let decoded = BitchatPacket.from(encoded) else {
|
||||
XCTFail("Failed to decode version 1 packet")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(decoded.version, 1)
|
||||
XCTAssertEqual(decoded.payload, Data("Hello".utf8))
|
||||
}
|
||||
|
||||
// MARK: - Version Message Type Tests
|
||||
|
||||
func testVersionHelloMessageType() {
|
||||
let hello = VersionHello(
|
||||
clientVersion: "1.0.0",
|
||||
platform: "iOS"
|
||||
)
|
||||
|
||||
guard let helloData = hello.encode() else {
|
||||
XCTFail("Failed to encode VersionHello")
|
||||
return
|
||||
}
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.versionHello.rawValue,
|
||||
ttl: 1,
|
||||
senderID: "testpeer",
|
||||
payload: helloData
|
||||
)
|
||||
|
||||
XCTAssertEqual(packet.type, MessageType.versionHello.rawValue)
|
||||
XCTAssertEqual(MessageType.versionHello.description, "versionHello")
|
||||
}
|
||||
|
||||
func testVersionAckMessageType() {
|
||||
let ack = VersionAck(
|
||||
agreedVersion: 1,
|
||||
serverVersion: "1.0.0",
|
||||
platform: "macOS"
|
||||
)
|
||||
|
||||
guard let ackData = ack.encode() else {
|
||||
XCTFail("Failed to encode VersionAck")
|
||||
return
|
||||
}
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.versionAck.rawValue,
|
||||
ttl: 1,
|
||||
senderID: "testpeer",
|
||||
payload: ackData
|
||||
)
|
||||
|
||||
XCTAssertEqual(packet.type, MessageType.versionAck.rawValue)
|
||||
XCTAssertEqual(MessageType.versionAck.description, "versionAck")
|
||||
}
|
||||
|
||||
// MARK: - Compression Compatibility Tests
|
||||
|
||||
func testCompressedPacketWithVersion() {
|
||||
// Create a large payload that will trigger compression
|
||||
let largeContent = String(repeating: "Hello World! ", count: 100)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data("sender12".utf8),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data(largeContent.utf8),
|
||||
signature: nil,
|
||||
ttl: 3
|
||||
)
|
||||
|
||||
// Encode (should compress)
|
||||
guard let encoded = packet.toBinaryData() else {
|
||||
XCTFail("Failed to encode packet with compression")
|
||||
return
|
||||
}
|
||||
|
||||
// Decode
|
||||
guard let decoded = BitchatPacket.from(encoded) else {
|
||||
XCTFail("Failed to decode compressed packet")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify version is preserved
|
||||
XCTAssertEqual(decoded.version, 1)
|
||||
XCTAssertEqual(decoded.payload, Data(largeContent.utf8))
|
||||
}
|
||||
|
||||
// MARK: - Future Version Migration Tests
|
||||
|
||||
func testVersionSetConsistency() {
|
||||
// Ensure version constants are consistent
|
||||
XCTAssertTrue(ProtocolVersion.supportedVersions.contains(ProtocolVersion.current))
|
||||
XCTAssertTrue(ProtocolVersion.supportedVersions.contains(ProtocolVersion.minimum))
|
||||
XCTAssertGreaterThanOrEqual(ProtocolVersion.current, ProtocolVersion.minimum)
|
||||
XCTAssertLessThanOrEqual(ProtocolVersion.current, ProtocolVersion.maximum)
|
||||
}
|
||||
|
||||
func testVersionNegotiationAlwaysPicksHighest() {
|
||||
// When multiple versions are supported, should pick highest
|
||||
let clientVersions: [UInt8] = [1, 2, 3, 4, 5]
|
||||
let serverVersions: [UInt8] = [3, 4, 5, 6, 7]
|
||||
|
||||
let agreed = ProtocolVersion.negotiateVersion(
|
||||
clientVersions: clientVersions,
|
||||
serverVersions: serverVersions
|
||||
)
|
||||
|
||||
XCTAssertEqual(agreed, 5) // Highest common version
|
||||
}
|
||||
|
||||
// MARK: - Packet Size Tests with Version Negotiation
|
||||
|
||||
func testVersionNegotiationPacketsAreSmall() {
|
||||
// Version negotiation should use minimal bandwidth
|
||||
let hello = VersionHello(
|
||||
clientVersion: "1.0.0",
|
||||
platform: "iOS"
|
||||
)
|
||||
|
||||
guard let helloData = hello.encode() else {
|
||||
XCTFail("Failed to encode hello")
|
||||
return
|
||||
}
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.versionHello.rawValue,
|
||||
ttl: 1,
|
||||
senderID: "12345678",
|
||||
payload: helloData
|
||||
)
|
||||
|
||||
guard let encoded = packet.toBinaryData() else {
|
||||
XCTFail("Failed to encode packet")
|
||||
return
|
||||
}
|
||||
|
||||
// Version negotiation packets should be reasonably small
|
||||
XCTAssertLessThan(encoded.count, 512, "Version negotiation packet too large")
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
//
|
||||
// BitchatMessageTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
class BitchatMessageTests: XCTestCase {
|
||||
|
||||
func testMessageEncodingDecoding() {
|
||||
let message = BitchatMessage(
|
||||
sender: "testuser",
|
||||
content: "Hello, World!",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: "peer123",
|
||||
mentions: ["alice", "bob"]
|
||||
)
|
||||
|
||||
guard let encoded = message.toBinaryPayload() else {
|
||||
XCTFail("Failed to encode message")
|
||||
return
|
||||
}
|
||||
|
||||
guard let decoded = BitchatMessage.fromBinaryPayload(encoded) else {
|
||||
XCTFail("Failed to decode message")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(decoded.sender, message.sender)
|
||||
XCTAssertEqual(decoded.content, message.content)
|
||||
XCTAssertEqual(decoded.isPrivate, message.isPrivate)
|
||||
XCTAssertEqual(decoded.mentions?.count, 2)
|
||||
XCTAssertTrue(decoded.mentions?.contains("alice") ?? false)
|
||||
XCTAssertTrue(decoded.mentions?.contains("bob") ?? false)
|
||||
}
|
||||
|
||||
func testRoomMessage() {
|
||||
let channelMessage = BitchatMessage(
|
||||
sender: "alice",
|
||||
content: "Hello #general",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: "alice123",
|
||||
mentions: nil,
|
||||
channel: "#general"
|
||||
)
|
||||
|
||||
guard let encoded = channelMessage.toBinaryPayload() else {
|
||||
XCTFail("Failed to encode channel message")
|
||||
return
|
||||
}
|
||||
|
||||
guard let decoded = BitchatMessage.fromBinaryPayload(encoded) else {
|
||||
XCTFail("Failed to decode channel message")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(decoded.channel, "#general")
|
||||
XCTAssertEqual(decoded.content, channelMessage.content)
|
||||
}
|
||||
|
||||
func testEncryptedRoomMessage() {
|
||||
let encryptedData = Data([1, 2, 3, 4, 5, 6, 7, 8]) // Mock encrypted content
|
||||
|
||||
let encryptedMessage = BitchatMessage(
|
||||
sender: "bob",
|
||||
content: "", // Empty for encrypted messages
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: "bob456",
|
||||
mentions: nil,
|
||||
channel: "#secret",
|
||||
encryptedContent: encryptedData,
|
||||
isEncrypted: true
|
||||
)
|
||||
|
||||
guard let encoded = encryptedMessage.toBinaryPayload() else {
|
||||
XCTFail("Failed to encode encrypted message")
|
||||
return
|
||||
}
|
||||
|
||||
guard let decoded = BitchatMessage.fromBinaryPayload(encoded) else {
|
||||
XCTFail("Failed to decode encrypted message")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertTrue(decoded.isEncrypted)
|
||||
XCTAssertEqual(decoded.encryptedContent, encryptedData)
|
||||
XCTAssertEqual(decoded.channel, "#secret")
|
||||
XCTAssertEqual(decoded.content, "") // Content should be empty for encrypted messages
|
||||
}
|
||||
|
||||
func testPrivateMessage() {
|
||||
let privateMessage = BitchatMessage(
|
||||
sender: "alice",
|
||||
content: "This is private",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: "bob",
|
||||
senderPeerID: "alicePeer"
|
||||
)
|
||||
|
||||
guard let encoded = privateMessage.toBinaryPayload() else {
|
||||
XCTFail("Failed to encode private message")
|
||||
return
|
||||
}
|
||||
|
||||
guard let decoded = BitchatMessage.fromBinaryPayload(encoded) else {
|
||||
XCTFail("Failed to decode private message")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertTrue(decoded.isPrivate)
|
||||
XCTAssertEqual(decoded.recipientNickname, "bob")
|
||||
}
|
||||
|
||||
func testRelayMessage() {
|
||||
let relayMessage = BitchatMessage(
|
||||
sender: "charlie",
|
||||
content: "Relayed message",
|
||||
timestamp: Date(),
|
||||
isRelay: true,
|
||||
originalSender: "alice",
|
||||
isPrivate: false
|
||||
)
|
||||
|
||||
guard let encoded = relayMessage.toBinaryPayload() else {
|
||||
XCTFail("Failed to encode relay message")
|
||||
return
|
||||
}
|
||||
|
||||
guard let decoded = BitchatMessage.fromBinaryPayload(encoded) else {
|
||||
XCTFail("Failed to decode relay message")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertTrue(decoded.isRelay)
|
||||
XCTAssertEqual(decoded.originalSender, "alice")
|
||||
}
|
||||
|
||||
func testEmptyContent() {
|
||||
let emptyMessage = BitchatMessage(
|
||||
sender: "user",
|
||||
content: "",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil
|
||||
)
|
||||
|
||||
guard let encoded = emptyMessage.toBinaryPayload() else {
|
||||
XCTFail("Failed to encode empty message")
|
||||
return
|
||||
}
|
||||
|
||||
guard let decoded = BitchatMessage.fromBinaryPayload(encoded) else {
|
||||
XCTFail("Failed to decode empty message")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(decoded.content, "")
|
||||
}
|
||||
|
||||
func testLongContent() {
|
||||
let longContent = String(repeating: "A", count: 1000)
|
||||
let longMessage = BitchatMessage(
|
||||
sender: "user",
|
||||
content: longContent,
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil
|
||||
)
|
||||
|
||||
guard let encoded = longMessage.toBinaryPayload() else {
|
||||
XCTFail("Failed to encode long message")
|
||||
return
|
||||
}
|
||||
|
||||
guard let decoded = BitchatMessage.fromBinaryPayload(encoded) else {
|
||||
XCTFail("Failed to decode long message")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(decoded.content, longContent)
|
||||
}
|
||||
|
||||
func testPrivateMessageWithAllFieldsForNoise() {
|
||||
// Test that private messages with ID field (used by Noise) are encoded/decoded correctly
|
||||
let messageID = UUID().uuidString
|
||||
let privateMessage = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: "alice",
|
||||
content: "Hello Bob, this is a private message via Noise",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: "bob",
|
||||
senderPeerID: "alice-peer-id-123",
|
||||
mentions: nil,
|
||||
channel: nil
|
||||
)
|
||||
|
||||
// Encode to binary payload (as used by Noise encryption)
|
||||
guard let encoded = privateMessage.toBinaryPayload() else {
|
||||
XCTFail("Failed to encode private message with ID to binary payload")
|
||||
return
|
||||
}
|
||||
|
||||
// Decode from binary payload (as received from Noise decryption)
|
||||
guard let decoded = BitchatMessage.fromBinaryPayload(encoded) else {
|
||||
XCTFail("Failed to decode private message with ID from binary payload")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify all fields match
|
||||
XCTAssertEqual(decoded.id, messageID)
|
||||
XCTAssertEqual(decoded.sender, "alice")
|
||||
XCTAssertEqual(decoded.content, "Hello Bob, this is a private message via Noise")
|
||||
XCTAssertEqual(decoded.isPrivate, true)
|
||||
XCTAssertEqual(decoded.recipientNickname, "bob")
|
||||
XCTAssertEqual(decoded.senderPeerID, "alice-peer-id-123")
|
||||
XCTAssertNil(decoded.channel)
|
||||
XCTAssertFalse(decoded.isRelay)
|
||||
XCTAssertNil(decoded.originalSender)
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
//
|
||||
// BloomFilterTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
class BloomFilterTests: XCTestCase {
|
||||
|
||||
func testBasicBloomFilter() {
|
||||
var filter = OptimizedBloomFilter(expectedItems: 100, falsePositiveRate: 0.01)
|
||||
|
||||
// Test insertion and lookup
|
||||
let testStrings = ["message1", "message2", "message3", "test123"]
|
||||
|
||||
for str in testStrings {
|
||||
XCTAssertFalse(filter.contains(str))
|
||||
filter.insert(str)
|
||||
XCTAssertTrue(filter.contains(str))
|
||||
}
|
||||
}
|
||||
|
||||
func testFalsePositiveRate() {
|
||||
var filter = OptimizedBloomFilter(expectedItems: 100, falsePositiveRate: 0.01)
|
||||
let itemCount = 100
|
||||
|
||||
// Insert items
|
||||
for i in 0..<itemCount {
|
||||
filter.insert("item\(i)")
|
||||
}
|
||||
|
||||
// Check false positive rate
|
||||
var falsePositives = 0
|
||||
let testCount = 1000
|
||||
|
||||
for i in itemCount..<(itemCount + testCount) {
|
||||
if filter.contains("item\(i)") {
|
||||
falsePositives += 1
|
||||
}
|
||||
}
|
||||
|
||||
let falsePositiveRate = Double(falsePositives) / Double(testCount)
|
||||
|
||||
// With optimized bloom filter targeting 1% false positive rate
|
||||
XCTAssertLessThan(falsePositiveRate, 0.02) // Allow some margin
|
||||
}
|
||||
|
||||
func testReset() {
|
||||
var filter = OptimizedBloomFilter(expectedItems: 100, falsePositiveRate: 0.01)
|
||||
|
||||
// Insert some items
|
||||
filter.insert("test1")
|
||||
filter.insert("test2")
|
||||
filter.insert("test3")
|
||||
|
||||
XCTAssertTrue(filter.contains("test1"))
|
||||
XCTAssertTrue(filter.contains("test2"))
|
||||
XCTAssertTrue(filter.contains("test3"))
|
||||
|
||||
// Reset
|
||||
filter.reset()
|
||||
|
||||
// Should no longer contain items
|
||||
XCTAssertFalse(filter.contains("test1"))
|
||||
XCTAssertFalse(filter.contains("test2"))
|
||||
XCTAssertFalse(filter.contains("test3"))
|
||||
}
|
||||
|
||||
func testHashDistribution() {
|
||||
var filter = OptimizedBloomFilter(expectedItems: 1000, falsePositiveRate: 0.01)
|
||||
|
||||
// Insert many items
|
||||
for i in 0..<500 {
|
||||
filter.insert("message-\(i)")
|
||||
}
|
||||
|
||||
// Check false positive rate
|
||||
let estimatedRate = filter.estimatedFalsePositiveRate
|
||||
|
||||
// Should be well below target since we're at 50% capacity
|
||||
XCTAssertLessThan(estimatedRate, 0.01)
|
||||
|
||||
// Test memory efficiency
|
||||
let memoryBytes = filter.memorySizeBytes
|
||||
XCTAssertLessThan(memoryBytes, 2048) // Should be under 2KB for this size
|
||||
}
|
||||
|
||||
func testAdaptiveBloomFilter() {
|
||||
// Test small network
|
||||
let smallFilter = OptimizedBloomFilter.adaptive(for: 20)
|
||||
XCTAssertLessThan(smallFilter.memorySizeBytes, 1024)
|
||||
|
||||
// Test large network
|
||||
let largeFilter = OptimizedBloomFilter.adaptive(for: 1000)
|
||||
XCTAssertGreaterThan(largeFilter.memorySizeBytes, 2048)
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
//
|
||||
// ChannelVerificationTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import CryptoKit
|
||||
@testable import bitchat
|
||||
|
||||
class ChannelVerificationTests: XCTestCase {
|
||||
|
||||
var viewModel: ChatViewModel!
|
||||
var mockMeshService: MockBluetoothMeshService!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
viewModel = ChatViewModel()
|
||||
mockMeshService = MockBluetoothMeshService()
|
||||
viewModel.meshService = mockMeshService
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
viewModel = nil
|
||||
mockMeshService = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Key Derivation Tests
|
||||
|
||||
func testChannelKeyDerivation() {
|
||||
let password = "testPassword123"
|
||||
let channel = "#testchannel"
|
||||
|
||||
// Derive key twice with same inputs
|
||||
let key1 = viewModel.deriveChannelKey(from: password, channelName: channel)
|
||||
let key2 = viewModel.deriveChannelKey(from: password, channelName: channel)
|
||||
|
||||
// Keys should be identical for same password/channel
|
||||
XCTAssertEqual(key1.withUnsafeBytes { Data($0) },
|
||||
key2.withUnsafeBytes { Data($0) })
|
||||
}
|
||||
|
||||
func testDifferentPasswordsProduceDifferentKeys() {
|
||||
let channel = "#testchannel"
|
||||
let password1 = "password123"
|
||||
let password2 = "password456"
|
||||
|
||||
let key1 = viewModel.deriveChannelKey(from: password1, channelName: channel)
|
||||
let key2 = viewModel.deriveChannelKey(from: password2, channelName: channel)
|
||||
|
||||
// Different passwords should produce different keys
|
||||
XCTAssertNotEqual(key1.withUnsafeBytes { Data($0) },
|
||||
key2.withUnsafeBytes { Data($0) })
|
||||
}
|
||||
|
||||
func testKeyCommitmentComputation() {
|
||||
let password = "testPassword"
|
||||
let channel = "#test"
|
||||
|
||||
let key = viewModel.deriveChannelKey(from: password, channelName: channel)
|
||||
let commitment1 = viewModel.computeKeyCommitment(for: key)
|
||||
let commitment2 = viewModel.computeKeyCommitment(for: key)
|
||||
|
||||
// Same key should produce same commitment
|
||||
XCTAssertEqual(commitment1, commitment2)
|
||||
|
||||
// Commitment should be 64 characters (SHA256 hex)
|
||||
XCTAssertEqual(commitment1.count, 64)
|
||||
}
|
||||
|
||||
// MARK: - Verification Request/Response Tests
|
||||
|
||||
func testChannelKeyVerifyRequestHandling() {
|
||||
// Setup
|
||||
let channel = "#test"
|
||||
let password = "secret123"
|
||||
let peerID = "peer123"
|
||||
|
||||
// Join channel with password
|
||||
_ = viewModel.joinChannel(channel, password: password)
|
||||
|
||||
// Create verification request with matching key
|
||||
let key = viewModel.deriveChannelKey(from: password, channelName: channel)
|
||||
let commitment = viewModel.computeKeyCommitment(for: key)
|
||||
|
||||
let request = ChannelKeyVerifyRequest(
|
||||
channel: channel,
|
||||
requesterID: peerID,
|
||||
keyCommitment: commitment
|
||||
)
|
||||
|
||||
// Handle request
|
||||
viewModel.didReceiveChannelKeyVerifyRequest(request, from: peerID)
|
||||
|
||||
// Should have sent a positive response
|
||||
XCTAssertTrue(mockMeshService.sentVerifyResponse)
|
||||
XCTAssertTrue(mockMeshService.lastVerifyResponse?.verified ?? false)
|
||||
}
|
||||
|
||||
func testChannelKeyVerifyResponseHandling() {
|
||||
// Setup
|
||||
let channel = "#test"
|
||||
let peerID = "peer123"
|
||||
|
||||
// Set initial verification status
|
||||
viewModel.channelVerificationStatus[channel] = .verifying
|
||||
viewModel.joinedChannels.insert(channel)
|
||||
|
||||
// Create positive response
|
||||
let response = ChannelKeyVerifyResponse(
|
||||
channel: channel,
|
||||
responderID: peerID,
|
||||
verified: true
|
||||
)
|
||||
|
||||
// Handle response
|
||||
viewModel.didReceiveChannelKeyVerifyResponse(response, from: peerID)
|
||||
|
||||
// Status should be verified
|
||||
XCTAssertEqual(viewModel.channelVerificationStatus[channel], .verified)
|
||||
}
|
||||
|
||||
func testFailedVerificationResponse() {
|
||||
// Setup
|
||||
let channel = "#test"
|
||||
let peerID = "peer123"
|
||||
|
||||
viewModel.channelVerificationStatus[channel] = .verifying
|
||||
viewModel.joinedChannels.insert(channel)
|
||||
|
||||
// Create negative response
|
||||
let response = ChannelKeyVerifyResponse(
|
||||
channel: channel,
|
||||
responderID: peerID,
|
||||
verified: false
|
||||
)
|
||||
|
||||
// Handle response
|
||||
viewModel.didReceiveChannelKeyVerifyResponse(response, from: peerID)
|
||||
|
||||
// Status should be failed
|
||||
XCTAssertEqual(viewModel.channelVerificationStatus[channel], .failed)
|
||||
}
|
||||
|
||||
// MARK: - Password Update Tests
|
||||
|
||||
func testChannelPasswordUpdateHandling() {
|
||||
// Setup
|
||||
let channel = "#test"
|
||||
let ownerID = "owner123"
|
||||
let newPassword = "newSecret456"
|
||||
|
||||
// Join channel first
|
||||
viewModel.joinedChannels.insert(channel)
|
||||
viewModel.channelCreators[channel] = ownerID
|
||||
|
||||
// Simulate having a Noise session
|
||||
mockMeshService.mockNoiseSessionEstablished = true
|
||||
|
||||
// Create password update
|
||||
let newKey = viewModel.deriveChannelKey(from: newPassword, channelName: channel)
|
||||
let newCommitment = viewModel.computeKeyCommitment(for: newKey)
|
||||
|
||||
let update = ChannelPasswordUpdate(
|
||||
channel: channel,
|
||||
ownerID: ownerID,
|
||||
ownerFingerprint: "test-fingerprint", // Mock fingerprint
|
||||
encryptedPassword: Data(), // Would be encrypted in real scenario
|
||||
newKeyCommitment: newCommitment
|
||||
)
|
||||
|
||||
// Mock decryption to return new password
|
||||
mockMeshService.mockDecryptedPassword = newPassword
|
||||
|
||||
// Handle update
|
||||
viewModel.didReceiveChannelPasswordUpdate(update, from: ownerID)
|
||||
|
||||
// Should have updated local key
|
||||
XCTAssertNotNil(viewModel.channelKeys[channel])
|
||||
XCTAssertEqual(viewModel.channelKeyCommitments[channel], newCommitment)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mock Mesh Service
|
||||
|
||||
class MockBluetoothMeshService: BluetoothMeshService {
|
||||
var sentVerifyResponse = false
|
||||
var lastVerifyResponse: ChannelKeyVerifyResponse?
|
||||
var mockNoiseSessionEstablished = false
|
||||
var mockDecryptedPassword: String?
|
||||
|
||||
// Mock the method without override since it's not overrideable
|
||||
func mockSendChannelKeyVerifyResponse(_ response: ChannelKeyVerifyResponse, to peerID: String) {
|
||||
sentVerifyResponse = true
|
||||
lastVerifyResponse = response
|
||||
// Call the real method if needed
|
||||
super.sendChannelKeyVerifyResponse(response, to: peerID)
|
||||
}
|
||||
|
||||
override func getNoiseService() -> NoiseEncryptionService {
|
||||
// Return actual noise service - tests should use real crypto
|
||||
return super.getNoiseService()
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
//
|
||||
// KeychainIntegrationTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Integration tests for keychain functionality
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
class KeychainIntegrationTests: XCTestCase {
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
// Start with clean state
|
||||
_ = KeychainManager.shared.deleteAllKeychainData()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Clean up test data
|
||||
_ = KeychainManager.shared.deleteAllKeychainData()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - App Lifecycle Simulation Tests
|
||||
|
||||
func testCompleteAppLifecycle() {
|
||||
print("\n🧪 Testing Complete App Lifecycle")
|
||||
|
||||
// 1. First app launch - create identity
|
||||
print("1️⃣ First launch...")
|
||||
let service1 = NoiseEncryptionService()
|
||||
let fingerprint1 = service1.getIdentityFingerprint()
|
||||
print(" Initial fingerprint: \(fingerprint1)")
|
||||
|
||||
// Verify stored in keychain
|
||||
let keychainData1 = KeychainManager.shared.getIdentityKey(forKey: "noiseStaticKey")
|
||||
XCTAssertNotNil(keychainData1, "Identity should be in keychain after first launch")
|
||||
|
||||
// 2. App goes to background and comes back
|
||||
print("2️⃣ Background/foreground cycle...")
|
||||
let service2 = NoiseEncryptionService()
|
||||
let fingerprint2 = service2.getIdentityFingerprint()
|
||||
XCTAssertEqual(fingerprint1, fingerprint2, "Identity should persist through background")
|
||||
|
||||
// 3. App terminates and relaunches
|
||||
print("3️⃣ Terminate and relaunch...")
|
||||
// In real app this would be a new process
|
||||
let service3 = NoiseEncryptionService()
|
||||
let fingerprint3 = service3.getIdentityFingerprint()
|
||||
XCTAssertEqual(fingerprint1, fingerprint3, "Identity should persist through termination")
|
||||
|
||||
// 4. User triggers panic mode
|
||||
print("4️⃣ Panic mode triggered...")
|
||||
service3.clearPersistentIdentity()
|
||||
|
||||
// 5. App creates new identity
|
||||
print("5️⃣ New identity after panic...")
|
||||
let service4 = NoiseEncryptionService()
|
||||
let fingerprint4 = service4.getIdentityFingerprint()
|
||||
XCTAssertNotEqual(fingerprint1, fingerprint4, "New identity should be created after panic")
|
||||
print(" New fingerprint: \(fingerprint4)")
|
||||
|
||||
print("✅ Lifecycle test complete\n")
|
||||
}
|
||||
|
||||
// MARK: - Channel Password Tests
|
||||
|
||||
func testChannelPasswordPersistence() {
|
||||
let channel1 = "#testchannel1"
|
||||
let channel2 = "#testchannel2"
|
||||
let password1 = "password123"
|
||||
let password2 = "differentpass456"
|
||||
|
||||
// Save passwords
|
||||
XCTAssertTrue(KeychainManager.shared.saveChannelPassword(password1, for: channel1))
|
||||
XCTAssertTrue(KeychainManager.shared.saveChannelPassword(password2, for: channel2))
|
||||
|
||||
// Retrieve passwords
|
||||
XCTAssertEqual(KeychainManager.shared.getChannelPassword(for: channel1), password1)
|
||||
XCTAssertEqual(KeychainManager.shared.getChannelPassword(for: channel2), password2)
|
||||
|
||||
// Test getAllChannelPasswords
|
||||
let allPasswords = KeychainManager.shared.getAllChannelPasswords()
|
||||
XCTAssertEqual(allPasswords.count, 2)
|
||||
XCTAssertEqual(allPasswords[channel1], password1)
|
||||
XCTAssertEqual(allPasswords[channel2], password2)
|
||||
|
||||
// Delete one password
|
||||
XCTAssertTrue(KeychainManager.shared.deleteChannelPassword(for: channel1))
|
||||
XCTAssertNil(KeychainManager.shared.getChannelPassword(for: channel1))
|
||||
XCTAssertEqual(KeychainManager.shared.getChannelPassword(for: channel2), password2)
|
||||
}
|
||||
|
||||
// MARK: - Security Tests
|
||||
|
||||
func testNoPlaintextInUserDefaults() {
|
||||
// Create services to generate keys
|
||||
_ = NoiseEncryptionService()
|
||||
|
||||
// Check UserDefaults for any sensitive data
|
||||
let keysToCheck = [
|
||||
"bitchat.noiseIdentityKey",
|
||||
"bitchat.channelPasswords",
|
||||
"bitchat.identityKey",
|
||||
"bitchat.staticKey"
|
||||
]
|
||||
|
||||
for key in keysToCheck {
|
||||
let data = UserDefaults.standard.object(forKey: key)
|
||||
XCTAssertNil(data, "UserDefaults should not contain: \(key)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Handling Tests
|
||||
|
||||
func testKeychainErrorRecovery() {
|
||||
// Test that the app can recover from keychain errors
|
||||
// This is difficult to test without mocking, but we can verify
|
||||
// that multiple save attempts don't crash
|
||||
|
||||
let testData = "test".data(using: .utf8)!
|
||||
|
||||
// Rapid saves
|
||||
for i in 0..<10 {
|
||||
let saved = KeychainManager.shared.saveIdentityKey(testData, forKey: "rapidTest\(i)")
|
||||
XCTAssertTrue(saved, "Save \(i) should succeed")
|
||||
}
|
||||
|
||||
// Rapid deletes
|
||||
for i in 0..<10 {
|
||||
_ = KeychainManager.shared.deleteIdentityKey(forKey: "rapidTest\(i)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cleanup Tests
|
||||
|
||||
func testAggressiveCleanupOnlyDeletesBitchatItems() {
|
||||
// This test verifies we don't delete other apps' keychain items
|
||||
|
||||
// Add a non-bitchat item (simulating another app)
|
||||
let otherAppQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "com.otherapp.service",
|
||||
kSecAttrAccount as String: "other_app_account",
|
||||
kSecValueData as String: "other app data".data(using: .utf8)!
|
||||
]
|
||||
|
||||
// Clean first
|
||||
SecItemDelete(otherAppQuery as CFDictionary)
|
||||
|
||||
// Add the item
|
||||
let addStatus = SecItemAdd(otherAppQuery as CFDictionary, nil)
|
||||
XCTAssertEqual(addStatus, errSecSuccess, "Should add other app item")
|
||||
|
||||
// Add a bitchat legacy item
|
||||
let bitchatQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "com.bitchat.legacy",
|
||||
kSecAttrAccount as String: "test_account",
|
||||
kSecValueData as String: "bitchat data".data(using: .utf8)!
|
||||
]
|
||||
SecItemDelete(bitchatQuery as CFDictionary)
|
||||
let bitchatStatus = SecItemAdd(bitchatQuery as CFDictionary, nil)
|
||||
XCTAssertEqual(bitchatStatus, errSecSuccess, "Should add bitchat item")
|
||||
|
||||
// Run aggressive cleanup
|
||||
_ = KeychainManager.shared.aggressiveCleanupLegacyItems()
|
||||
|
||||
// Verify other app item still exists
|
||||
var result: AnyObject?
|
||||
let checkStatus = SecItemCopyMatching(otherAppQuery as CFDictionary, &result)
|
||||
XCTAssertEqual(checkStatus, errSecSuccess, "Other app item should still exist")
|
||||
|
||||
// Verify bitchat item was deleted
|
||||
var bitchatResult: AnyObject?
|
||||
let bitchatCheck = SecItemCopyMatching(bitchatQuery as CFDictionary, &bitchatResult)
|
||||
XCTAssertEqual(bitchatCheck, errSecItemNotFound, "Bitchat legacy item should be deleted")
|
||||
|
||||
// Clean up
|
||||
SecItemDelete(otherAppQuery as CFDictionary)
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
//
|
||||
// MessagePaddingTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
class MessagePaddingTests: XCTestCase {
|
||||
|
||||
func testBasicPadding() {
|
||||
let originalData = Data("Hello".utf8)
|
||||
let targetSize = 256
|
||||
|
||||
let padded = MessagePadding.pad(originalData, toSize: targetSize)
|
||||
XCTAssertEqual(padded.count, targetSize)
|
||||
|
||||
let unpadded = MessagePadding.unpad(padded)
|
||||
XCTAssertEqual(unpadded, originalData)
|
||||
}
|
||||
|
||||
func testMultipleBlockSizes() {
|
||||
let testMessages = [
|
||||
"Hi",
|
||||
"This is a longer message",
|
||||
"This is an even longer message that should require a larger block size",
|
||||
String(repeating: "A", count: 500)
|
||||
]
|
||||
|
||||
for message in testMessages {
|
||||
let data = Data(message.utf8)
|
||||
let blockSize = MessagePadding.optimalBlockSize(for: data.count)
|
||||
|
||||
// Block size should be reasonable
|
||||
XCTAssertGreaterThan(blockSize, data.count)
|
||||
XCTAssertTrue(MessagePadding.blockSizes.contains(blockSize) || blockSize == data.count)
|
||||
|
||||
let padded = MessagePadding.pad(data, toSize: blockSize)
|
||||
|
||||
// Check if padding was applied (only if needed padding <= 255)
|
||||
let paddingNeeded = blockSize - data.count
|
||||
if paddingNeeded <= 255 {
|
||||
XCTAssertEqual(padded.count, blockSize)
|
||||
let unpadded = MessagePadding.unpad(padded)
|
||||
XCTAssertEqual(unpadded, data)
|
||||
} else {
|
||||
// No padding applied if more than 255 bytes needed
|
||||
XCTAssertEqual(padded, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testPaddingWithLargeData() {
|
||||
let largeData = Data(repeating: 0xFF, count: 1500)
|
||||
let blockSize = MessagePadding.optimalBlockSize(for: largeData.count)
|
||||
|
||||
// Should use 2048 block
|
||||
XCTAssertEqual(blockSize, 2048)
|
||||
|
||||
let padded = MessagePadding.pad(largeData, toSize: blockSize)
|
||||
// Since padding needed (548 bytes) > 255, no padding is applied
|
||||
XCTAssertEqual(padded.count, largeData.count)
|
||||
XCTAssertEqual(padded, largeData)
|
||||
|
||||
// Test with data that fits within PKCS#7 limits
|
||||
let smallerData = Data(repeating: 0xAA, count: 1800)
|
||||
let paddedSmaller = MessagePadding.pad(smallerData, toSize: 2048)
|
||||
// Padding needed is 248 bytes, which is < 255, so padding should work
|
||||
XCTAssertEqual(paddedSmaller.count, 2048)
|
||||
|
||||
let unpaddedSmaller = MessagePadding.unpad(paddedSmaller)
|
||||
XCTAssertEqual(unpaddedSmaller, smallerData)
|
||||
}
|
||||
|
||||
func testInvalidPadding() {
|
||||
// Test empty data
|
||||
let empty = Data()
|
||||
let unpaddedEmpty = MessagePadding.unpad(empty)
|
||||
XCTAssertEqual(unpaddedEmpty, empty)
|
||||
|
||||
// Test data with invalid padding length
|
||||
var invalidPadding = Data(repeating: 0x00, count: 100)
|
||||
invalidPadding[99] = 255 // Invalid padding length
|
||||
let result = MessagePadding.unpad(invalidPadding)
|
||||
XCTAssertEqual(result, invalidPadding) // Should return original if invalid
|
||||
}
|
||||
|
||||
func testPaddingRandomness() {
|
||||
// Ensure padding bytes are random (not predictable)
|
||||
let data = Data("Test".utf8)
|
||||
let padded1 = MessagePadding.pad(data, toSize: 256)
|
||||
let padded2 = MessagePadding.pad(data, toSize: 256)
|
||||
|
||||
// Same size
|
||||
XCTAssertEqual(padded1.count, padded2.count)
|
||||
|
||||
// But different padding bytes (with very high probability)
|
||||
XCTAssertNotEqual(padded1, padded2)
|
||||
|
||||
// Both should unpad to same data
|
||||
XCTAssertEqual(MessagePadding.unpad(padded1), data)
|
||||
XCTAssertEqual(MessagePadding.unpad(padded2), data)
|
||||
}
|
||||
|
||||
// MARK: - Edge Case Tests
|
||||
|
||||
func testExactBlockSizeData() {
|
||||
// Test data that exactly matches block sizes
|
||||
for blockSize in MessagePadding.blockSizes {
|
||||
// Account for 16 bytes encryption overhead
|
||||
let dataSize = blockSize - 16
|
||||
let data = Data(repeating: 0x42, count: dataSize)
|
||||
|
||||
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
|
||||
XCTAssertEqual(optimalSize, blockSize)
|
||||
|
||||
// Should fit exactly, no padding needed
|
||||
let padded = MessagePadding.pad(data, toSize: blockSize)
|
||||
XCTAssertEqual(padded.count, blockSize)
|
||||
}
|
||||
}
|
||||
|
||||
func testOneByteOverBlockSize() {
|
||||
// Test data that's one byte over block size threshold
|
||||
let blockSizes = [256, 512, 1024]
|
||||
|
||||
for blockSize in blockSizes {
|
||||
// Create data that's 1 byte too large for current block
|
||||
let dataSize = blockSize - 16 + 1
|
||||
let data = Data(repeating: 0x42, count: dataSize)
|
||||
|
||||
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
|
||||
|
||||
// Should jump to next block size
|
||||
if blockSize < 2048 {
|
||||
XCTAssertGreaterThan(optimalSize, blockSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testVerySmallData() {
|
||||
// Test tiny messages
|
||||
let tinyMessages = [
|
||||
Data([0x01]),
|
||||
Data([0x01, 0x02]),
|
||||
Data("a".utf8),
|
||||
Data()
|
||||
]
|
||||
|
||||
for data in tinyMessages {
|
||||
let blockSize = MessagePadding.optimalBlockSize(for: data.count)
|
||||
XCTAssertEqual(blockSize, 256) // Should use minimum block size
|
||||
|
||||
if !data.isEmpty {
|
||||
let padded = MessagePadding.pad(data, toSize: blockSize)
|
||||
XCTAssertEqual(padded.count, blockSize)
|
||||
|
||||
let unpadded = MessagePadding.unpad(padded)
|
||||
XCTAssertEqual(unpadded, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testPaddingBoundaryConditions() {
|
||||
// Test PKCS#7 padding limit (255 bytes)
|
||||
let testCases = [
|
||||
(dataSize: 1, targetSize: 256), // Need 255 bytes padding - exactly at limit
|
||||
(dataSize: 2, targetSize: 256), // Need 254 bytes padding - just under limit
|
||||
(dataSize: 256, targetSize: 512), // Need 256 bytes padding - just over limit
|
||||
]
|
||||
|
||||
for testCase in testCases {
|
||||
let data = Data(repeating: 0x42, count: testCase.dataSize)
|
||||
let padded = MessagePadding.pad(data, toSize: testCase.targetSize)
|
||||
|
||||
let paddingNeeded = testCase.targetSize - testCase.dataSize
|
||||
if paddingNeeded <= 255 {
|
||||
// Padding should be applied
|
||||
XCTAssertEqual(padded.count, testCase.targetSize)
|
||||
|
||||
// Verify correct padding byte value
|
||||
let paddingByte = padded[padded.count - 1]
|
||||
XCTAssertEqual(Int(paddingByte), paddingNeeded)
|
||||
|
||||
// Should unpad correctly
|
||||
let unpadded = MessagePadding.unpad(padded)
|
||||
XCTAssertEqual(unpadded, data)
|
||||
} else {
|
||||
// No padding applied
|
||||
XCTAssertEqual(padded, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testCorruptedPadding() {
|
||||
let data = Data("Test message".utf8)
|
||||
let padded = MessagePadding.pad(data, toSize: 256)
|
||||
|
||||
// Corrupt the padding length byte
|
||||
var corrupted = padded
|
||||
corrupted[corrupted.count - 1] = 0
|
||||
|
||||
let result = MessagePadding.unpad(corrupted)
|
||||
XCTAssertEqual(result, corrupted) // Should return original when padding is invalid
|
||||
|
||||
// Test with padding length > data size
|
||||
var corruptedTooLarge = padded
|
||||
corruptedTooLarge[corruptedTooLarge.count - 1] = 255
|
||||
|
||||
let result2 = MessagePadding.unpad(corruptedTooLarge)
|
||||
XCTAssertEqual(result2, corruptedTooLarge)
|
||||
}
|
||||
|
||||
func testDataAlreadyLargerThanTarget() {
|
||||
let data = Data(repeating: 0x42, count: 1000)
|
||||
let tooSmallTarget = 256
|
||||
|
||||
// Should return original data when it's already larger than target
|
||||
let result = MessagePadding.pad(data, toSize: tooSmallTarget)
|
||||
XCTAssertEqual(result, data)
|
||||
XCTAssertEqual(result.count, data.count)
|
||||
}
|
||||
|
||||
func testOptimalBlockSizeForLargeData() {
|
||||
// Test data larger than largest block size
|
||||
let hugeData = Data(repeating: 0x42, count: 5000)
|
||||
let blockSize = MessagePadding.optimalBlockSize(for: hugeData.count)
|
||||
|
||||
// Should return data size when larger than all blocks
|
||||
XCTAssertEqual(blockSize, hugeData.count)
|
||||
}
|
||||
|
||||
func testPaddingPerformance() {
|
||||
let data = Data(repeating: 0x42, count: 1000)
|
||||
|
||||
measure {
|
||||
for _ in 0..<1000 {
|
||||
let blockSize = MessagePadding.optimalBlockSize(for: data.count)
|
||||
let padded = MessagePadding.pad(data, toSize: blockSize)
|
||||
_ = MessagePadding.unpad(padded)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
//
|
||||
// NoiseChannelEncryptionTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import CryptoKit
|
||||
@testable import bitchat
|
||||
|
||||
class NoiseChannelEncryptionTests: XCTestCase {
|
||||
|
||||
// MARK: - Channel Key Derivation with Fingerprint Tests
|
||||
|
||||
func testChannelEncryptionWithFingerprint() {
|
||||
let encryption = NoiseChannelEncryption()
|
||||
let password = "test-password-123"
|
||||
let channel = "#secure-channel"
|
||||
let fingerprint = "e36f7993abc123def456789012345678901234567890abcdef1234567890abcd"
|
||||
|
||||
// Set channel password with fingerprint
|
||||
encryption.setChannelPasswordForCreator(password, channel: channel, creatorFingerprint: fingerprint)
|
||||
|
||||
// Test encryption
|
||||
let message = "This is a secret message"
|
||||
do {
|
||||
let encrypted = try encryption.encryptChannelMessage(message, for: channel)
|
||||
|
||||
// Ensure it's actually encrypted
|
||||
XCTAssertNotEqual(encrypted, Data(message.utf8))
|
||||
XCTAssertGreaterThan(encrypted.count, message.count) // Should have IV + tag
|
||||
|
||||
// Test decryption
|
||||
let decrypted = try encryption.decryptChannelMessage(encrypted, for: channel)
|
||||
XCTAssertEqual(decrypted, message)
|
||||
|
||||
} catch {
|
||||
XCTFail("Encryption/decryption failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func testBackwardsCompatibilityWithoutFingerprint() {
|
||||
let encryption = NoiseChannelEncryption()
|
||||
let password = "test-password-123"
|
||||
let channel = "#legacy-channel"
|
||||
|
||||
// Set password without fingerprint (legacy mode)
|
||||
encryption.setChannelPassword(password, for: channel)
|
||||
|
||||
// Encrypt message
|
||||
let message = "Legacy message"
|
||||
do {
|
||||
let encrypted = try encryption.encryptChannelMessage(message, for: channel)
|
||||
|
||||
// Should still work
|
||||
let decrypted = try encryption.decryptChannelMessage(encrypted, for: channel)
|
||||
XCTAssertEqual(decrypted, message)
|
||||
|
||||
} catch {
|
||||
XCTFail("Legacy encryption failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func testDifferentFingerprintsProduceDifferentEncryption() throws {
|
||||
let encryption1 = NoiseChannelEncryption()
|
||||
let encryption2 = NoiseChannelEncryption()
|
||||
|
||||
let password = "same-password"
|
||||
let channel = "#test-channel"
|
||||
let message = "Test message"
|
||||
|
||||
let fingerprint1 = "1111111111111111111111111111111111111111111111111111111111111111"
|
||||
let fingerprint2 = "2222222222222222222222222222222222222222222222222222222222222222"
|
||||
|
||||
// Set same password with different fingerprints
|
||||
encryption1.setChannelPasswordForCreator(password, channel: channel, creatorFingerprint: fingerprint1)
|
||||
encryption2.setChannelPasswordForCreator(password, channel: channel, creatorFingerprint: fingerprint2)
|
||||
|
||||
// Encrypt same message
|
||||
let encrypted1 = try encryption1.encryptChannelMessage(message, for: channel)
|
||||
let encrypted2 = try encryption2.encryptChannelMessage(message, for: channel)
|
||||
|
||||
// Encrypted data should be different (different keys due to different salts)
|
||||
// Note: We can't directly compare ciphertexts due to random IVs, but we can verify they don't decrypt with wrong key
|
||||
|
||||
// Try to decrypt with wrong fingerprint - should fail
|
||||
encryption1.removeChannelPassword(for: channel)
|
||||
encryption1.setChannelPasswordForCreator(password, channel: channel, creatorFingerprint: fingerprint2)
|
||||
|
||||
XCTAssertThrowsError(try encryption1.decryptChannelMessage(encrypted1, for: channel)) { error in
|
||||
// Should fail to decrypt because key is different
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Key Management Tests
|
||||
|
||||
func testChannelKeyPersistence() {
|
||||
let encryption = NoiseChannelEncryption()
|
||||
let password = "persistent-password"
|
||||
let channel = "#persistent-channel"
|
||||
|
||||
// Set and save password
|
||||
encryption.setChannelPassword(password, for: channel)
|
||||
|
||||
// Verify it's saved in keychain
|
||||
XCTAssertTrue(encryption.loadChannelPassword(for: channel))
|
||||
|
||||
// Create new instance and load
|
||||
let encryption2 = NoiseChannelEncryption()
|
||||
XCTAssertTrue(encryption2.loadChannelPassword(for: channel))
|
||||
|
||||
// Should be able to decrypt messages from first instance
|
||||
do {
|
||||
let message = "Cross-instance message"
|
||||
let encrypted = try encryption.encryptChannelMessage(message, for: channel)
|
||||
let decrypted = try encryption2.decryptChannelMessage(encrypted, for: channel)
|
||||
XCTAssertEqual(decrypted, message)
|
||||
} catch {
|
||||
XCTFail("Cross-instance encryption failed: \(error)")
|
||||
}
|
||||
|
||||
// Clean up
|
||||
encryption.removeChannelPassword(for: channel)
|
||||
}
|
||||
|
||||
func testChannelKeyPacketCreation() {
|
||||
let encryption = NoiseChannelEncryption()
|
||||
let password = "shared-password"
|
||||
let channel = "#shared-channel"
|
||||
|
||||
// Create key packet
|
||||
guard let packet = encryption.createChannelKeyPacket(password: password, channel: channel) else {
|
||||
XCTFail("Failed to create key packet")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify packet structure
|
||||
XCTAssertGreaterThan(packet.count, 32) // Should have channel name + password + metadata
|
||||
|
||||
// Process packet in another instance
|
||||
let encryption2 = NoiseChannelEncryption()
|
||||
guard let (extractedChannel, extractedPassword) = encryption2.processChannelKeyPacket(packet) else {
|
||||
XCTFail("Failed to process key packet")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(extractedChannel, channel)
|
||||
XCTAssertEqual(extractedPassword, password)
|
||||
}
|
||||
|
||||
// MARK: - Error Handling Tests
|
||||
|
||||
func testDecryptionWithWrongPassword() {
|
||||
let encryption = NoiseChannelEncryption()
|
||||
let channel = "#error-test"
|
||||
|
||||
// Encrypt with one password
|
||||
encryption.setChannelPassword("correct-password", for: channel)
|
||||
let message = "Secret message"
|
||||
|
||||
do {
|
||||
let encrypted = try encryption.encryptChannelMessage(message, for: channel)
|
||||
|
||||
// Change to wrong password
|
||||
encryption.setChannelPassword("wrong-password", for: channel)
|
||||
|
||||
// Should fail to decrypt
|
||||
XCTAssertThrowsError(try encryption.decryptChannelMessage(encrypted, for: channel))
|
||||
|
||||
} catch {
|
||||
XCTFail("Encryption failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func testEncryptionWithoutPassword() {
|
||||
let encryption = NoiseChannelEncryption()
|
||||
let channel = "#no-password"
|
||||
|
||||
// Try to encrypt without setting password
|
||||
XCTAssertThrowsError(try encryption.encryptChannelMessage("Test", for: channel)) { error in
|
||||
// Should throw channelKeyMissing error
|
||||
if let encryptionError = error as? NoiseChannelEncryptionError {
|
||||
XCTAssertEqual(encryptionError, NoiseChannelEncryptionError.channelKeyMissing)
|
||||
} else {
|
||||
XCTFail("Wrong error type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testInvalidChannelName() {
|
||||
let encryption = NoiseChannelEncryption()
|
||||
|
||||
// Empty channel
|
||||
XCTAssertThrowsError(try encryption.encryptChannelMessage("Test", for: ""))
|
||||
|
||||
// Channel without # prefix
|
||||
XCTAssertThrowsError(try encryption.encryptChannelMessage("Test", for: "invalid"))
|
||||
}
|
||||
|
||||
// MARK: - Performance Tests
|
||||
|
||||
func testEncryptionPerformance() {
|
||||
let encryption = NoiseChannelEncryption()
|
||||
let channel = "#perf-test"
|
||||
let fingerprint = "e36f7993abc123def456789012345678901234567890abcdef1234567890abcd"
|
||||
|
||||
encryption.setChannelPasswordForCreator("test-password", channel: channel, creatorFingerprint: fingerprint)
|
||||
|
||||
let message = String(repeating: "Hello World! ", count: 100) // ~1.3KB message
|
||||
|
||||
measure {
|
||||
do {
|
||||
let encrypted = try encryption.encryptChannelMessage(message, for: channel)
|
||||
_ = try encryption.decryptChannelMessage(encrypted, for: channel)
|
||||
} catch {
|
||||
XCTFail("Performance test failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
//
|
||||
// NoiseIdentityPersistenceTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for Noise Protocol identity key persistence
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
class NoiseIdentityPersistenceTests: XCTestCase {
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
// Clean up any existing test data
|
||||
cleanupTestData()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Clean up after tests
|
||||
cleanupTestData()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
private func cleanupTestData() {
|
||||
// Clear any existing identity keys
|
||||
_ = KeychainManager.shared.deleteIdentityKey(forKey: "noiseStaticKey")
|
||||
_ = KeychainManager.shared.deleteIdentityKey(forKey: "messageRetentionKey")
|
||||
|
||||
// Clear any UserDefaults that might interfere
|
||||
UserDefaults.standard.removeObject(forKey: "bitchat.noiseIdentityKey")
|
||||
UserDefaults.standard.removeObject(forKey: "bitchat.messageRetentionKey")
|
||||
UserDefaults.standard.synchronize()
|
||||
}
|
||||
|
||||
// MARK: - Identity Persistence Tests
|
||||
|
||||
func testIdentityPersistsAcrossInstances() {
|
||||
// Create first instance
|
||||
let service1 = NoiseEncryptionService()
|
||||
let fingerprint1 = service1.getIdentityFingerprint()
|
||||
let publicKey1 = service1.getStaticPublicKeyData()
|
||||
|
||||
XCTAssertFalse(fingerprint1.isEmpty, "Fingerprint should not be empty")
|
||||
XCTAssertEqual(publicKey1.count, 32, "Public key should be 32 bytes")
|
||||
|
||||
// Create second instance
|
||||
let service2 = NoiseEncryptionService()
|
||||
let fingerprint2 = service2.getIdentityFingerprint()
|
||||
let publicKey2 = service2.getStaticPublicKeyData()
|
||||
|
||||
// Verify same identity
|
||||
XCTAssertEqual(fingerprint1, fingerprint2, "Fingerprint should persist across instances")
|
||||
XCTAssertEqual(publicKey1, publicKey2, "Public key should persist across instances")
|
||||
}
|
||||
|
||||
func testIdentityNotStoredInUserDefaults() {
|
||||
// Create service to generate identity
|
||||
_ = NoiseEncryptionService()
|
||||
|
||||
// Verify identity is NOT in UserDefaults
|
||||
let userDefaultsData = UserDefaults.standard.data(forKey: "bitchat.noiseIdentityKey")
|
||||
XCTAssertNil(userDefaultsData, "Identity key should NOT be stored in UserDefaults")
|
||||
}
|
||||
|
||||
func testIdentityStoredInKeychain() {
|
||||
// Create service to generate identity
|
||||
_ = NoiseEncryptionService()
|
||||
|
||||
// Verify identity IS in Keychain
|
||||
let keychainData = KeychainManager.shared.getIdentityKey(forKey: "noiseStaticKey")
|
||||
XCTAssertNotNil(keychainData, "Identity key should be stored in Keychain")
|
||||
XCTAssertEqual(keychainData?.count, 32, "Identity key should be 32 bytes")
|
||||
}
|
||||
|
||||
func testPanicModeClearsIdentity() {
|
||||
// Create service and get initial fingerprint
|
||||
let service1 = NoiseEncryptionService()
|
||||
let fingerprint1 = service1.getIdentityFingerprint()
|
||||
|
||||
// Clear identity (panic mode)
|
||||
service1.clearPersistentIdentity()
|
||||
|
||||
// Create new service and verify new identity
|
||||
let service2 = NoiseEncryptionService()
|
||||
let fingerprint2 = service2.getIdentityFingerprint()
|
||||
|
||||
XCTAssertNotEqual(fingerprint1, fingerprint2, "New identity should be created after panic mode")
|
||||
}
|
||||
|
||||
func testMultipleRapidInstantiations() {
|
||||
// Create multiple services rapidly
|
||||
var fingerprints: [String] = []
|
||||
|
||||
for _ in 0..<10 {
|
||||
let service = NoiseEncryptionService()
|
||||
fingerprints.append(service.getIdentityFingerprint())
|
||||
}
|
||||
|
||||
// Verify all fingerprints are the same
|
||||
let firstFingerprint = fingerprints[0]
|
||||
for fingerprint in fingerprints {
|
||||
XCTAssertEqual(fingerprint, firstFingerprint, "All instances should have same identity")
|
||||
}
|
||||
}
|
||||
|
||||
func testKeychainAccessFailureHandling() {
|
||||
// This test would require mocking KeychainManager, but we can at least
|
||||
// verify the service initializes even if keychain is problematic
|
||||
let service = NoiseEncryptionService()
|
||||
XCTAssertFalse(service.getIdentityFingerprint().isEmpty, "Service should initialize with valid identity")
|
||||
}
|
||||
|
||||
// MARK: - Message Retention Key Tests
|
||||
// Message retention feature has been removed
|
||||
|
||||
// MARK: - Keychain Service Name Tests
|
||||
|
||||
func testKeychainServiceName() {
|
||||
// Verify we're using the correct service name
|
||||
let expectedService = "chat.bitchat"
|
||||
|
||||
// Save a test item
|
||||
let testKey = "test_service_verification"
|
||||
let testData = "test".data(using: .utf8)!
|
||||
_ = KeychainManager.shared.saveIdentityKey(testData, forKey: testKey)
|
||||
|
||||
// Query directly to verify service name
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: expectedService,
|
||||
kSecAttrAccount as String: "identity_\(testKey)",
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
XCTAssertEqual(status, errSecSuccess, "Should find item with expected service name")
|
||||
XCTAssertNotNil(result as? Data, "Should retrieve data")
|
||||
|
||||
// Clean up
|
||||
_ = KeychainManager.shared.deleteIdentityKey(forKey: testKey)
|
||||
}
|
||||
|
||||
// MARK: - Legacy Cleanup Tests
|
||||
|
||||
func testLegacyKeychainCleanup() {
|
||||
// Create some legacy items with old service names
|
||||
let legacyServices = [
|
||||
"com.bitchat.passwords",
|
||||
"com.bitchat.noise.identity",
|
||||
"bitchat.keychain"
|
||||
]
|
||||
|
||||
// Add test items with legacy service names
|
||||
for service in legacyServices {
|
||||
let addQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: "test_legacy_item",
|
||||
kSecValueData as String: "test".data(using: .utf8)!
|
||||
]
|
||||
|
||||
// Add item (ignore if already exists)
|
||||
_ = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
}
|
||||
|
||||
// Run aggressive cleanup
|
||||
let deletedCount = KeychainManager.shared.aggressiveCleanupLegacyItems()
|
||||
|
||||
// Verify items were deleted
|
||||
XCTAssertGreaterThan(deletedCount, 0, "Should delete at least some legacy items")
|
||||
|
||||
// Verify legacy items are gone
|
||||
for service in legacyServices {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
XCTAssertEqual(status, errSecItemNotFound, "Legacy service '\(service)' should be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Performance Tests
|
||||
|
||||
func testIdentityLoadPerformance() {
|
||||
// Ensure identity exists
|
||||
_ = NoiseEncryptionService()
|
||||
|
||||
measure {
|
||||
// Measure how long it takes to load identity
|
||||
_ = NoiseEncryptionService()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
//
|
||||
// NoiseKeyRotationTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import CryptoKit
|
||||
@testable import bitchat
|
||||
|
||||
class NoiseKeyRotationTests: XCTestCase {
|
||||
|
||||
var keyRotation: NoiseChannelKeyRotation!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
keyRotation = NoiseChannelKeyRotation()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Clean up test data
|
||||
keyRotation.clearEpochs(for: "#test-channel")
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Basic Key Rotation Tests
|
||||
|
||||
func testInitialKeyGeneration() {
|
||||
let channel = "#test-channel"
|
||||
let password = "test-password"
|
||||
let fingerprint = "abc123def456"
|
||||
|
||||
// Get initial key
|
||||
guard let rotatedKey = keyRotation.getCurrentKey(
|
||||
for: channel,
|
||||
basePassword: password,
|
||||
creatorFingerprint: fingerprint
|
||||
) else {
|
||||
XCTFail("Failed to get initial key")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(rotatedKey.epoch.epochNumber, 1)
|
||||
XCTAssertTrue(rotatedKey.isActive)
|
||||
XCTAssertNotNil(rotatedKey.key)
|
||||
}
|
||||
|
||||
func testKeyRotation() {
|
||||
let channel = "#test-channel"
|
||||
let password = "test-password"
|
||||
let fingerprint = "abc123def456"
|
||||
|
||||
// Get initial key
|
||||
let initialKey = keyRotation.getCurrentKey(
|
||||
for: channel,
|
||||
basePassword: password,
|
||||
creatorFingerprint: fingerprint
|
||||
)
|
||||
|
||||
// Rotate key
|
||||
let newEpoch = keyRotation.rotateChannelKey(
|
||||
for: channel,
|
||||
basePassword: password,
|
||||
creatorFingerprint: fingerprint
|
||||
)
|
||||
|
||||
XCTAssertEqual(newEpoch.epochNumber, 2)
|
||||
XCTAssertNotNil(newEpoch.previousEpochCommitment)
|
||||
|
||||
// Get new current key
|
||||
let rotatedKey = keyRotation.getCurrentKey(
|
||||
for: channel,
|
||||
basePassword: password,
|
||||
creatorFingerprint: fingerprint
|
||||
)
|
||||
|
||||
XCTAssertEqual(rotatedKey?.epoch.epochNumber, 2)
|
||||
|
||||
// Keys should be different
|
||||
if let initial = initialKey, let rotated = rotatedKey {
|
||||
XCTAssertNotEqual(
|
||||
initial.key.withUnsafeBytes { Data($0) },
|
||||
rotated.key.withUnsafeBytes { Data($0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testKeyRotationNeeded() {
|
||||
let channel = "#test-channel"
|
||||
let password = "test-password"
|
||||
let fingerprint = "abc123def456"
|
||||
|
||||
// Initially needs rotation (no epochs)
|
||||
XCTAssertTrue(keyRotation.needsKeyRotation(for: channel))
|
||||
|
||||
// After getting initial key, shouldn't need rotation
|
||||
_ = keyRotation.getCurrentKey(
|
||||
for: channel,
|
||||
basePassword: password,
|
||||
creatorFingerprint: fingerprint
|
||||
)
|
||||
|
||||
XCTAssertFalse(keyRotation.needsKeyRotation(for: channel))
|
||||
|
||||
// Note: We can't easily test time-based rotation need without
|
||||
// modifying internal state or waiting 22+ hours
|
||||
}
|
||||
|
||||
// MARK: - Multiple Epoch Tests
|
||||
|
||||
func testMultipleEpochsForDecryption() {
|
||||
let channel = "#test-channel"
|
||||
let password = "test-password"
|
||||
let fingerprint = "abc123def456"
|
||||
|
||||
// Create initial epoch
|
||||
_ = keyRotation.getCurrentKey(
|
||||
for: channel,
|
||||
basePassword: password,
|
||||
creatorFingerprint: fingerprint
|
||||
)
|
||||
|
||||
// Rotate multiple times
|
||||
for _ in 0..<3 {
|
||||
_ = keyRotation.rotateChannelKey(
|
||||
for: channel,
|
||||
basePassword: password,
|
||||
creatorFingerprint: fingerprint
|
||||
)
|
||||
}
|
||||
|
||||
// Get valid keys for decryption
|
||||
let validKeys = keyRotation.getValidKeysForDecryption(
|
||||
channel: channel,
|
||||
basePassword: password,
|
||||
creatorFingerprint: fingerprint
|
||||
)
|
||||
|
||||
// Should have at least the current epoch
|
||||
XCTAssertGreaterThanOrEqual(validKeys.count, 1)
|
||||
|
||||
// Check that we have the latest epoch
|
||||
XCTAssertTrue(validKeys.contains { $0.epoch.epochNumber == 4 })
|
||||
}
|
||||
|
||||
func testEpochKeyDerivationConsistency() {
|
||||
let channel = "#test-channel"
|
||||
let password = "test-password"
|
||||
let fingerprint = "abc123def456"
|
||||
|
||||
// Get key for epoch 1
|
||||
let key1a = keyRotation.getCurrentKey(
|
||||
for: channel,
|
||||
basePassword: password,
|
||||
creatorFingerprint: fingerprint
|
||||
)
|
||||
|
||||
// Get the same key again
|
||||
let key1b = keyRotation.getCurrentKey(
|
||||
for: channel,
|
||||
basePassword: password,
|
||||
creatorFingerprint: fingerprint
|
||||
)
|
||||
|
||||
// Keys should be identical for same epoch
|
||||
if let a = key1a, let b = key1b {
|
||||
XCTAssertEqual(
|
||||
a.key.withUnsafeBytes { Data($0) },
|
||||
b.key.withUnsafeBytes { Data($0) }
|
||||
)
|
||||
XCTAssertEqual(a.epoch.epochNumber, b.epoch.epochNumber)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Edge Cases
|
||||
|
||||
func testMaxEpochLimit() {
|
||||
let channel = "#test-channel"
|
||||
let password = "test-password"
|
||||
let fingerprint = "abc123def456"
|
||||
|
||||
// Create initial epoch
|
||||
_ = keyRotation.getCurrentKey(
|
||||
for: channel,
|
||||
basePassword: password,
|
||||
creatorFingerprint: fingerprint
|
||||
)
|
||||
|
||||
// Rotate many times (more than max stored epochs)
|
||||
for _ in 0..<10 {
|
||||
_ = keyRotation.rotateChannelKey(
|
||||
for: channel,
|
||||
basePassword: password,
|
||||
creatorFingerprint: fingerprint
|
||||
)
|
||||
}
|
||||
|
||||
// Get all valid epochs
|
||||
let validKeys = keyRotation.getValidKeysForDecryption(
|
||||
channel: channel,
|
||||
basePassword: password,
|
||||
creatorFingerprint: fingerprint
|
||||
)
|
||||
|
||||
// Should not exceed reasonable limit
|
||||
XCTAssertLessThanOrEqual(validKeys.count, 7) // maxStoredEpochs
|
||||
}
|
||||
|
||||
func testDifferentChannelsDifferentEpochs() {
|
||||
let password = "test-password"
|
||||
let fingerprint = "abc123def456"
|
||||
|
||||
let channel1 = "#channel-1"
|
||||
let channel2 = "#channel-2"
|
||||
|
||||
// Get keys for both channels
|
||||
let key1 = keyRotation.getCurrentKey(
|
||||
for: channel1,
|
||||
basePassword: password,
|
||||
creatorFingerprint: fingerprint
|
||||
)
|
||||
|
||||
let key2 = keyRotation.getCurrentKey(
|
||||
for: channel2,
|
||||
basePassword: password,
|
||||
creatorFingerprint: fingerprint
|
||||
)
|
||||
|
||||
// Keys should be different even with same password
|
||||
if let k1 = key1, let k2 = key2 {
|
||||
XCTAssertNotEqual(
|
||||
k1.key.withUnsafeBytes { Data($0) },
|
||||
k2.key.withUnsafeBytes { Data($0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Integration Tests
|
||||
|
||||
func testKeyRotationWithEncryption() throws {
|
||||
let channel = "#test-channel"
|
||||
let password = "test-password"
|
||||
let fingerprint = "abc123def456"
|
||||
|
||||
// Get initial key
|
||||
guard let initialRotatedKey = keyRotation.getCurrentKey(
|
||||
for: channel,
|
||||
basePassword: password,
|
||||
creatorFingerprint: fingerprint
|
||||
) else {
|
||||
XCTFail("Failed to get initial key")
|
||||
return
|
||||
}
|
||||
|
||||
// Encrypt a message with initial key
|
||||
let message = "Test message before rotation"
|
||||
let nonce = ChaChaPoly.Nonce()
|
||||
let sealed1 = try ChaChaPoly.seal(
|
||||
Data(message.utf8),
|
||||
using: initialRotatedKey.key,
|
||||
nonce: nonce
|
||||
)
|
||||
|
||||
// Rotate key
|
||||
_ = keyRotation.rotateChannelKey(
|
||||
for: channel,
|
||||
basePassword: password,
|
||||
creatorFingerprint: fingerprint
|
||||
)
|
||||
|
||||
// Get new key
|
||||
guard let newRotatedKey = keyRotation.getCurrentKey(
|
||||
for: channel,
|
||||
basePassword: password,
|
||||
creatorFingerprint: fingerprint
|
||||
) else {
|
||||
XCTFail("Failed to get rotated key")
|
||||
return
|
||||
}
|
||||
|
||||
// New key should not decrypt old message
|
||||
XCTAssertThrowsError(
|
||||
try ChaChaPoly.open(sealed1, using: newRotatedKey.key)
|
||||
)
|
||||
|
||||
// But we should still be able to decrypt with old epoch key
|
||||
let validKeys = keyRotation.getValidKeysForDecryption(
|
||||
channel: channel,
|
||||
basePassword: password,
|
||||
creatorFingerprint: fingerprint
|
||||
)
|
||||
|
||||
// Try each valid key until one works
|
||||
var decrypted = false
|
||||
for rotatedKey in validKeys {
|
||||
do {
|
||||
let plaintext = try ChaChaPoly.open(sealed1, using: rotatedKey.key)
|
||||
XCTAssertEqual(String(data: plaintext, encoding: .utf8), message)
|
||||
decrypted = true
|
||||
break
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
XCTAssertTrue(decrypted, "Failed to decrypt with any valid key")
|
||||
}
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
//
|
||||
// NoiseProtocolTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import CryptoKit
|
||||
@testable import bitchat
|
||||
|
||||
class NoiseProtocolTests: XCTestCase {
|
||||
|
||||
// MARK: - Cipher State Tests
|
||||
|
||||
func testCipherStateEncryptDecrypt() throws {
|
||||
let key = SymmetricKey(size: .bits256)
|
||||
let cipher = NoiseCipherState(key: key)
|
||||
|
||||
let plaintext = "Hello, Noise Protocol!".data(using: .utf8)!
|
||||
let associatedData = "metadata".data(using: .utf8)!
|
||||
|
||||
// Encrypt
|
||||
let ciphertext = try cipher.encrypt(plaintext: plaintext, associatedData: associatedData)
|
||||
|
||||
// Create new cipher with same key for decryption
|
||||
let decryptCipher = NoiseCipherState(key: key)
|
||||
let decrypted = try decryptCipher.decrypt(ciphertext: ciphertext, associatedData: associatedData)
|
||||
|
||||
XCTAssertEqual(plaintext, decrypted)
|
||||
}
|
||||
|
||||
func testCipherStateNonceIncrement() throws {
|
||||
let key = SymmetricKey(size: .bits256)
|
||||
let cipher = NoiseCipherState(key: key)
|
||||
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
|
||||
// Encrypt multiple messages
|
||||
let ct1 = try cipher.encrypt(plaintext: plaintext)
|
||||
let ct2 = try cipher.encrypt(plaintext: plaintext)
|
||||
let ct3 = try cipher.encrypt(plaintext: plaintext)
|
||||
|
||||
// All ciphertexts should be different due to nonce increment
|
||||
XCTAssertNotEqual(ct1, ct2)
|
||||
XCTAssertNotEqual(ct2, ct3)
|
||||
XCTAssertNotEqual(ct1, ct3)
|
||||
}
|
||||
|
||||
// MARK: - Symmetric State Tests
|
||||
|
||||
func testSymmetricStateInitialization() {
|
||||
let protocolName = "Noise_XX_25519_ChaChaPoly_SHA256"
|
||||
let state = NoiseSymmetricState(protocolName: protocolName)
|
||||
|
||||
// Hash should be initialized with protocol name
|
||||
let hash = state.getHandshakeHash()
|
||||
XCTAssertEqual(hash.count, 32) // SHA256 output
|
||||
}
|
||||
|
||||
func testSymmetricStateMixKey() throws {
|
||||
let state = NoiseSymmetricState(protocolName: "Noise_XX_25519_ChaChaPoly_SHA256")
|
||||
|
||||
let keyMaterial = Data(repeating: 0x42, count: 32)
|
||||
state.mixKey(keyMaterial)
|
||||
|
||||
// After mixKey, cipher should be initialized
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let encrypted = try state.encryptAndHash(plaintext)
|
||||
|
||||
XCTAssertNotEqual(plaintext, encrypted)
|
||||
XCTAssertEqual(encrypted.count, plaintext.count + 16) // ChaCha20Poly1305 adds 16-byte tag
|
||||
}
|
||||
|
||||
// MARK: - Handshake State Tests
|
||||
|
||||
func testNoiseXXHandshakeComplete() throws {
|
||||
// Create initiator and responder
|
||||
let initiatorStatic = Curve25519.KeyAgreement.PrivateKey()
|
||||
let responderStatic = Curve25519.KeyAgreement.PrivateKey()
|
||||
|
||||
var initiator = NoiseHandshakeState(role: .initiator, pattern: .XX, localStaticKey: initiatorStatic)
|
||||
var responder = NoiseHandshakeState(role: .responder, pattern: .XX, localStaticKey: responderStatic)
|
||||
|
||||
// Message 1: initiator -> responder (e)
|
||||
let msg1 = try initiator.writeMessage()
|
||||
_ = try responder.readMessage(msg1)
|
||||
|
||||
// Message 2: responder -> initiator (e, ee, s, es)
|
||||
let msg2 = try responder.writeMessage()
|
||||
_ = try initiator.readMessage(msg2)
|
||||
|
||||
// Message 3: initiator -> responder (s, se)
|
||||
let msg3 = try initiator.writeMessage()
|
||||
_ = try responder.readMessage(msg3)
|
||||
|
||||
// Both should have completed handshake
|
||||
XCTAssertTrue(initiator.isHandshakeComplete())
|
||||
XCTAssertTrue(responder.isHandshakeComplete())
|
||||
|
||||
// Get transport ciphers
|
||||
let (initSend, initRecv) = try initiator.getTransportCiphers()
|
||||
let (respSend, respRecv) = try responder.getTransportCiphers()
|
||||
|
||||
// Test transport encryption
|
||||
let testMessage = "Secret message".data(using: .utf8)!
|
||||
let encrypted = try initSend.encrypt(plaintext: testMessage)
|
||||
let decrypted = try respRecv.decrypt(ciphertext: encrypted)
|
||||
|
||||
XCTAssertEqual(testMessage, decrypted)
|
||||
|
||||
// Test reverse direction
|
||||
let encrypted2 = try respSend.encrypt(plaintext: testMessage)
|
||||
let decrypted2 = try initRecv.decrypt(ciphertext: encrypted2)
|
||||
|
||||
XCTAssertEqual(testMessage, decrypted2)
|
||||
}
|
||||
|
||||
func testNoiseXXWithPayloads() throws {
|
||||
let initiatorStatic = Curve25519.KeyAgreement.PrivateKey()
|
||||
let responderStatic = Curve25519.KeyAgreement.PrivateKey()
|
||||
|
||||
var initiator = NoiseHandshakeState(role: .initiator, pattern: .XX, localStaticKey: initiatorStatic)
|
||||
var responder = NoiseHandshakeState(role: .responder, pattern: .XX, localStaticKey: responderStatic)
|
||||
|
||||
// Message 1 with payload
|
||||
let payload1 = "Hello from initiator".data(using: .utf8)!
|
||||
let msg1 = try initiator.writeMessage(payload: payload1)
|
||||
let received1 = try responder.readMessage(msg1)
|
||||
XCTAssertEqual(payload1, received1)
|
||||
|
||||
// Message 2 with payload
|
||||
let payload2 = "Hello from responder".data(using: .utf8)!
|
||||
let msg2 = try responder.writeMessage(payload: payload2)
|
||||
let received2 = try initiator.readMessage(msg2)
|
||||
XCTAssertEqual(payload2, received2)
|
||||
|
||||
// Message 3 with payload
|
||||
let payload3 = "Final message".data(using: .utf8)!
|
||||
let msg3 = try initiator.writeMessage(payload: payload3)
|
||||
let received3 = try responder.readMessage(msg3)
|
||||
XCTAssertEqual(payload3, received3)
|
||||
}
|
||||
|
||||
// MARK: - Session Tests
|
||||
|
||||
func testNoiseSessionLifecycle() throws {
|
||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
|
||||
let aliceSession = NoiseSession(peerID: "bob", role: .initiator, localStaticKey: aliceKey)
|
||||
let bobSession = NoiseSession(peerID: "alice", role: .responder, localStaticKey: bobKey)
|
||||
|
||||
// Start handshake - only initiator calls startHandshake
|
||||
let msg1 = try aliceSession.startHandshake()
|
||||
XCTAssertFalse(msg1.isEmpty, "Initiator should send first message")
|
||||
|
||||
// Process messages - responder will auto-initialize on first message
|
||||
let msg2 = try bobSession.processHandshakeMessage(msg1)!
|
||||
XCTAssertFalse(msg2.isEmpty, "Responder should send second message")
|
||||
|
||||
let msg3 = try aliceSession.processHandshakeMessage(msg2)!
|
||||
XCTAssertFalse(msg3.isEmpty, "Initiator should send third message")
|
||||
|
||||
let finalMsg = try bobSession.processHandshakeMessage(msg3)
|
||||
XCTAssertNil(finalMsg, "No more messages after handshake complete")
|
||||
|
||||
// Both sessions should be established
|
||||
XCTAssertTrue(aliceSession.isEstablished(), "Alice session should be established")
|
||||
XCTAssertTrue(bobSession.isEstablished(), "Bob session should be established")
|
||||
|
||||
// Test encryption
|
||||
let plaintext = "Test message".data(using: .utf8)!
|
||||
let encrypted = try aliceSession.encrypt(plaintext)
|
||||
let decrypted = try bobSession.decrypt(encrypted)
|
||||
|
||||
XCTAssertEqual(plaintext, decrypted)
|
||||
}
|
||||
|
||||
// MARK: - Integration Tests
|
||||
|
||||
func testNoiseEncryptionServiceIntegration() throws {
|
||||
// Clean up any existing keys
|
||||
_ = KeychainManager.shared.deleteIdentityKey(forKey: "noiseStaticKey")
|
||||
|
||||
let service1 = NoiseEncryptionService()
|
||||
let service2 = NoiseEncryptionService()
|
||||
|
||||
let peer1ID = "peer1"
|
||||
let peer2ID = "peer2"
|
||||
|
||||
// Initiate handshake from peer1 to peer2
|
||||
let handshake1 = try service1.initiateHandshake(with: peer2ID)
|
||||
|
||||
// Process on peer2 and get response
|
||||
let handshake2 = try service2.processHandshakeMessage(from: peer1ID, message: handshake1)!
|
||||
|
||||
// Process response on peer1
|
||||
let handshake3 = try service1.processHandshakeMessage(from: peer2ID, message: handshake2)!
|
||||
|
||||
// Final message on peer2
|
||||
let final = try service2.processHandshakeMessage(from: peer1ID, message: handshake3)
|
||||
XCTAssertNil(final)
|
||||
|
||||
// Both should have established sessions
|
||||
XCTAssertTrue(service1.hasEstablishedSession(with: peer2ID))
|
||||
XCTAssertTrue(service2.hasEstablishedSession(with: peer1ID))
|
||||
|
||||
// Test message encryption
|
||||
let message = "Secret message".data(using: .utf8)!
|
||||
let encrypted = try service1.encrypt(message, for: peer2ID)
|
||||
let decrypted = try service2.decrypt(encrypted, from: peer1ID)
|
||||
|
||||
XCTAssertEqual(message, decrypted)
|
||||
}
|
||||
|
||||
func testBidirectionalNoiseSession() throws {
|
||||
// This test verifies that messages can be sent in both directions after handshake
|
||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
|
||||
// Create session managers
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey)
|
||||
|
||||
// Alice initiates handshake (msg1: -> e)
|
||||
let msg1 = try aliceManager.initiateHandshake(with: "bob")
|
||||
XCTAssertFalse(msg1.isEmpty)
|
||||
|
||||
// Bob processes and responds (msg2: <- e, ee, s, es)
|
||||
let msg2 = try bobManager.handleIncomingHandshake(from: "alice", message: msg1)
|
||||
XCTAssertNotNil(msg2)
|
||||
XCTAssertFalse(msg2!.isEmpty)
|
||||
|
||||
// Alice processes and sends final message (msg3: -> s, se)
|
||||
let msg3 = try aliceManager.handleIncomingHandshake(from: "bob", message: msg2!)
|
||||
XCTAssertNotNil(msg3)
|
||||
XCTAssertFalse(msg3!.isEmpty)
|
||||
|
||||
// Bob processes final message
|
||||
let msg4 = try bobManager.handleIncomingHandshake(from: "alice", message: msg3!)
|
||||
XCTAssertNil(msg4) // Now handshake is complete
|
||||
|
||||
// Verify both sessions are established
|
||||
XCTAssertTrue(aliceManager.getSession(for: "bob")?.isEstablished() ?? false)
|
||||
XCTAssertTrue(bobManager.getSession(for: "alice")?.isEstablished() ?? false)
|
||||
|
||||
// Test Alice -> Bob
|
||||
let aliceMessage = "Hello Bob!".data(using: .utf8)!
|
||||
let encrypted1 = try aliceManager.encrypt(aliceMessage, for: "bob")
|
||||
let decrypted1 = try bobManager.decrypt(encrypted1, from: "alice")
|
||||
XCTAssertEqual(decrypted1, aliceMessage)
|
||||
|
||||
// Test Bob -> Alice
|
||||
let bobMessage = "Hello Alice!".data(using: .utf8)!
|
||||
let encrypted2 = try bobManager.encrypt(bobMessage, for: "alice")
|
||||
let decrypted2 = try aliceManager.decrypt(encrypted2, from: "bob")
|
||||
XCTAssertEqual(decrypted2, bobMessage)
|
||||
|
||||
// Test multiple messages in both directions
|
||||
for i in 1...5 {
|
||||
// Alice -> Bob
|
||||
let msg = "Message \(i) from Alice".data(using: .utf8)!
|
||||
let enc = try aliceManager.encrypt(msg, for: "bob")
|
||||
let dec = try bobManager.decrypt(enc, from: "alice")
|
||||
XCTAssertEqual(dec, msg)
|
||||
|
||||
// Bob -> Alice
|
||||
let msg2 = "Message \(i) from Bob".data(using: .utf8)!
|
||||
let enc2 = try bobManager.encrypt(msg2, for: "alice")
|
||||
let dec2 = try aliceManager.decrypt(enc2, from: "bob")
|
||||
XCTAssertEqual(dec2, msg2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Channel Encryption Tests
|
||||
|
||||
func testChannelEncryption() throws {
|
||||
let channelEnc = NoiseChannelEncryption()
|
||||
let channel = "#test-channel"
|
||||
let password = "super-secret-password"
|
||||
|
||||
// Set channel password
|
||||
channelEnc.setChannelPassword(password, for: channel)
|
||||
|
||||
// Encrypt message
|
||||
let message = "Hello channel!"
|
||||
let encrypted = try channelEnc.encryptChannelMessage(message, for: channel)
|
||||
|
||||
// Decrypt message
|
||||
let decrypted = try channelEnc.decryptChannelMessage(encrypted, for: channel)
|
||||
|
||||
XCTAssertEqual(message, decrypted)
|
||||
}
|
||||
|
||||
func testChannelKeyDerivation() {
|
||||
let channelEnc = NoiseChannelEncryption()
|
||||
let password = "test-password"
|
||||
|
||||
// Same password and channel should produce same key
|
||||
let key1 = channelEnc.deriveChannelKey(from: password, channel: "#channel1")
|
||||
let key2 = channelEnc.deriveChannelKey(from: password, channel: "#channel1")
|
||||
|
||||
// Different channels should produce different keys
|
||||
let key3 = channelEnc.deriveChannelKey(from: password, channel: "#channel2")
|
||||
|
||||
// Can't directly compare SymmetricKey, but we can test encryption
|
||||
let testData = "test".data(using: .utf8)!
|
||||
let nonce = ChaChaPoly.Nonce()
|
||||
|
||||
let sealed1 = try! ChaChaPoly.seal(testData, using: key1, nonce: nonce)
|
||||
let sealed2 = try! ChaChaPoly.seal(testData, using: key2, nonce: nonce)
|
||||
|
||||
XCTAssertEqual(sealed1.ciphertext, sealed2.ciphertext)
|
||||
|
||||
// Different key should produce different ciphertext
|
||||
let sealed3 = try! ChaChaPoly.seal(testData, using: key3, nonce: nonce)
|
||||
XCTAssertNotEqual(sealed1.ciphertext, sealed3.ciphertext)
|
||||
}
|
||||
|
||||
// MARK: - Security Tests
|
||||
|
||||
func testHandshakeAuthentication() throws {
|
||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let eveKey = Curve25519.KeyAgreement.PrivateKey() // Attacker
|
||||
|
||||
var alice = NoiseHandshakeState(role: .initiator, pattern: .XX, localStaticKey: aliceKey)
|
||||
var eve = NoiseHandshakeState(role: .responder, pattern: .XX, localStaticKey: eveKey)
|
||||
|
||||
// Alice initiates handshake thinking she's talking to Bob
|
||||
let msg1 = try alice.writeMessage()
|
||||
_ = try eve.readMessage(msg1)
|
||||
|
||||
// Eve responds with her keys
|
||||
let msg2 = try eve.writeMessage()
|
||||
_ = try alice.readMessage(msg2)
|
||||
|
||||
// Alice completes handshake
|
||||
let msg3 = try alice.writeMessage()
|
||||
_ = try eve.readMessage(msg3)
|
||||
|
||||
// Both complete handshake, but Alice has Eve's public key, not Bob's
|
||||
let aliceRemoteKey = alice.getRemoteStaticPublicKey()
|
||||
XCTAssertEqual(aliceRemoteKey?.rawRepresentation, eveKey.publicKey.rawRepresentation)
|
||||
XCTAssertNotEqual(aliceRemoteKey?.rawRepresentation, bobKey.publicKey.rawRepresentation)
|
||||
|
||||
// This demonstrates that authentication requires out-of-band verification
|
||||
// or pre-shared knowledge of public keys
|
||||
}
|
||||
|
||||
func testReplayProtection() throws {
|
||||
let key = SymmetricKey(size: .bits256)
|
||||
let cipher1 = NoiseCipherState(key: key)
|
||||
let cipher2 = NoiseCipherState(key: key)
|
||||
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
|
||||
// Encrypt a message
|
||||
let ciphertext = try cipher1.encrypt(plaintext: plaintext)
|
||||
|
||||
// Decrypt normally works
|
||||
_ = try cipher2.decrypt(ciphertext: ciphertext)
|
||||
|
||||
// Replaying the same ciphertext should fail due to nonce mismatch
|
||||
XCTAssertThrowsError(try cipher2.decrypt(ciphertext: ciphertext))
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
//
|
||||
// NoiseRateLimiterTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
class NoiseRateLimiterTests: XCTestCase {
|
||||
|
||||
// MARK: - Basic Rate Limiting Tests
|
||||
|
||||
func testHandshakeRateLimiting() {
|
||||
let rateLimiter = NoiseRateLimiter()
|
||||
let peerID = "test-peer"
|
||||
|
||||
// First few handshakes should be allowed
|
||||
XCTAssertTrue(rateLimiter.allowHandshake(from: peerID))
|
||||
XCTAssertTrue(rateLimiter.allowHandshake(from: peerID))
|
||||
XCTAssertTrue(rateLimiter.allowHandshake(from: peerID))
|
||||
|
||||
// After hitting limit, should be rate limited
|
||||
// Default is 3 handshakes per minute
|
||||
XCTAssertFalse(rateLimiter.allowHandshake(from: peerID))
|
||||
XCTAssertFalse(rateLimiter.allowHandshake(from: peerID))
|
||||
}
|
||||
|
||||
func testMessageRateLimiting() {
|
||||
let rateLimiter = NoiseRateLimiter()
|
||||
let peerID = "test-peer"
|
||||
|
||||
// Messages have higher limit (100 per minute default)
|
||||
for _ in 0..<100 {
|
||||
XCTAssertTrue(rateLimiter.allowMessage(from: peerID))
|
||||
}
|
||||
|
||||
// 101st message should be rate limited
|
||||
XCTAssertFalse(rateLimiter.allowMessage(from: peerID))
|
||||
}
|
||||
|
||||
func testPerPeerRateLimiting() {
|
||||
let rateLimiter = NoiseRateLimiter()
|
||||
let peer1 = "alice"
|
||||
let peer2 = "bob"
|
||||
|
||||
// Rate limit peer1
|
||||
XCTAssertTrue(rateLimiter.allowHandshake(from: peer1))
|
||||
XCTAssertTrue(rateLimiter.allowHandshake(from: peer1))
|
||||
XCTAssertTrue(rateLimiter.allowHandshake(from: peer1))
|
||||
XCTAssertFalse(rateLimiter.allowHandshake(from: peer1))
|
||||
|
||||
// Peer2 should still be allowed
|
||||
XCTAssertTrue(rateLimiter.allowHandshake(from: peer2))
|
||||
XCTAssertTrue(rateLimiter.allowHandshake(from: peer2))
|
||||
}
|
||||
|
||||
// MARK: - Time Window Tests
|
||||
|
||||
func testRateLimitResetsAfterWindow() {
|
||||
let rateLimiter = NoiseRateLimiter()
|
||||
let peerID = "test-peer"
|
||||
|
||||
// Use up the limit
|
||||
for _ in 0..<3 {
|
||||
XCTAssertTrue(rateLimiter.allowHandshake(from: peerID))
|
||||
}
|
||||
XCTAssertFalse(rateLimiter.allowHandshake(from: peerID))
|
||||
|
||||
// Simulate time passing by clearing the window
|
||||
rateLimiter.clearExpiredEntries()
|
||||
|
||||
// Should be allowed again after window expires
|
||||
// Note: In real implementation, this would require actual time to pass
|
||||
// For testing, we might need to inject a clock or expose internal state
|
||||
}
|
||||
|
||||
// MARK: - Global Rate Limiting Tests
|
||||
|
||||
func testGlobalHandshakeLimit() {
|
||||
let rateLimiter = NoiseRateLimiter()
|
||||
|
||||
// Global limit prevents too many handshakes across all peers
|
||||
var allowedCount = 0
|
||||
|
||||
// Try many handshakes from different peers
|
||||
for i in 0..<50 {
|
||||
let peerID = "peer-\(i)"
|
||||
if rateLimiter.allowHandshake(from: peerID) {
|
||||
allowedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Should hit global limit before allowing all 50
|
||||
XCTAssertLessThan(allowedCount, 50)
|
||||
XCTAssertGreaterThan(allowedCount, 10) // But should allow reasonable amount
|
||||
}
|
||||
|
||||
// MARK: - Attack Mitigation Tests
|
||||
|
||||
func testRapidHandshakeAttackMitigation() {
|
||||
let rateLimiter = NoiseRateLimiter()
|
||||
let attackerID = "attacker"
|
||||
|
||||
var blockedCount = 0
|
||||
|
||||
// Simulate rapid handshake attempts
|
||||
for _ in 0..<20 {
|
||||
if !rateLimiter.allowHandshake(from: attackerID) {
|
||||
blockedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Most attempts should be blocked
|
||||
XCTAssertGreaterThan(blockedCount, 15)
|
||||
}
|
||||
|
||||
func testDistributedAttackMitigation() {
|
||||
let rateLimiter = NoiseRateLimiter()
|
||||
|
||||
var blockedCount = 0
|
||||
|
||||
// Simulate distributed attack from many IPs
|
||||
for i in 0..<100 {
|
||||
let attackerID = "192.168.1.\(i)"
|
||||
// Each attacker tries multiple times
|
||||
for _ in 0..<5 {
|
||||
if !rateLimiter.allowHandshake(from: attackerID) {
|
||||
blockedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global rate limiting should kick in
|
||||
XCTAssertGreaterThan(blockedCount, 0)
|
||||
}
|
||||
|
||||
// MARK: - Memory Management Tests
|
||||
|
||||
func testMemoryBoundedTracking() {
|
||||
let rateLimiter = NoiseRateLimiter()
|
||||
|
||||
// Add many different peers
|
||||
for i in 0..<10000 {
|
||||
let peerID = "peer-\(i)"
|
||||
_ = rateLimiter.allowMessage(from: peerID)
|
||||
}
|
||||
|
||||
// Rate limiter should have bounds on memory usage
|
||||
// Implementation should clean up old entries
|
||||
rateLimiter.clearExpiredEntries()
|
||||
|
||||
// Verify it still functions correctly
|
||||
XCTAssertTrue(rateLimiter.allowMessage(from: "new-peer"))
|
||||
}
|
||||
|
||||
// MARK: - Configuration Tests
|
||||
|
||||
func testCustomRateLimits() {
|
||||
// Test with custom configuration
|
||||
let config = NoiseRateLimiter.Configuration(
|
||||
handshakesPerMinute: 5,
|
||||
messagesPerMinute: 200,
|
||||
globalHandshakesPerMinute: 30
|
||||
)
|
||||
|
||||
let rateLimiter = NoiseRateLimiter(configuration: config)
|
||||
let peerID = "test-peer"
|
||||
|
||||
// Should allow up to 5 handshakes
|
||||
for i in 0..<5 {
|
||||
XCTAssertTrue(rateLimiter.allowHandshake(from: peerID), "Handshake \(i+1) should be allowed")
|
||||
}
|
||||
|
||||
// 6th should be blocked
|
||||
XCTAssertFalse(rateLimiter.allowHandshake(from: peerID))
|
||||
}
|
||||
|
||||
// MARK: - Thread Safety Tests
|
||||
|
||||
func testConcurrentAccess() {
|
||||
let rateLimiter = NoiseRateLimiter()
|
||||
let expectation = self.expectation(description: "Concurrent access")
|
||||
expectation.expectedFulfillmentCount = 10
|
||||
|
||||
// Multiple threads accessing rate limiter
|
||||
for i in 0..<10 {
|
||||
DispatchQueue.global().async {
|
||||
let peerID = "peer-\(i)"
|
||||
for _ in 0..<100 {
|
||||
_ = rateLimiter.allowMessage(from: peerID)
|
||||
}
|
||||
expectation.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 5) { error in
|
||||
XCTAssertNil(error)
|
||||
}
|
||||
|
||||
// Verify rate limiter still works
|
||||
XCTAssertTrue(rateLimiter.allowMessage(from: "final-test"))
|
||||
}
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
//
|
||||
// NoiseSecurityTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import CryptoKit
|
||||
@testable import bitchat
|
||||
|
||||
class NoiseSecurityTests: XCTestCase {
|
||||
|
||||
// MARK: - Channel Password Salt Tests
|
||||
|
||||
func testChannelPasswordSaltIncludesFingerprint() {
|
||||
let encryption = NoiseChannelEncryption()
|
||||
let password = "test-password-123"
|
||||
let channel = "#secure-channel"
|
||||
|
||||
// Derive key without fingerprint
|
||||
let key1 = encryption.deriveChannelKey(from: password, channel: channel, creatorFingerprint: nil)
|
||||
|
||||
// Derive key with fingerprint
|
||||
let fingerprint = "e36f7993abc123def456789012345678901234567890abcdef1234567890abcd"
|
||||
let key2 = encryption.deriveChannelKey(from: password, channel: channel, creatorFingerprint: fingerprint)
|
||||
|
||||
// Keys should be different due to different salts
|
||||
XCTAssertNotEqual(key1.withUnsafeBytes { Data($0) }, key2.withUnsafeBytes { Data($0) })
|
||||
}
|
||||
|
||||
func testChannelPasswordDerivationPerformance() {
|
||||
let encryption = NoiseChannelEncryption()
|
||||
let password = "test-password-123"
|
||||
let channel = "#performance-test"
|
||||
let fingerprint = "e36f7993abc123def456789012345678901234567890abcdef1234567890abcd"
|
||||
|
||||
// Measure time for PBKDF2 with 210,000 iterations
|
||||
measure {
|
||||
_ = encryption.deriveChannelKey(from: password, channel: channel, creatorFingerprint: fingerprint)
|
||||
}
|
||||
|
||||
// Should complete within reasonable time (< 1 second on modern hardware)
|
||||
}
|
||||
|
||||
func testDifferentChannelsProduceDifferentKeys() {
|
||||
let encryption = NoiseChannelEncryption()
|
||||
let password = "same-password"
|
||||
let fingerprint = "e36f7993abc123def456789012345678901234567890abcdef1234567890abcd"
|
||||
|
||||
let key1 = encryption.deriveChannelKey(from: password, channel: "#channel1", creatorFingerprint: fingerprint)
|
||||
let key2 = encryption.deriveChannelKey(from: password, channel: "#channel2", creatorFingerprint: fingerprint)
|
||||
|
||||
// Same password but different channels should produce different keys
|
||||
XCTAssertNotEqual(key1.withUnsafeBytes { Data($0) }, key2.withUnsafeBytes { Data($0) })
|
||||
}
|
||||
|
||||
// MARK: - Message Padding Tests
|
||||
|
||||
func testMessagePaddingAppliedToAllPackets() throws {
|
||||
// Create a small packet
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data("testuser".utf8),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data("Hello".utf8),
|
||||
signature: nil,
|
||||
ttl: 3
|
||||
)
|
||||
|
||||
// Encode packet
|
||||
guard let encodedData = packet.toBinaryData() else {
|
||||
XCTFail("Failed to encode packet")
|
||||
return
|
||||
}
|
||||
|
||||
// Check that size matches one of the standard block sizes
|
||||
let blockSizes = [256, 512, 1024, 2048]
|
||||
XCTAssertTrue(blockSizes.contains(encodedData.count) || encodedData.count > 2048,
|
||||
"Encoded data size \(encodedData.count) doesn't match expected block sizes")
|
||||
|
||||
// Decode should work correctly
|
||||
guard let decodedPacket = BitchatPacket.from(encodedData) else {
|
||||
XCTFail("Failed to decode packet")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify decoded content matches original
|
||||
XCTAssertEqual(decodedPacket.type, packet.type)
|
||||
XCTAssertEqual(String(data: decodedPacket.payload, encoding: .utf8),
|
||||
String(data: packet.payload, encoding: .utf8))
|
||||
}
|
||||
|
||||
func testPaddingConsistentAcrossMessages() {
|
||||
// Create multiple packets with same size payload
|
||||
let packets: [BitchatPacket] = (0..<5).map { i in
|
||||
BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data("user\(i)".utf8),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data("Same size message content here".utf8),
|
||||
signature: nil,
|
||||
ttl: 3
|
||||
)
|
||||
}
|
||||
|
||||
// Encode all packets
|
||||
let encodedSizes = packets.compactMap { $0.toBinaryData()?.count }
|
||||
|
||||
// All should have same padded size
|
||||
XCTAssertEqual(encodedSizes.count, packets.count)
|
||||
let firstSize = encodedSizes[0]
|
||||
XCTAssertTrue(encodedSizes.allSatisfy { $0 == firstSize },
|
||||
"All packets with similar content should pad to same size")
|
||||
}
|
||||
|
||||
// MARK: - Public Key Validation Tests
|
||||
|
||||
func testValidPublicKeyAccepted() throws {
|
||||
// Generate a valid key
|
||||
let validKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let publicKeyData = validKey.publicKey.rawRepresentation
|
||||
|
||||
// Should validate successfully
|
||||
let validated = try NoiseHandshakeState.validatePublicKey(publicKeyData)
|
||||
XCTAssertEqual(validated.rawRepresentation, publicKeyData)
|
||||
}
|
||||
|
||||
func testAllZeroKeyRejected() {
|
||||
let zeroKey = Data(repeating: 0x00, count: 32)
|
||||
|
||||
XCTAssertThrowsError(try NoiseHandshakeState.validatePublicKey(zeroKey)) { error in
|
||||
XCTAssertEqual(error as? NoiseError, NoiseError.invalidPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
func testAllOneKeyRejected() {
|
||||
let oneKey = Data(repeating: 0xFF, count: 32)
|
||||
|
||||
XCTAssertThrowsError(try NoiseHandshakeState.validatePublicKey(oneKey)) { error in
|
||||
XCTAssertEqual(error as? NoiseError, NoiseError.invalidPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
func testInvalidKeySizeRejected() {
|
||||
// Too short
|
||||
let shortKey = Data(repeating: 0x42, count: 16)
|
||||
XCTAssertThrowsError(try NoiseHandshakeState.validatePublicKey(shortKey)) { error in
|
||||
XCTAssertEqual(error as? NoiseError, NoiseError.invalidPublicKey)
|
||||
}
|
||||
|
||||
// Too long
|
||||
let longKey = Data(repeating: 0x42, count: 64)
|
||||
XCTAssertThrowsError(try NoiseHandshakeState.validatePublicKey(longKey)) { error in
|
||||
XCTAssertEqual(error as? NoiseError, NoiseError.invalidPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
func testWeakKeyRejected() {
|
||||
// Known weak Curve25519 key patterns
|
||||
// Low order points that would result in weak DH
|
||||
let weakKeys = [
|
||||
Data([0x01] + Array(repeating: 0x00, count: 31)), // Near zero
|
||||
Data(Array(repeating: 0x00, count: 31) + [0x01]), // Different pattern
|
||||
]
|
||||
|
||||
for weakKey in weakKeys {
|
||||
// CryptoKit should reject these during DH operation
|
||||
if (try? NoiseHandshakeState.validatePublicKey(weakKey)) != nil {
|
||||
// If key creation succeeds, DH should fail in validation
|
||||
print("Note: Weak key pattern was not rejected by CryptoKit directly")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Integration Tests
|
||||
|
||||
func testSecureHandshakeWithValidation() throws {
|
||||
// Create two parties
|
||||
let aliceStatic = Curve25519.KeyAgreement.PrivateKey()
|
||||
let bobStatic = Curve25519.KeyAgreement.PrivateKey()
|
||||
|
||||
var alice = NoiseHandshakeState(role: .initiator, pattern: .XX, localStaticKey: aliceStatic)
|
||||
var bob = NoiseHandshakeState(role: .responder, pattern: .XX, localStaticKey: bobStatic)
|
||||
|
||||
// Perform handshake - validation happens automatically
|
||||
let msg1 = try alice.writeMessage()
|
||||
_ = try bob.readMessage(msg1)
|
||||
|
||||
let msg2 = try bob.writeMessage()
|
||||
_ = try alice.readMessage(msg2)
|
||||
|
||||
let msg3 = try alice.writeMessage()
|
||||
_ = try bob.readMessage(msg3)
|
||||
|
||||
// Both should complete successfully
|
||||
XCTAssertTrue(alice.isHandshakeComplete())
|
||||
XCTAssertTrue(bob.isHandshakeComplete())
|
||||
}
|
||||
|
||||
func testPaddedMessageTransmission() throws {
|
||||
// Create a packet and encode it
|
||||
let originalMessage = "Test message for padding"
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data("sender123".utf8),
|
||||
recipientID: Data("recipient".utf8),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data(originalMessage.utf8),
|
||||
signature: nil,
|
||||
ttl: 5
|
||||
)
|
||||
|
||||
// Encode (with padding)
|
||||
guard let encoded = packet.toBinaryData() else {
|
||||
XCTFail("Failed to encode")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify padded size
|
||||
XCTAssertTrue(encoded.count >= originalMessage.count + 21) // Header + sender + payload
|
||||
|
||||
// Decode (removes padding)
|
||||
guard let decoded = BitchatPacket.from(encoded) else {
|
||||
XCTFail("Failed to decode")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify message integrity
|
||||
XCTAssertEqual(String(data: decoded.payload, encoding: .utf8), originalMessage)
|
||||
}
|
||||
|
||||
// MARK: - Session Rekeying Tests
|
||||
|
||||
func testSessionRekeyingTriggered() {
|
||||
// Create session manager
|
||||
let localKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let sessionManager = NoiseSessionManager(localStaticKey: localKey)
|
||||
|
||||
// Create a session
|
||||
let session = sessionManager.createSession(for: "testPeer", role: .initiator)
|
||||
|
||||
// Complete handshake
|
||||
let remoteKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
var remoteHandshake = NoiseHandshakeState(role: .responder, pattern: .XX, localStaticKey: remoteKey)
|
||||
|
||||
do {
|
||||
let msg1 = try session.startHandshake()
|
||||
_ = try remoteHandshake.readMessage(msg1)
|
||||
|
||||
let msg2 = try remoteHandshake.writeMessage()
|
||||
_ = try session.processHandshakeMessage(msg2)
|
||||
|
||||
let msg3 = try session.writeMessage()
|
||||
_ = try remoteHandshake.readMessage(msg3)
|
||||
|
||||
XCTAssertTrue(session.isEstablished())
|
||||
|
||||
// Get sessions needing rekey (should be empty)
|
||||
var needsRekey = sessionManager.getSessionsNeedingRekey()
|
||||
XCTAssertTrue(needsRekey.isEmpty)
|
||||
|
||||
// Force the session to need rekeying by manipulating its state
|
||||
if let secureSession = session as? SecureNoiseSession {
|
||||
// Set old activity time
|
||||
let oldTime = Date().addingTimeInterval(-35 * 60)
|
||||
secureSession.setLastActivityTimeForTesting(oldTime)
|
||||
|
||||
// Now check again
|
||||
needsRekey = sessionManager.getSessionsNeedingRekey()
|
||||
XCTAssertFalse(needsRekey.isEmpty)
|
||||
XCTAssertTrue(needsRekey.contains(where: { $0.peerID == "testPeer" && $0.needsRekey }))
|
||||
}
|
||||
|
||||
} catch {
|
||||
XCTFail("Test failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func testRekeyInitiation() {
|
||||
// Create session manager
|
||||
let localKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let sessionManager = NoiseSessionManager(localStaticKey: localKey)
|
||||
|
||||
// Create and establish a session
|
||||
let session = sessionManager.createSession(for: "testPeer", role: .initiator)
|
||||
|
||||
// Complete handshake
|
||||
let remoteKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
var remoteHandshake = NoiseHandshakeState(role: .responder, pattern: .XX, localStaticKey: remoteKey)
|
||||
|
||||
do {
|
||||
let msg1 = try session.startHandshake()
|
||||
_ = try remoteHandshake.readMessage(msg1)
|
||||
|
||||
let msg2 = try remoteHandshake.writeMessage()
|
||||
_ = try session.processHandshakeMessage(msg2)
|
||||
|
||||
let msg3 = try session.writeMessage()
|
||||
_ = try remoteHandshake.readMessage(msg3)
|
||||
|
||||
XCTAssertTrue(session.isEstablished())
|
||||
|
||||
// Store the old session's remote key
|
||||
let oldRemoteKey = session.getRemoteStaticPublicKey()
|
||||
XCTAssertNotNil(oldRemoteKey)
|
||||
|
||||
// Initiate rekey
|
||||
try sessionManager.initiateRekey(for: "testPeer")
|
||||
|
||||
// The old session should be removed
|
||||
let currentSession = sessionManager.getSession(for: "testPeer")
|
||||
XCTAssertNil(currentSession) // Session removed, waiting for new handshake
|
||||
|
||||
} catch {
|
||||
XCTFail("Test failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Integration Tests
|
||||
|
||||
func testFullRekeyHandshake() {
|
||||
// Create encryption service
|
||||
let alice = NoiseEncryptionService()
|
||||
let bob = NoiseEncryptionService()
|
||||
|
||||
let aliceID = "alice"
|
||||
let bobID = "bob"
|
||||
|
||||
do {
|
||||
// Initial handshake
|
||||
let msg1 = try alice.initiateHandshake(with: bobID)
|
||||
let msg2 = try bob.processHandshakeMessage(from: aliceID, message: msg1)!
|
||||
_ = try alice.processHandshakeMessage(from: bobID, message: msg2)
|
||||
|
||||
// Verify sessions established
|
||||
XCTAssertTrue(alice.hasEstablishedSession(with: bobID))
|
||||
XCTAssertTrue(bob.hasEstablishedSession(with: aliceID))
|
||||
|
||||
// Exchange some messages
|
||||
let plaintext1 = "Hello Bob"
|
||||
let encrypted1 = try alice.encrypt(Data(plaintext1.utf8), for: bobID)
|
||||
let decrypted1 = try bob.decrypt(encrypted1, from: aliceID)
|
||||
XCTAssertEqual(String(data: decrypted1, encoding: .utf8), plaintext1)
|
||||
|
||||
// Force session to expire by manipulating internal state
|
||||
// (In real scenario, this would happen after 30 minutes or 1M messages)
|
||||
|
||||
// Trigger rekey from Alice's side
|
||||
var rekeyHandshakeCompleted = false
|
||||
alice.onHandshakeRequired = { peerID in
|
||||
XCTAssertEqual(peerID, bobID)
|
||||
rekeyHandshakeCompleted = true
|
||||
}
|
||||
|
||||
// After rekey, should be able to continue messaging
|
||||
// Note: In real implementation, the rekey would be triggered automatically
|
||||
|
||||
} catch {
|
||||
XCTFail("Integration test failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func testErrorHandlingDuringHandshake() {
|
||||
let service = NoiseEncryptionService()
|
||||
|
||||
// Test invalid peer ID
|
||||
XCTAssertThrowsError(try service.initiateHandshake(with: "")) { error in
|
||||
if let securityError = error as? NoiseSecurityError {
|
||||
XCTAssertEqual(securityError, NoiseSecurityError.invalidPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
// Test invalid handshake message
|
||||
XCTAssertThrowsError(try service.processHandshakeMessage(from: "peer", message: Data())) { error in
|
||||
// Should fail to parse empty data as handshake
|
||||
}
|
||||
|
||||
// Test oversized handshake message
|
||||
let oversizedMessage = Data(repeating: 0x42, count: 100_000)
|
||||
XCTAssertThrowsError(try service.processHandshakeMessage(from: "peer", message: oversizedMessage)) { error in
|
||||
if let securityError = error as? NoiseSecurityError {
|
||||
XCTAssertEqual(securityError, NoiseSecurityError.messageTooLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testRateLimitingIntegration() {
|
||||
let service = NoiseEncryptionService()
|
||||
let peerID = "rate-limited-peer"
|
||||
|
||||
var handshakeAttempts = 0
|
||||
var rateLimitHit = false
|
||||
|
||||
// Try many rapid handshakes
|
||||
for _ in 0..<10 {
|
||||
do {
|
||||
_ = try service.initiateHandshake(with: peerID)
|
||||
handshakeAttempts += 1
|
||||
} catch {
|
||||
if let securityError = error as? NoiseSecurityError,
|
||||
securityError == NoiseSecurityError.rateLimitExceeded {
|
||||
rateLimitHit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Should hit rate limit before all 10 attempts
|
||||
XCTAssertTrue(rateLimitHit)
|
||||
XCTAssertLessThan(handshakeAttempts, 10)
|
||||
}
|
||||
|
||||
func testChannelEncryptionIntegration() {
|
||||
let service = NoiseEncryptionService()
|
||||
let channel = "#integration-test"
|
||||
let password = "test-password"
|
||||
let fingerprint = service.getIdentityFingerprint()
|
||||
|
||||
// Set channel password
|
||||
service.setChannelPassword(password, for: channel)
|
||||
|
||||
// Encrypt channel message
|
||||
do {
|
||||
let message = "Channel message test"
|
||||
let encrypted = try service.encryptChannelMessage(message, for: channel)
|
||||
|
||||
// Verify it's encrypted
|
||||
XCTAssertNotEqual(encrypted, Data(message.utf8))
|
||||
|
||||
// Decrypt
|
||||
let decrypted = try service.decryptChannelMessage(encrypted, for: channel)
|
||||
XCTAssertEqual(decrypted, message)
|
||||
|
||||
// Clean up
|
||||
service.removeChannelPassword(for: channel)
|
||||
|
||||
} catch {
|
||||
XCTFail("Channel encryption failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func testSecureSessionConcurrency() {
|
||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
|
||||
let alice = SecureNoiseSession(peerID: "bob", role: .initiator, localStaticKey: aliceKey)
|
||||
let bob = SecureNoiseSession(peerID: "alice", role: .responder, localStaticKey: bobKey)
|
||||
|
||||
// Complete handshake
|
||||
do {
|
||||
let msg1 = try alice.startHandshake()
|
||||
_ = try bob.processHandshakeMessage(msg1)
|
||||
|
||||
let msg2 = try bob.writeMessage()
|
||||
_ = try alice.processHandshakeMessage(msg2)
|
||||
|
||||
let msg3 = try alice.writeMessage()
|
||||
_ = try bob.processHandshakeMessage(msg3)
|
||||
|
||||
XCTAssertTrue(alice.isEstablished())
|
||||
XCTAssertTrue(bob.isEstablished())
|
||||
|
||||
// Concurrent encryption/decryption
|
||||
let expectation = self.expectation(description: "Concurrent operations")
|
||||
expectation.expectedFulfillmentCount = 20
|
||||
|
||||
let queue = DispatchQueue(label: "test.concurrent", attributes: .concurrent)
|
||||
|
||||
for i in 0..<10 {
|
||||
// Encrypt from Alice
|
||||
queue.async {
|
||||
do {
|
||||
let message = "Message \(i) from Alice"
|
||||
let encrypted = try alice.encrypt(Data(message.utf8))
|
||||
let decrypted = try bob.decrypt(encrypted)
|
||||
XCTAssertEqual(String(data: decrypted, encoding: .utf8), message)
|
||||
expectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Concurrent encrypt failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt from Bob
|
||||
queue.async {
|
||||
do {
|
||||
let message = "Message \(i) from Bob"
|
||||
let encrypted = try bob.encrypt(Data(message.utf8))
|
||||
let decrypted = try alice.decrypt(encrypted)
|
||||
XCTAssertEqual(String(data: decrypted, encoding: .utf8), message)
|
||||
expectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Concurrent decrypt failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 5)
|
||||
|
||||
} catch {
|
||||
XCTFail("Handshake failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
//
|
||||
// NoiseSecurityValidatorTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
class NoiseSecurityValidatorTests: XCTestCase {
|
||||
|
||||
// MARK: - Peer ID Validation Tests
|
||||
|
||||
func testValidPeerIDAccepted() {
|
||||
// Valid peer IDs
|
||||
XCTAssertTrue(NoiseSecurityValidator.validatePeerID("user123"))
|
||||
XCTAssertTrue(NoiseSecurityValidator.validatePeerID("alice"))
|
||||
XCTAssertTrue(NoiseSecurityValidator.validatePeerID("bob_2024"))
|
||||
XCTAssertTrue(NoiseSecurityValidator.validatePeerID("test-user"))
|
||||
XCTAssertTrue(NoiseSecurityValidator.validatePeerID("192.168.1.1:8080")) // IP:port format
|
||||
}
|
||||
|
||||
func testInvalidPeerIDRejected() {
|
||||
// Empty
|
||||
XCTAssertFalse(NoiseSecurityValidator.validatePeerID(""))
|
||||
|
||||
// Too long (over 255 chars)
|
||||
let longID = String(repeating: "a", count: 256)
|
||||
XCTAssertFalse(NoiseSecurityValidator.validatePeerID(longID))
|
||||
|
||||
// Control characters
|
||||
XCTAssertFalse(NoiseSecurityValidator.validatePeerID("user\0null"))
|
||||
XCTAssertFalse(NoiseSecurityValidator.validatePeerID("user\nline"))
|
||||
XCTAssertFalse(NoiseSecurityValidator.validatePeerID("user\ttab"))
|
||||
|
||||
// Path traversal attempts
|
||||
XCTAssertFalse(NoiseSecurityValidator.validatePeerID("../../../etc/passwd"))
|
||||
XCTAssertFalse(NoiseSecurityValidator.validatePeerID("user/../admin"))
|
||||
}
|
||||
|
||||
// MARK: - Message Size Validation Tests
|
||||
|
||||
func testValidMessageSizeAccepted() {
|
||||
// Small message
|
||||
let smallData = Data(repeating: 0x42, count: 100)
|
||||
XCTAssertTrue(NoiseSecurityValidator.validateMessageSize(smallData))
|
||||
|
||||
// Medium message (1MB)
|
||||
let mediumData = Data(repeating: 0x42, count: 1024 * 1024)
|
||||
XCTAssertTrue(NoiseSecurityValidator.validateMessageSize(mediumData))
|
||||
|
||||
// Just under limit (10MB - 1 byte)
|
||||
let nearLimitData = Data(repeating: 0x42, count: 10 * 1024 * 1024 - 1)
|
||||
XCTAssertTrue(NoiseSecurityValidator.validateMessageSize(nearLimitData))
|
||||
}
|
||||
|
||||
func testOversizedMessageRejected() {
|
||||
// Exactly at limit (10MB)
|
||||
let limitData = Data(repeating: 0x42, count: 10 * 1024 * 1024)
|
||||
XCTAssertFalse(NoiseSecurityValidator.validateMessageSize(limitData))
|
||||
|
||||
// Over limit
|
||||
let overData = Data(repeating: 0x42, count: 11 * 1024 * 1024)
|
||||
XCTAssertFalse(NoiseSecurityValidator.validateMessageSize(overData))
|
||||
}
|
||||
|
||||
func testHandshakeMessageSizeValidation() {
|
||||
// Valid handshake size
|
||||
let validHandshake = Data(repeating: 0x42, count: 500)
|
||||
XCTAssertTrue(NoiseSecurityValidator.validateHandshakeMessageSize(validHandshake))
|
||||
|
||||
// Too large for handshake (over 64KB)
|
||||
let largeHandshake = Data(repeating: 0x42, count: 65 * 1024)
|
||||
XCTAssertFalse(NoiseSecurityValidator.validateHandshakeMessageSize(largeHandshake))
|
||||
}
|
||||
|
||||
// MARK: - Channel Name Validation Tests
|
||||
|
||||
func testValidChannelNameAccepted() {
|
||||
XCTAssertTrue(NoiseSecurityValidator.validateChannelName("#general"))
|
||||
XCTAssertTrue(NoiseSecurityValidator.validateChannelName("#test-channel"))
|
||||
XCTAssertTrue(NoiseSecurityValidator.validateChannelName("#channel_123"))
|
||||
XCTAssertTrue(NoiseSecurityValidator.validateChannelName("#🎉party"))
|
||||
XCTAssertTrue(NoiseSecurityValidator.validateChannelName("#2024"))
|
||||
}
|
||||
|
||||
func testInvalidChannelNameRejected() {
|
||||
// Missing # prefix
|
||||
XCTAssertFalse(NoiseSecurityValidator.validateChannelName("general"))
|
||||
|
||||
// Empty or just #
|
||||
XCTAssertFalse(NoiseSecurityValidator.validateChannelName(""))
|
||||
XCTAssertFalse(NoiseSecurityValidator.validateChannelName("#"))
|
||||
|
||||
// Too long (over 50 chars)
|
||||
let longName = "#" + String(repeating: "a", count: 51)
|
||||
XCTAssertFalse(NoiseSecurityValidator.validateChannelName(longName))
|
||||
|
||||
// Invalid characters
|
||||
XCTAssertFalse(NoiseSecurityValidator.validateChannelName("#channel\nwith\nnewlines"))
|
||||
XCTAssertFalse(NoiseSecurityValidator.validateChannelName("#../../etc"))
|
||||
XCTAssertFalse(NoiseSecurityValidator.validateChannelName("#channel<script>"))
|
||||
}
|
||||
|
||||
// MARK: - Encryption Parameters Validation
|
||||
|
||||
func testValidateEncryptionNonce() {
|
||||
// Valid 12-byte nonce for ChaCha20
|
||||
let validNonce = Data(repeating: 0x42, count: 12)
|
||||
XCTAssertTrue(NoiseSecurityValidator.validateNonce(validNonce))
|
||||
|
||||
// Invalid sizes
|
||||
let shortNonce = Data(repeating: 0x42, count: 8)
|
||||
XCTAssertFalse(NoiseSecurityValidator.validateNonce(shortNonce))
|
||||
|
||||
let longNonce = Data(repeating: 0x42, count: 16)
|
||||
XCTAssertFalse(NoiseSecurityValidator.validateNonce(longNonce))
|
||||
|
||||
// Empty
|
||||
XCTAssertFalse(NoiseSecurityValidator.validateNonce(Data()))
|
||||
}
|
||||
|
||||
func testValidateKeyMaterial() {
|
||||
// Valid 32-byte key
|
||||
let validKey = Data(repeating: 0x42, count: 32)
|
||||
XCTAssertTrue(NoiseSecurityValidator.validateKeyMaterial(validKey))
|
||||
|
||||
// Invalid sizes
|
||||
XCTAssertFalse(NoiseSecurityValidator.validateKeyMaterial(Data(repeating: 0x42, count: 16)))
|
||||
XCTAssertFalse(NoiseSecurityValidator.validateKeyMaterial(Data(repeating: 0x42, count: 64)))
|
||||
XCTAssertFalse(NoiseSecurityValidator.validateKeyMaterial(Data()))
|
||||
}
|
||||
|
||||
// MARK: - Input Sanitization Tests
|
||||
|
||||
func testSanitizePeerID() {
|
||||
// Normal case
|
||||
XCTAssertEqual(NoiseSecurityValidator.sanitizePeerID("alice123"), "alice123")
|
||||
|
||||
// Remove control characters
|
||||
XCTAssertEqual(NoiseSecurityValidator.sanitizePeerID("alice\0bob"), "alicebob")
|
||||
XCTAssertEqual(NoiseSecurityValidator.sanitizePeerID("user\n\r\t"), "user")
|
||||
|
||||
// Truncate long IDs
|
||||
let longID = String(repeating: "a", count: 300)
|
||||
let sanitized = NoiseSecurityValidator.sanitizePeerID(longID)
|
||||
XCTAssertEqual(sanitized.count, 255)
|
||||
|
||||
// Empty becomes placeholder
|
||||
XCTAssertEqual(NoiseSecurityValidator.sanitizePeerID(""), "unknown")
|
||||
}
|
||||
|
||||
func testSanitizeChannelName() {
|
||||
// Normal case
|
||||
XCTAssertEqual(NoiseSecurityValidator.sanitizeChannelName("#general"), "#general")
|
||||
|
||||
// Add # prefix if missing
|
||||
XCTAssertEqual(NoiseSecurityValidator.sanitizeChannelName("general"), "#general")
|
||||
|
||||
// Remove invalid characters
|
||||
XCTAssertEqual(NoiseSecurityValidator.sanitizeChannelName("#test\nchannel"), "#testchannel")
|
||||
|
||||
// Truncate long names
|
||||
let longName = String(repeating: "a", count: 100)
|
||||
let sanitized = NoiseSecurityValidator.sanitizeChannelName(longName)
|
||||
XCTAssertTrue(sanitized.hasPrefix("#"))
|
||||
XCTAssertLessThanOrEqual(sanitized.count, 50)
|
||||
}
|
||||
|
||||
// MARK: - Security Pattern Detection Tests
|
||||
|
||||
func testDetectSuspiciousPatterns() {
|
||||
// Path traversal
|
||||
XCTAssertTrue(NoiseSecurityValidator.containsSuspiciousPattern("../../../etc/passwd"))
|
||||
XCTAssertTrue(NoiseSecurityValidator.containsSuspiciousPattern("..\\..\\windows\\system32"))
|
||||
|
||||
// Script injection
|
||||
XCTAssertTrue(NoiseSecurityValidator.containsSuspiciousPattern("<script>alert('xss')</script>"))
|
||||
XCTAssertTrue(NoiseSecurityValidator.containsSuspiciousPattern("javascript:void(0)"))
|
||||
|
||||
// SQL injection patterns
|
||||
XCTAssertTrue(NoiseSecurityValidator.containsSuspiciousPattern("'; DROP TABLE users; --"))
|
||||
XCTAssertTrue(NoiseSecurityValidator.containsSuspiciousPattern("1' OR '1'='1"))
|
||||
|
||||
// Normal text should pass
|
||||
XCTAssertFalse(NoiseSecurityValidator.containsSuspiciousPattern("Hello, this is a normal message!"))
|
||||
XCTAssertFalse(NoiseSecurityValidator.containsSuspiciousPattern("Meeting at 3:00 PM"))
|
||||
}
|
||||
}
|
||||
@@ -1,437 +0,0 @@
|
||||
//
|
||||
// PasswordProtectedChannelTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import CryptoKit
|
||||
import CommonCrypto
|
||||
@testable import bitchat
|
||||
|
||||
class PasswordProtectedChannelTests: XCTestCase {
|
||||
var viewModel: ChatViewModel!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
// Clear UserDefaults to ensure test isolation
|
||||
clearAllUserDefaults()
|
||||
|
||||
// Create a fresh view model for each test
|
||||
viewModel = ChatViewModel()
|
||||
|
||||
// Ensure clean state
|
||||
viewModel.passwordProtectedChannels.removeAll()
|
||||
viewModel.channelCreators.removeAll()
|
||||
viewModel.channelPasswords.removeAll()
|
||||
viewModel.channelKeys.removeAll()
|
||||
viewModel.joinedChannels.removeAll()
|
||||
viewModel.channelMembers.removeAll()
|
||||
viewModel.channelMessages.removeAll()
|
||||
}
|
||||
|
||||
private func clearAllUserDefaults() {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.removeObject(forKey: "bitchat_nickname")
|
||||
defaults.removeObject(forKey: "bitchat_joined_channels")
|
||||
defaults.removeObject(forKey: "bitchat_password_protected_channels")
|
||||
defaults.removeObject(forKey: "bitchat_channel_creators")
|
||||
defaults.removeObject(forKey: "bitchat_channel_passwords")
|
||||
defaults.removeObject(forKey: "bitchat_favorite_peers")
|
||||
defaults.synchronize()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Clean up after tests
|
||||
clearAllUserDefaults()
|
||||
viewModel = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Password Key Derivation Tests
|
||||
|
||||
func testPasswordKeyDerivation() {
|
||||
// Same password and channel should always produce same key
|
||||
let password = "secretPassword123"
|
||||
let channelName = "#testchannel"
|
||||
|
||||
let key1 = deriveChannelKey(from: password, channelName: channelName)
|
||||
let key2 = deriveChannelKey(from: password, channelName: channelName)
|
||||
|
||||
// Keys should be identical
|
||||
XCTAssertEqual(key1, key2, "Same password and channel should produce same key")
|
||||
}
|
||||
|
||||
func testDifferentPasswordsProduceDifferentKeys() {
|
||||
let channelName = "#testchannel"
|
||||
let password1 = "password123"
|
||||
let password2 = "different456"
|
||||
|
||||
let key1 = deriveChannelKey(from: password1, channelName: channelName)
|
||||
let key2 = deriveChannelKey(from: password2, channelName: channelName)
|
||||
|
||||
XCTAssertNotEqual(key1, key2, "Different passwords should produce different keys")
|
||||
}
|
||||
|
||||
func testDifferentChannelsProduceDifferentKeys() {
|
||||
let password = "samePassword"
|
||||
let channel1 = "#channel1"
|
||||
let channel2 = "#channel2"
|
||||
|
||||
let key1 = deriveChannelKey(from: password, channelName: channel1)
|
||||
let key2 = deriveChannelKey(from: password, channelName: channel2)
|
||||
|
||||
XCTAssertNotEqual(key1, key2, "Same password in different channels should produce different keys")
|
||||
}
|
||||
|
||||
// MARK: - Channel Creation and Joining Tests
|
||||
|
||||
func testJoinUnprotectedChannel() {
|
||||
let channelName = "#public"
|
||||
|
||||
let success = viewModel.joinChannel(channelName)
|
||||
|
||||
XCTAssertTrue(success, "Should be able to join unprotected channel")
|
||||
XCTAssertTrue(viewModel.joinedChannels.contains(channelName))
|
||||
XCTAssertEqual(viewModel.currentChannel, channelName)
|
||||
XCTAssertTrue(viewModel.channelMembers[channelName]?.contains(viewModel.meshService.myPeerID) ?? false)
|
||||
}
|
||||
|
||||
func testCreatePasswordProtectedChannel() {
|
||||
let channelName = "#private"
|
||||
let password = "secret123"
|
||||
|
||||
// Join channel first
|
||||
let joinSuccess = viewModel.joinChannel(channelName)
|
||||
XCTAssertTrue(joinSuccess)
|
||||
|
||||
// Set password
|
||||
viewModel.setChannelPassword(password, for: channelName)
|
||||
|
||||
XCTAssertTrue(viewModel.passwordProtectedChannels.contains(channelName))
|
||||
XCTAssertNotNil(viewModel.channelKeys[channelName])
|
||||
XCTAssertEqual(viewModel.channelPasswords[channelName], password)
|
||||
XCTAssertEqual(viewModel.channelCreators[channelName], viewModel.meshService.myPeerID)
|
||||
}
|
||||
|
||||
func testJoinPasswordProtectedEmptyChannel() {
|
||||
let channelName = "#protected"
|
||||
let password = "test123"
|
||||
|
||||
// Simulate channel being marked as password protected
|
||||
viewModel.passwordProtectedChannels.insert(channelName)
|
||||
|
||||
// Try to join with password - should be accepted tentatively for empty channel
|
||||
let success = viewModel.joinChannel(channelName, password: password)
|
||||
|
||||
XCTAssertTrue(success, "Should accept tentative access to empty password-protected channel")
|
||||
XCTAssertNotNil(viewModel.channelKeys[channelName], "Should store key tentatively")
|
||||
XCTAssertEqual(viewModel.channelPasswords[channelName], password, "Should store password tentatively")
|
||||
|
||||
// Should have a system message explaining tentative access
|
||||
let hasSystemMessage = viewModel.messages.contains { $0.sender == "system" && $0.content.contains("waiting for encrypted messages to verify password") }
|
||||
XCTAssertTrue(hasSystemMessage, "Should add system message explaining tentative access")
|
||||
}
|
||||
|
||||
func testJoinPasswordProtectedChannelWithMessages() {
|
||||
let channelName = "#secure"
|
||||
let correctPassword = "correct123"
|
||||
let wrongPassword = "wrong456"
|
||||
let testMessage = "Test encrypted message"
|
||||
|
||||
// First, create the channel and set password as creator
|
||||
let _ = viewModel.joinChannel(channelName)
|
||||
viewModel.setChannelPassword(correctPassword, for: channelName)
|
||||
|
||||
// Simulate an encrypted message in the channel
|
||||
let key = viewModel.channelKeys[channelName]!
|
||||
guard let messageData = testMessage.data(using: .utf8) else {
|
||||
XCTFail("Failed to convert message to data")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let sealedBox = try AES.GCM.seal(messageData, using: key)
|
||||
let encryptedData = sealedBox.combined!
|
||||
|
||||
let encryptedMsg = BitchatMessage(
|
||||
sender: "alice",
|
||||
content: "[Encrypted message - password required]",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: "alice123",
|
||||
mentions: nil,
|
||||
channel: channelName,
|
||||
encryptedContent: encryptedData,
|
||||
isEncrypted: true
|
||||
)
|
||||
|
||||
// Add to channel messages
|
||||
viewModel.channelMessages[channelName] = [encryptedMsg]
|
||||
|
||||
// Clear keys to simulate another user
|
||||
viewModel.channelKeys.removeValue(forKey: channelName)
|
||||
viewModel.channelPasswords.removeValue(forKey: channelName)
|
||||
|
||||
// Try to join with wrong password
|
||||
let wrongSuccess = viewModel.joinChannel(channelName, password: wrongPassword)
|
||||
XCTAssertFalse(wrongSuccess, "Should reject wrong password")
|
||||
|
||||
// Try to join with correct password
|
||||
let correctSuccess = viewModel.joinChannel(channelName, password: correctPassword)
|
||||
XCTAssertTrue(correctSuccess, "Should accept correct password")
|
||||
XCTAssertNotNil(viewModel.channelKeys[channelName], "Should store key for correct password")
|
||||
|
||||
} catch {
|
||||
XCTFail("Encryption failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Password Verification Tests
|
||||
|
||||
func testEncryptDecryptChannelMessage() {
|
||||
let channelName = "#crypto"
|
||||
let password = "cryptoKey"
|
||||
let testMessage = "This is a secret message"
|
||||
|
||||
// Derive key
|
||||
let key = deriveChannelKey(from: password, channelName: channelName)
|
||||
|
||||
// Encrypt
|
||||
guard let messageData = testMessage.data(using: .utf8) else {
|
||||
XCTFail("Failed to convert message to data")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let sealedBox = try AES.GCM.seal(messageData, using: key)
|
||||
let encryptedData = sealedBox.combined!
|
||||
|
||||
// Store key and decrypt
|
||||
viewModel.channelKeys[channelName] = key
|
||||
let decrypted = viewModel.decryptChannelMessage(encryptedData, channel: channelName)
|
||||
|
||||
XCTAssertEqual(decrypted, testMessage, "Decrypted message should match original")
|
||||
} catch {
|
||||
XCTFail("Encryption failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func testWrongPasswordFailsDecryption() {
|
||||
let channelName = "#secure"
|
||||
let correctPassword = "correct"
|
||||
let wrongPassword = "wrong"
|
||||
let testMessage = "Secret content"
|
||||
|
||||
// Encrypt with correct password
|
||||
let correctKey = deriveChannelKey(from: correctPassword, channelName: channelName)
|
||||
|
||||
guard let messageData = testMessage.data(using: .utf8) else {
|
||||
XCTFail("Failed to convert message to data")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let sealedBox = try AES.GCM.seal(messageData, using: correctKey)
|
||||
let encryptedData = sealedBox.combined!
|
||||
|
||||
// Try to decrypt with wrong password
|
||||
let wrongKey = deriveChannelKey(from: wrongPassword, channelName: channelName)
|
||||
let decrypted = viewModel.decryptChannelMessage(encryptedData, channel: channelName, testKey: wrongKey)
|
||||
|
||||
XCTAssertNil(decrypted, "Wrong password should fail to decrypt")
|
||||
} catch {
|
||||
XCTFail("Encryption failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Channel Creator Tests
|
||||
|
||||
func testOnlyCreatorCanSetPassword() {
|
||||
let channelName = "#owned"
|
||||
let password = "ownerOnly"
|
||||
|
||||
// Join channel (becomes creator)
|
||||
let _ = viewModel.joinChannel(channelName)
|
||||
|
||||
// Set password as creator
|
||||
viewModel.setChannelPassword(password, for: channelName)
|
||||
XCTAssertTrue(viewModel.passwordProtectedChannels.contains(channelName))
|
||||
|
||||
// Simulate another user trying to set password
|
||||
viewModel.channelCreators[channelName] = "otherUser123"
|
||||
viewModel.setChannelPassword("hackerPassword", for: channelName)
|
||||
|
||||
// Password should not change
|
||||
XCTAssertEqual(viewModel.channelPasswords[channelName], password, "Non-creator should not be able to change password")
|
||||
}
|
||||
|
||||
func testCreatorCanRemovePassword() {
|
||||
let channelName = "#changeable"
|
||||
let password = "temporary"
|
||||
|
||||
// Create protected channel
|
||||
let _ = viewModel.joinChannel(channelName)
|
||||
viewModel.setChannelPassword(password, for: channelName)
|
||||
|
||||
XCTAssertTrue(viewModel.passwordProtectedChannels.contains(channelName))
|
||||
|
||||
// Remove password
|
||||
viewModel.removeChannelPassword(for: channelName)
|
||||
|
||||
XCTAssertFalse(viewModel.passwordProtectedChannels.contains(channelName))
|
||||
XCTAssertNil(viewModel.channelKeys[channelName])
|
||||
XCTAssertNil(viewModel.channelPasswords[channelName])
|
||||
}
|
||||
|
||||
// MARK: - Message Handling Tests
|
||||
|
||||
func testReceiveEncryptedMessageWithoutKey() {
|
||||
let channelName = "#encrypted"
|
||||
|
||||
// Join channel without password
|
||||
let _ = viewModel.joinChannel(channelName)
|
||||
|
||||
// Simulate receiving encrypted message
|
||||
let encryptedMessage = BitchatMessage(
|
||||
sender: "alice",
|
||||
content: "[Encrypted message - password required]",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: "alice123",
|
||||
mentions: nil,
|
||||
channel: channelName,
|
||||
encryptedContent: Data([1, 2, 3, 4]), // dummy encrypted data
|
||||
isEncrypted: true
|
||||
)
|
||||
|
||||
viewModel.didReceiveMessage(encryptedMessage)
|
||||
|
||||
// Should mark channel as password protected
|
||||
XCTAssertTrue(viewModel.passwordProtectedChannels.contains(channelName))
|
||||
|
||||
// Should add system message
|
||||
let channelMessages = viewModel.channelMessages[channelName] ?? []
|
||||
let hasSystemMessage = channelMessages.contains { $0.sender == "system" && $0.content.contains("password protected") }
|
||||
XCTAssertTrue(hasSystemMessage, "Should add system message about password protection")
|
||||
}
|
||||
|
||||
// MARK: - Command Tests
|
||||
|
||||
func testJoinCommand() {
|
||||
let input = "/join #testchannel"
|
||||
viewModel.sendMessage(input)
|
||||
|
||||
XCTAssertTrue(viewModel.joinedChannels.contains("#testchannel"))
|
||||
XCTAssertEqual(viewModel.currentChannel, "#testchannel")
|
||||
}
|
||||
|
||||
func testJoinCommandAlias() {
|
||||
let input = "/j #quick"
|
||||
viewModel.sendMessage(input)
|
||||
|
||||
XCTAssertTrue(viewModel.joinedChannels.contains("#quick"))
|
||||
XCTAssertEqual(viewModel.currentChannel, "#quick")
|
||||
}
|
||||
|
||||
func testInvalidChannelName() {
|
||||
let input = "/j #invalid-channel!"
|
||||
viewModel.sendMessage(input)
|
||||
|
||||
XCTAssertFalse(viewModel.joinedChannels.contains("#invalid-channel!"))
|
||||
|
||||
// Should have system message about invalid name
|
||||
let hasErrorMessage = viewModel.messages.contains { $0.sender == "system" && $0.content.contains("invalid channel name") }
|
||||
XCTAssertTrue(hasErrorMessage)
|
||||
}
|
||||
|
||||
// MARK: - Key Commitment Tests
|
||||
|
||||
func testKeyCommitmentVerification() {
|
||||
let channelName = "#commitment"
|
||||
let password = "testpass123"
|
||||
|
||||
// Join and set password
|
||||
let _ = viewModel.joinChannel(channelName)
|
||||
viewModel.setChannelPassword(password, for: channelName)
|
||||
|
||||
// Verify key commitment was stored
|
||||
XCTAssertNotNil(viewModel.channelKeyCommitments[channelName], "Should store key commitment")
|
||||
|
||||
// Simulate another user with the stored commitment
|
||||
let commitment = viewModel.channelKeyCommitments[channelName]!
|
||||
viewModel.channelKeys.removeValue(forKey: channelName)
|
||||
viewModel.channelPasswords.removeValue(forKey: channelName)
|
||||
|
||||
// Manually set the commitment as if received from network
|
||||
viewModel.channelKeyCommitments[channelName] = commitment
|
||||
|
||||
// Try with wrong password - should fail immediately
|
||||
let wrongSuccess = viewModel.joinChannel(channelName, password: "wrongpass")
|
||||
XCTAssertFalse(wrongSuccess, "Should reject wrong password via commitment check")
|
||||
|
||||
// Try with correct password - should succeed
|
||||
let correctSuccess = viewModel.joinChannel(channelName, password: password)
|
||||
XCTAssertTrue(correctSuccess, "Should accept correct password via commitment check")
|
||||
}
|
||||
|
||||
func testOwnershipTransfer() {
|
||||
let channelName = "#transfertest"
|
||||
let password = "ownerpass"
|
||||
|
||||
// Create channel and set password
|
||||
let _ = viewModel.joinChannel(channelName)
|
||||
viewModel.setChannelPassword(password, for: channelName)
|
||||
|
||||
// Verify creator is set
|
||||
XCTAssertEqual(viewModel.channelCreators[channelName], viewModel.meshService.myPeerID)
|
||||
|
||||
// Simulate transfer (in real app would use /transfer command)
|
||||
let newOwnerID = "newowner123"
|
||||
viewModel.channelCreators[channelName] = newOwnerID
|
||||
|
||||
// Verify ownership changed
|
||||
XCTAssertEqual(viewModel.channelCreators[channelName], newOwnerID)
|
||||
XCTAssertNotEqual(viewModel.channelCreators[channelName], viewModel.meshService.myPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Extensions for Testing
|
||||
|
||||
extension PasswordProtectedChannelTests {
|
||||
// Helper method to derive channel key for testing
|
||||
// This duplicates the logic from ChatViewModel for testing purposes
|
||||
func deriveChannelKey(from password: String, channelName: String) -> SymmetricKey {
|
||||
let salt = channelName.data(using: .utf8)!
|
||||
let iterations = 100000
|
||||
let keyLength = 32
|
||||
|
||||
var derivedKey = Data(count: keyLength)
|
||||
let passwordData = password.data(using: .utf8)!
|
||||
|
||||
_ = derivedKey.withUnsafeMutableBytes { derivedKeyBytes in
|
||||
salt.withUnsafeBytes { saltBytes in
|
||||
passwordData.withUnsafeBytes { passwordBytes in
|
||||
CCKeyDerivationPBKDF(
|
||||
CCPBKDFAlgorithm(kCCPBKDF2),
|
||||
passwordBytes.baseAddress, passwordData.count,
|
||||
saltBytes.baseAddress, salt.count,
|
||||
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
|
||||
UInt32(iterations),
|
||||
derivedKeyBytes.baseAddress, keyLength
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SymmetricKey(data: derivedKey)
|
||||
}
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
//
|
||||
// ProtocolVersionNegotiationTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
class ProtocolVersionNegotiationTests: XCTestCase {
|
||||
|
||||
// MARK: - VersionHello Tests
|
||||
|
||||
func testVersionHelloEncodingDecoding() {
|
||||
let hello = VersionHello(
|
||||
supportedVersions: [1, 2, 3],
|
||||
preferredVersion: 3,
|
||||
clientVersion: "1.2.3",
|
||||
platform: "iOS",
|
||||
capabilities: ["noise", "compression"]
|
||||
)
|
||||
|
||||
// Encode
|
||||
guard let encoded = hello.encode() else {
|
||||
XCTFail("Failed to encode VersionHello")
|
||||
return
|
||||
}
|
||||
|
||||
// Decode
|
||||
guard let decoded = VersionHello.decode(from: encoded) else {
|
||||
XCTFail("Failed to decode VersionHello")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify
|
||||
XCTAssertEqual(decoded.supportedVersions, hello.supportedVersions)
|
||||
XCTAssertEqual(decoded.preferredVersion, hello.preferredVersion)
|
||||
XCTAssertEqual(decoded.clientVersion, hello.clientVersion)
|
||||
XCTAssertEqual(decoded.platform, hello.platform)
|
||||
XCTAssertEqual(decoded.capabilities, hello.capabilities)
|
||||
}
|
||||
|
||||
func testVersionHelloDefaults() {
|
||||
let hello = VersionHello(
|
||||
clientVersion: "1.0.0",
|
||||
platform: "macOS"
|
||||
)
|
||||
|
||||
XCTAssertEqual(hello.supportedVersions, Array(ProtocolVersion.supportedVersions))
|
||||
XCTAssertEqual(hello.preferredVersion, ProtocolVersion.current)
|
||||
XCTAssertNil(hello.capabilities)
|
||||
}
|
||||
|
||||
// MARK: - VersionAck Tests
|
||||
|
||||
func testVersionAckEncodingDecoding() {
|
||||
let ack = VersionAck(
|
||||
agreedVersion: 2,
|
||||
serverVersion: "1.1.0",
|
||||
platform: "iOS",
|
||||
capabilities: ["noise"],
|
||||
rejected: false,
|
||||
reason: nil
|
||||
)
|
||||
|
||||
// Encode
|
||||
guard let encoded = ack.encode() else {
|
||||
XCTFail("Failed to encode VersionAck")
|
||||
return
|
||||
}
|
||||
|
||||
// Decode
|
||||
guard let decoded = VersionAck.decode(from: encoded) else {
|
||||
XCTFail("Failed to decode VersionAck")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify
|
||||
XCTAssertEqual(decoded.agreedVersion, ack.agreedVersion)
|
||||
XCTAssertEqual(decoded.serverVersion, ack.serverVersion)
|
||||
XCTAssertEqual(decoded.platform, ack.platform)
|
||||
XCTAssertEqual(decoded.capabilities, ack.capabilities)
|
||||
XCTAssertEqual(decoded.rejected, ack.rejected)
|
||||
XCTAssertNil(decoded.reason)
|
||||
}
|
||||
|
||||
func testVersionAckRejection() {
|
||||
let ack = VersionAck(
|
||||
agreedVersion: 0,
|
||||
serverVersion: "2.0.0",
|
||||
platform: "macOS",
|
||||
rejected: true,
|
||||
reason: "No compatible version found"
|
||||
)
|
||||
|
||||
guard let encoded = ack.encode(),
|
||||
let decoded = VersionAck.decode(from: encoded) else {
|
||||
XCTFail("Failed to encode/decode rejection VersionAck")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertTrue(decoded.rejected)
|
||||
XCTAssertEqual(decoded.reason, "No compatible version found")
|
||||
XCTAssertEqual(decoded.agreedVersion, 0)
|
||||
}
|
||||
|
||||
// MARK: - ProtocolVersion Tests
|
||||
|
||||
func testIsSupported() {
|
||||
XCTAssertTrue(ProtocolVersion.isSupported(1))
|
||||
XCTAssertFalse(ProtocolVersion.isSupported(99))
|
||||
XCTAssertFalse(ProtocolVersion.isSupported(0))
|
||||
}
|
||||
|
||||
func testVersionNegotiation() {
|
||||
// Test successful negotiation
|
||||
let clientVersions: [UInt8] = [1, 2, 3]
|
||||
let serverVersions: [UInt8] = [1, 3, 4]
|
||||
|
||||
let agreed = ProtocolVersion.negotiateVersion(
|
||||
clientVersions: clientVersions,
|
||||
serverVersions: serverVersions
|
||||
)
|
||||
|
||||
XCTAssertEqual(agreed, 3) // Should pick highest common version
|
||||
}
|
||||
|
||||
func testVersionNegotiationNoCommon() {
|
||||
// Test no common version
|
||||
let clientVersions: [UInt8] = [2, 3]
|
||||
let serverVersions: [UInt8] = [4, 5]
|
||||
|
||||
let agreed = ProtocolVersion.negotiateVersion(
|
||||
clientVersions: clientVersions,
|
||||
serverVersions: serverVersions
|
||||
)
|
||||
|
||||
XCTAssertNil(agreed)
|
||||
}
|
||||
|
||||
func testVersionNegotiationSingleCommon() {
|
||||
// Test single common version
|
||||
let clientVersions: [UInt8] = [1]
|
||||
let serverVersions: [UInt8] = [1, 2, 3]
|
||||
|
||||
let agreed = ProtocolVersion.negotiateVersion(
|
||||
clientVersions: clientVersions,
|
||||
serverVersions: serverVersions
|
||||
)
|
||||
|
||||
XCTAssertEqual(agreed, 1)
|
||||
}
|
||||
|
||||
func testVersionNegotiationEmpty() {
|
||||
// Test empty version lists
|
||||
let agreed1 = ProtocolVersion.negotiateVersion(
|
||||
clientVersions: [],
|
||||
serverVersions: [1, 2]
|
||||
)
|
||||
XCTAssertNil(agreed1)
|
||||
|
||||
let agreed2 = ProtocolVersion.negotiateVersion(
|
||||
clientVersions: [1, 2],
|
||||
serverVersions: []
|
||||
)
|
||||
XCTAssertNil(agreed2)
|
||||
}
|
||||
|
||||
// MARK: - Binary Protocol Integration Tests
|
||||
|
||||
func testVersionHelloPacketEncoding() {
|
||||
let hello = VersionHello(
|
||||
clientVersion: "1.0.0",
|
||||
platform: "iOS"
|
||||
)
|
||||
|
||||
guard let helloData = hello.encode() else {
|
||||
XCTFail("Failed to encode VersionHello")
|
||||
return
|
||||
}
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.versionHello.rawValue,
|
||||
ttl: 1,
|
||||
senderID: "testpeer",
|
||||
payload: helloData
|
||||
)
|
||||
|
||||
guard let encoded = packet.toBinaryData() else {
|
||||
XCTFail("Failed to encode packet")
|
||||
return
|
||||
}
|
||||
|
||||
guard let decoded = BitchatPacket.from(encoded) else {
|
||||
XCTFail("Failed to decode packet")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(decoded.type, MessageType.versionHello.rawValue)
|
||||
XCTAssertEqual(decoded.ttl, 1)
|
||||
|
||||
// Verify payload can be decoded back to VersionHello
|
||||
guard let decodedHello = VersionHello.decode(from: decoded.payload) else {
|
||||
XCTFail("Failed to decode VersionHello from packet payload")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(decodedHello.clientVersion, "1.0.0")
|
||||
XCTAssertEqual(decodedHello.platform, "iOS")
|
||||
}
|
||||
|
||||
func testVersionAckPacketEncoding() {
|
||||
let ack = VersionAck(
|
||||
agreedVersion: 1,
|
||||
serverVersion: "1.0.0",
|
||||
platform: "macOS"
|
||||
)
|
||||
|
||||
guard let ackData = ack.encode() else {
|
||||
XCTFail("Failed to encode VersionAck")
|
||||
return
|
||||
}
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.versionAck.rawValue,
|
||||
senderID: Data("sender".utf8),
|
||||
recipientID: Data("recipient".utf8),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: ackData,
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
|
||||
guard let encoded = packet.toBinaryData() else {
|
||||
XCTFail("Failed to encode packet")
|
||||
return
|
||||
}
|
||||
|
||||
guard let decoded = BitchatPacket.from(encoded) else {
|
||||
XCTFail("Failed to decode packet")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(decoded.type, MessageType.versionAck.rawValue)
|
||||
|
||||
// Verify payload can be decoded back to VersionAck
|
||||
guard let decodedAck = VersionAck.decode(from: decoded.payload) else {
|
||||
XCTFail("Failed to decode VersionAck from packet payload")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(decodedAck.agreedVersion, 1)
|
||||
XCTAssertEqual(decodedAck.serverVersion, "1.0.0")
|
||||
XCTAssertEqual(decodedAck.platform, "macOS")
|
||||
}
|
||||
|
||||
// MARK: - Version State Management Tests
|
||||
|
||||
func testVersionNegotiationStateTransitions() {
|
||||
var state = VersionNegotiationState.none
|
||||
|
||||
// Test transition to helloSent
|
||||
state = .helloSent
|
||||
if case .helloSent = state {
|
||||
// Success
|
||||
} else {
|
||||
XCTFail("State should be helloSent")
|
||||
}
|
||||
|
||||
// Test transition to ackReceived
|
||||
state = .ackReceived(version: 2)
|
||||
if case .ackReceived(let version) = state {
|
||||
XCTAssertEqual(version, 2)
|
||||
} else {
|
||||
XCTFail("State should be ackReceived")
|
||||
}
|
||||
|
||||
// Test transition to failed
|
||||
state = .failed(reason: "Version mismatch")
|
||||
if case .failed(let reason) = state {
|
||||
XCTAssertEqual(reason, "Version mismatch")
|
||||
} else {
|
||||
XCTFail("State should be failed")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Edge Cases
|
||||
|
||||
func testLargeVersionNumbers() {
|
||||
let hello = VersionHello(
|
||||
supportedVersions: [1, 127, 255],
|
||||
preferredVersion: 255,
|
||||
clientVersion: "99.99.99",
|
||||
platform: "iOS"
|
||||
)
|
||||
|
||||
guard let encoded = hello.encode(),
|
||||
let decoded = VersionHello.decode(from: encoded) else {
|
||||
XCTFail("Failed to encode/decode with large version numbers")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(decoded.supportedVersions, [1, 127, 255])
|
||||
XCTAssertEqual(decoded.preferredVersion, 255)
|
||||
}
|
||||
|
||||
func testEmptyCapabilities() {
|
||||
let hello = VersionHello(
|
||||
clientVersion: "1.0.0",
|
||||
platform: "iOS",
|
||||
capabilities: []
|
||||
)
|
||||
|
||||
guard let encoded = hello.encode(),
|
||||
let decoded = VersionHello.decode(from: encoded) else {
|
||||
XCTFail("Failed to encode/decode with empty capabilities")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(decoded.capabilities, [])
|
||||
}
|
||||
|
||||
func testLongCapabilityStrings() {
|
||||
let longCapability = String(repeating: "a", count: 1000)
|
||||
let hello = VersionHello(
|
||||
clientVersion: "1.0.0",
|
||||
platform: "iOS",
|
||||
capabilities: [longCapability, "normal"]
|
||||
)
|
||||
|
||||
guard let encoded = hello.encode(),
|
||||
let decoded = VersionHello.decode(from: encoded) else {
|
||||
XCTFail("Failed to encode/decode with long capability strings")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(decoded.capabilities?.count, 2)
|
||||
XCTAssertEqual(decoded.capabilities?[0], longCapability)
|
||||
XCTAssertEqual(decoded.capabilities?[1], "normal")
|
||||
}
|
||||
|
||||
func testInvalidJSON() {
|
||||
let invalidData = Data("not json".utf8)
|
||||
|
||||
XCTAssertNil(VersionHello.decode(from: invalidData))
|
||||
XCTAssertNil(VersionAck.decode(from: invalidData))
|
||||
}
|
||||
|
||||
func testEmptyData() {
|
||||
let emptyData = Data()
|
||||
|
||||
XCTAssertNil(VersionHello.decode(from: emptyData))
|
||||
XCTAssertNil(VersionAck.decode(from: emptyData))
|
||||
}
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
//
|
||||
// SecureNoiseSessionTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import CryptoKit
|
||||
@testable import bitchat
|
||||
|
||||
class SecureNoiseSessionTests: XCTestCase {
|
||||
|
||||
// MARK: - Session Timeout Tests
|
||||
|
||||
func testSessionTimesOutAfter30Minutes() {
|
||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let session = SecureNoiseSession(peerID: "bob", role: .initiator, localStaticKey: aliceKey)
|
||||
|
||||
// Complete handshake
|
||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
var bob = NoiseHandshakeState(role: .responder, pattern: .XX, localStaticKey: bobKey)
|
||||
|
||||
// Perform handshake
|
||||
do {
|
||||
let msg1 = try session.startHandshake()
|
||||
_ = try bob.readMessage(msg1)
|
||||
|
||||
let msg2 = try bob.writeMessage()
|
||||
_ = try session.processHandshakeMessage(msg2)
|
||||
|
||||
let msg3 = try session.writeMessage()
|
||||
_ = try bob.readMessage(msg3)
|
||||
|
||||
XCTAssertTrue(session.isEstablished())
|
||||
|
||||
// Check initial state
|
||||
XCTAssertFalse(session.needsRenegotiation())
|
||||
|
||||
// Fast-forward time by setting lastActivity to 31 minutes ago
|
||||
let thirtyOneMinutesAgo = Date().addingTimeInterval(-31 * 60)
|
||||
session.setLastActivityTimeForTesting(thirtyOneMinutesAgo)
|
||||
|
||||
// Should now need renegotiation
|
||||
XCTAssertTrue(session.needsRenegotiation())
|
||||
|
||||
} catch {
|
||||
XCTFail("Handshake failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func testSessionRemainsValidUnder30Minutes() {
|
||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let session = SecureNoiseSession(peerID: "bob", role: .initiator, localStaticKey: aliceKey)
|
||||
|
||||
// Complete handshake
|
||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
var bob = NoiseHandshakeState(role: .responder, pattern: .XX, localStaticKey: bobKey)
|
||||
|
||||
do {
|
||||
let msg1 = try session.startHandshake()
|
||||
_ = try bob.readMessage(msg1)
|
||||
|
||||
let msg2 = try bob.writeMessage()
|
||||
_ = try session.processHandshakeMessage(msg2)
|
||||
|
||||
let msg3 = try session.writeMessage()
|
||||
_ = try bob.readMessage(msg3)
|
||||
|
||||
XCTAssertTrue(session.isEstablished())
|
||||
|
||||
// Set lastActivity to 29 minutes ago
|
||||
let twentyNineMinutesAgo = Date().addingTimeInterval(-29 * 60)
|
||||
session.setLastActivityTimeForTesting(twentyNineMinutesAgo)
|
||||
|
||||
// Should NOT need renegotiation
|
||||
XCTAssertFalse(session.needsRenegotiation())
|
||||
|
||||
} catch {
|
||||
XCTFail("Handshake failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Count Limit Tests
|
||||
|
||||
func testSessionNeedsRekeyAfterMessageLimit() {
|
||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
|
||||
let alice = SecureNoiseSession(peerID: "bob", role: .initiator, localStaticKey: aliceKey)
|
||||
let bob = SecureNoiseSession(peerID: "alice", role: .responder, localStaticKey: bobKey)
|
||||
|
||||
// Complete handshake
|
||||
do {
|
||||
let msg1 = try alice.startHandshake()
|
||||
_ = try bob.processHandshakeMessage(msg1)
|
||||
|
||||
let msg2 = try bob.writeMessage()
|
||||
_ = try alice.processHandshakeMessage(msg2)
|
||||
|
||||
let msg3 = try alice.writeMessage()
|
||||
_ = try bob.processHandshakeMessage(msg3)
|
||||
|
||||
XCTAssertTrue(alice.isEstablished())
|
||||
XCTAssertTrue(bob.isEstablished())
|
||||
|
||||
// Check initial state
|
||||
XCTAssertFalse(alice.needsRenegotiation())
|
||||
|
||||
// Set message count to just under 90% threshold (900,000)
|
||||
alice.setMessageCountForTesting(899_999)
|
||||
XCTAssertFalse(alice.needsRenegotiation())
|
||||
|
||||
// Set message count to 90% threshold
|
||||
alice.setMessageCountForTesting(900_000)
|
||||
XCTAssertTrue(alice.needsRenegotiation())
|
||||
|
||||
} catch {
|
||||
XCTFail("Handshake failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Activity Tracking Tests
|
||||
|
||||
func testActivityUpdatesOnEncryption() {
|
||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
|
||||
let alice = SecureNoiseSession(peerID: "bob", role: .initiator, localStaticKey: aliceKey)
|
||||
let bob = SecureNoiseSession(peerID: "alice", role: .responder, localStaticKey: bobKey)
|
||||
|
||||
// Complete handshake
|
||||
do {
|
||||
let msg1 = try alice.startHandshake()
|
||||
_ = try bob.processHandshakeMessage(msg1)
|
||||
|
||||
let msg2 = try bob.writeMessage()
|
||||
_ = try alice.processHandshakeMessage(msg2)
|
||||
|
||||
let msg3 = try alice.writeMessage()
|
||||
_ = try bob.processHandshakeMessage(msg3)
|
||||
|
||||
// Set lastActivity to 5 minutes ago
|
||||
let fiveMinutesAgo = Date().addingTimeInterval(-5 * 60)
|
||||
alice.setLastActivityTimeForTesting(fiveMinutesAgo)
|
||||
|
||||
// Encrypt a message
|
||||
let plaintext = Data("Hello Bob".utf8)
|
||||
_ = try alice.encrypt(plaintext)
|
||||
|
||||
// Activity should be updated to now
|
||||
let timeSinceUpdate = Date().timeIntervalSince(alice.lastActivityTime)
|
||||
XCTAssertLessThan(timeSinceUpdate, 1.0) // Should be within 1 second
|
||||
|
||||
} catch {
|
||||
XCTFail("Test failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func testActivityUpdatesOnDecryption() {
|
||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
|
||||
let alice = SecureNoiseSession(peerID: "bob", role: .initiator, localStaticKey: aliceKey)
|
||||
let bob = SecureNoiseSession(peerID: "alice", role: .responder, localStaticKey: bobKey)
|
||||
|
||||
// Complete handshake
|
||||
do {
|
||||
let msg1 = try alice.startHandshake()
|
||||
_ = try bob.processHandshakeMessage(msg1)
|
||||
|
||||
let msg2 = try bob.writeMessage()
|
||||
_ = try alice.processHandshakeMessage(msg2)
|
||||
|
||||
let msg3 = try alice.writeMessage()
|
||||
_ = try bob.processHandshakeMessage(msg3)
|
||||
|
||||
// Encrypt a message from Alice
|
||||
let plaintext = Data("Hello Bob".utf8)
|
||||
let ciphertext = try alice.encrypt(plaintext)
|
||||
|
||||
// Set Bob's lastActivity to 5 minutes ago
|
||||
let fiveMinutesAgo = Date().addingTimeInterval(-5 * 60)
|
||||
bob.setLastActivityTimeForTesting(fiveMinutesAgo)
|
||||
|
||||
// Decrypt the message
|
||||
_ = try bob.decrypt(ciphertext)
|
||||
|
||||
// Activity should be updated to now
|
||||
let timeSinceUpdate = Date().timeIntervalSince(bob.lastActivityTime)
|
||||
XCTAssertLessThan(timeSinceUpdate, 1.0) // Should be within 1 second
|
||||
|
||||
} catch {
|
||||
XCTFail("Test failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Count Tracking Tests
|
||||
|
||||
func testMessageCountIncrements() {
|
||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
|
||||
let alice = SecureNoiseSession(peerID: "bob", role: .initiator, localStaticKey: aliceKey)
|
||||
let bob = SecureNoiseSession(peerID: "alice", role: .responder, localStaticKey: bobKey)
|
||||
|
||||
// Complete handshake
|
||||
do {
|
||||
let msg1 = try alice.startHandshake()
|
||||
_ = try bob.processHandshakeMessage(msg1)
|
||||
|
||||
let msg2 = try bob.writeMessage()
|
||||
_ = try alice.processHandshakeMessage(msg2)
|
||||
|
||||
let msg3 = try alice.writeMessage()
|
||||
_ = try bob.processHandshakeMessage(msg3)
|
||||
|
||||
// Check initial message count
|
||||
XCTAssertEqual(alice.messageCount, 0)
|
||||
|
||||
// Send multiple messages
|
||||
for i in 1...5 {
|
||||
let plaintext = Data("Message \(i)".utf8)
|
||||
let ciphertext = try alice.encrypt(plaintext)
|
||||
_ = try bob.decrypt(ciphertext)
|
||||
}
|
||||
|
||||
// Check message count incremented
|
||||
XCTAssertEqual(alice.messageCount, 5) // Alice sent 5 messages
|
||||
XCTAssertEqual(bob.messageCount, 0) // Bob received but didn't send
|
||||
|
||||
} catch {
|
||||
XCTFail("Test failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Integration Tests
|
||||
|
||||
func testFullSessionLifecycle() {
|
||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
|
||||
let alice = SecureNoiseSession(peerID: "bob", role: .initiator, localStaticKey: aliceKey)
|
||||
let bob = SecureNoiseSession(peerID: "alice", role: .responder, localStaticKey: bobKey)
|
||||
|
||||
do {
|
||||
// 1. Perform handshake
|
||||
let msg1 = try alice.startHandshake()
|
||||
_ = try bob.processHandshakeMessage(msg1)
|
||||
|
||||
let msg2 = try bob.writeMessage()
|
||||
_ = try alice.processHandshakeMessage(msg2)
|
||||
|
||||
let msg3 = try alice.writeMessage()
|
||||
_ = try bob.processHandshakeMessage(msg3)
|
||||
|
||||
XCTAssertTrue(alice.isEstablished())
|
||||
XCTAssertTrue(bob.isEstablished())
|
||||
|
||||
// 2. Exchange messages
|
||||
let message1 = "Hello from Alice"
|
||||
let ciphertext1 = try alice.encrypt(Data(message1.utf8))
|
||||
let decrypted1 = try bob.decrypt(ciphertext1)
|
||||
XCTAssertEqual(String(data: decrypted1, encoding: .utf8), message1)
|
||||
|
||||
let message2 = "Hello from Bob"
|
||||
let ciphertext2 = try bob.encrypt(Data(message2.utf8))
|
||||
let decrypted2 = try alice.decrypt(ciphertext2)
|
||||
XCTAssertEqual(String(data: decrypted2, encoding: .utf8), message2)
|
||||
|
||||
// 3. Check session health
|
||||
XCTAssertFalse(alice.needsRenegotiation())
|
||||
XCTAssertFalse(bob.needsRenegotiation())
|
||||
|
||||
// 4. Simulate time passing
|
||||
let oldTime = Date().addingTimeInterval(-35 * 60)
|
||||
alice.setLastActivityTimeForTesting(oldTime)
|
||||
|
||||
// 5. Check renegotiation needed
|
||||
XCTAssertTrue(alice.needsRenegotiation())
|
||||
|
||||
} catch {
|
||||
XCTFail("Test failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
//
|
||||
// VersionNegotiationIntegrationTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import CoreBluetooth
|
||||
@testable import bitchat
|
||||
|
||||
class VersionNegotiationIntegrationTests: XCTestCase {
|
||||
|
||||
var meshService: BluetoothMeshService!
|
||||
var mockDelegate: MockBitchatDelegate!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
meshService = BluetoothMeshService()
|
||||
mockDelegate = MockBitchatDelegate()
|
||||
meshService.delegate = mockDelegate
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
meshService = nil
|
||||
mockDelegate = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Version Negotiation Flow Tests
|
||||
|
||||
func testVersionNegotiationSuccessFlow() {
|
||||
let peerID = "testpeer12345678"
|
||||
|
||||
// Simulate receiving version hello
|
||||
let hello = VersionHello(
|
||||
supportedVersions: [1],
|
||||
preferredVersion: 1,
|
||||
clientVersion: "1.0.0",
|
||||
platform: "iOS"
|
||||
)
|
||||
|
||||
guard let helloData = hello.encode() else {
|
||||
XCTFail("Failed to encode hello")
|
||||
return
|
||||
}
|
||||
|
||||
let helloPacket = BitchatPacket(
|
||||
type: MessageType.versionHello.rawValue,
|
||||
senderID: Data(hexString: peerID) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: helloData,
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
|
||||
// Process the hello packet
|
||||
meshService.handleReceivedPacket(helloPacket, from: peerID, peripheral: nil)
|
||||
|
||||
// Verify that version was negotiated
|
||||
// Note: We'd need to expose negotiatedVersions or add a getter to properly test this
|
||||
// For now, we're testing that the packet is processed without errors
|
||||
|
||||
// The service should have sent a version ack
|
||||
// In a real test, we'd mock the broadcast mechanism to verify this
|
||||
}
|
||||
|
||||
func testVersionNegotiationRejectionFlow() {
|
||||
let peerID = "incompatiblepeer"
|
||||
|
||||
// Simulate receiving version hello with incompatible version
|
||||
let hello = VersionHello(
|
||||
supportedVersions: [99, 100], // Unsupported versions
|
||||
preferredVersion: 100,
|
||||
clientVersion: "99.0.0",
|
||||
platform: "Unknown"
|
||||
)
|
||||
|
||||
guard let helloData = hello.encode() else {
|
||||
XCTFail("Failed to encode hello")
|
||||
return
|
||||
}
|
||||
|
||||
let helloPacket = BitchatPacket(
|
||||
type: MessageType.versionHello.rawValue,
|
||||
senderID: Data(hexString: peerID) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: helloData,
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
|
||||
// Process the hello packet
|
||||
meshService.handleReceivedPacket(helloPacket, from: peerID, peripheral: nil)
|
||||
|
||||
// The service should send a rejection ack
|
||||
// In a real implementation, we'd verify the rejection was sent
|
||||
}
|
||||
|
||||
func testBackwardCompatibilityWithLegacyPeer() {
|
||||
let peerID = "legacypeer123456"
|
||||
|
||||
// Simulate receiving a Noise handshake init without prior version negotiation
|
||||
let handshakePacket = BitchatPacket(
|
||||
type: MessageType.noiseHandshakeInit.rawValue,
|
||||
senderID: Data(hexString: peerID) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data("handshake_data".utf8),
|
||||
signature: nil,
|
||||
ttl: 3
|
||||
)
|
||||
|
||||
// Process the handshake packet
|
||||
meshService.handleReceivedPacket(handshakePacket, from: peerID, peripheral: nil)
|
||||
|
||||
// Should assume version 1 for backward compatibility
|
||||
// The handshake should proceed normally
|
||||
}
|
||||
|
||||
func testVersionAckHandling() {
|
||||
let peerID = "ackpeer12345678"
|
||||
|
||||
// Simulate receiving version ack
|
||||
let ack = VersionAck(
|
||||
agreedVersion: 1,
|
||||
serverVersion: "1.0.0",
|
||||
platform: "macOS"
|
||||
)
|
||||
|
||||
guard let ackData = ack.encode() else {
|
||||
XCTFail("Failed to encode ack")
|
||||
return
|
||||
}
|
||||
|
||||
let ackPacket = BitchatPacket(
|
||||
type: MessageType.versionAck.rawValue,
|
||||
senderID: Data(hexString: peerID) ?? Data(),
|
||||
recipientID: meshService.getMyPeerID().data(using: .utf8),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: ackData,
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
|
||||
// Process the ack packet
|
||||
meshService.handleReceivedPacket(ackPacket, from: peerID, peripheral: nil)
|
||||
|
||||
// Should update negotiated version and proceed with handshake
|
||||
}
|
||||
|
||||
func testVersionAckRejectionHandling() {
|
||||
let peerID = "rejectpeer123456"
|
||||
|
||||
// Simulate receiving rejection ack
|
||||
let ack = VersionAck(
|
||||
agreedVersion: 0,
|
||||
serverVersion: "2.0.0",
|
||||
platform: "iOS",
|
||||
rejected: true,
|
||||
reason: "No compatible protocol version"
|
||||
)
|
||||
|
||||
guard let ackData = ack.encode() else {
|
||||
XCTFail("Failed to encode rejection ack")
|
||||
return
|
||||
}
|
||||
|
||||
let ackPacket = BitchatPacket(
|
||||
type: MessageType.versionAck.rawValue,
|
||||
senderID: Data(hexString: peerID) ?? Data(),
|
||||
recipientID: meshService.getMyPeerID().data(using: .utf8),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: ackData,
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
|
||||
// Process the rejection ack
|
||||
meshService.handleReceivedPacket(ackPacket, from: peerID, peripheral: nil)
|
||||
|
||||
// Should mark negotiation as failed
|
||||
}
|
||||
|
||||
// MARK: - State Management Tests
|
||||
|
||||
func testVersionStateCleanupOnDisconnect() {
|
||||
let peerID = "disconnectpeer12"
|
||||
|
||||
// First establish some version negotiation state
|
||||
let hello = VersionHello(
|
||||
clientVersion: "1.0.0",
|
||||
platform: "iOS"
|
||||
)
|
||||
|
||||
guard let helloData = hello.encode() else {
|
||||
XCTFail("Failed to encode hello")
|
||||
return
|
||||
}
|
||||
|
||||
let helloPacket = BitchatPacket(
|
||||
type: MessageType.versionHello.rawValue,
|
||||
senderID: Data(hexString: peerID) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: helloData,
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
|
||||
// Process hello to establish state
|
||||
meshService.handleReceivedPacket(helloPacket, from: peerID, peripheral: nil)
|
||||
|
||||
// Simulate disconnect
|
||||
// In real implementation, we'd trigger the disconnect logic
|
||||
// and verify state is cleaned up
|
||||
}
|
||||
|
||||
// MARK: - Error Handling Tests
|
||||
|
||||
func testMalformedVersionHello() {
|
||||
let peerID = "malformedpeer123"
|
||||
|
||||
// Send malformed data
|
||||
let malformedPacket = BitchatPacket(
|
||||
type: MessageType.versionHello.rawValue,
|
||||
senderID: Data(hexString: peerID) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data("not valid json".utf8),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
|
||||
// Should handle gracefully without crashing
|
||||
meshService.handleReceivedPacket(malformedPacket, from: peerID, peripheral: nil)
|
||||
}
|
||||
|
||||
func testMalformedVersionAck() {
|
||||
let peerID = "malformedackpeer"
|
||||
|
||||
// Send malformed ack data
|
||||
let malformedPacket = BitchatPacket(
|
||||
type: MessageType.versionAck.rawValue,
|
||||
senderID: Data(hexString: peerID) ?? Data(),
|
||||
recipientID: meshService.getMyPeerID().data(using: .utf8),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data("{invalid json}".utf8),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
|
||||
// Should handle gracefully without crashing
|
||||
meshService.handleReceivedPacket(malformedPacket, from: peerID, peripheral: nil)
|
||||
}
|
||||
|
||||
// MARK: - Performance Tests
|
||||
|
||||
func testVersionNegotiationPerformance() {
|
||||
measure {
|
||||
// Test encoding/decoding performance
|
||||
for i in 0..<1000 {
|
||||
let hello = VersionHello(
|
||||
supportedVersions: [1, 2, 3],
|
||||
preferredVersion: 3,
|
||||
clientVersion: "1.\(i).0",
|
||||
platform: "iOS",
|
||||
capabilities: ["cap1", "cap2", "cap3"]
|
||||
)
|
||||
|
||||
if let data = hello.encode(),
|
||||
let _ = VersionHello.decode(from: data) {
|
||||
// Success
|
||||
} else {
|
||||
XCTFail("Failed at iteration \(i)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mock Delegate
|
||||
|
||||
class MockBitchatDelegate: BitchatDelegate {
|
||||
var receivedMessages: [BitchatMessage] = []
|
||||
var connectedPeers: [String] = []
|
||||
var disconnectedPeers: [String] = []
|
||||
|
||||
func didReceiveMessage(_ message: BitchatMessage) {
|
||||
receivedMessages.append(message)
|
||||
}
|
||||
|
||||
func didConnectToPeer(_ peerID: String) {
|
||||
connectedPeers.append(peerID)
|
||||
}
|
||||
|
||||
func didDisconnectFromPeer(_ peerID: String) {
|
||||
disconnectedPeers.append(peerID)
|
||||
}
|
||||
|
||||
func didUpdatePeerList(_ peers: [String]) {
|
||||
// Not used in these tests
|
||||
}
|
||||
|
||||
func didReceiveChannelLeave(_ channel: String, from peerID: String) {
|
||||
// Not used in these tests
|
||||
}
|
||||
|
||||
func didReceivePasswordProtectedChannelAnnouncement(_ channel: String, isProtected: Bool, creatorID: String?, keyCommitment: String?) {
|
||||
// Not used in these tests
|
||||
}
|
||||
|
||||
func didReceiveChannelRetentionAnnouncement(_ channel: String, enabled: Bool, creatorID: String?) {
|
||||
// Not used in these tests
|
||||
}
|
||||
|
||||
func decryptChannelMessage(_ encryptedContent: Data, channel: String) -> String? {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
//
|
||||
// VersionNegotiationScenarioTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
class VersionNegotiationScenarioTests: XCTestCase {
|
||||
|
||||
// MARK: - Real-World Scenarios
|
||||
|
||||
func testOldClientConnectsToNewServer() {
|
||||
// Scenario: Old client (v1 only) connects to new server (v1, v2, v3)
|
||||
let oldClientVersions: [UInt8] = [1]
|
||||
let newServerVersions: [UInt8] = [1, 2, 3]
|
||||
|
||||
let agreed = ProtocolVersion.negotiateVersion(
|
||||
clientVersions: oldClientVersions,
|
||||
serverVersions: newServerVersions
|
||||
)
|
||||
|
||||
XCTAssertEqual(agreed, 1, "Should agree on v1 for backward compatibility")
|
||||
}
|
||||
|
||||
func testNewClientConnectsToOldServer() {
|
||||
// Scenario: New client (v1, v2, v3) connects to old server (v1 only)
|
||||
let newClientVersions: [UInt8] = [1, 2, 3]
|
||||
let oldServerVersions: [UInt8] = [1]
|
||||
|
||||
let agreed = ProtocolVersion.negotiateVersion(
|
||||
clientVersions: newClientVersions,
|
||||
serverVersions: oldServerVersions
|
||||
)
|
||||
|
||||
XCTAssertEqual(agreed, 1, "Should agree on v1 for backward compatibility")
|
||||
}
|
||||
|
||||
func testMixedVersionNetwork() {
|
||||
// Scenario: Network with mixed client versions
|
||||
let clients: [[UInt8]] = [
|
||||
[1], // Old client
|
||||
[1, 2], // Mid-version client
|
||||
[1, 2, 3] // New client
|
||||
]
|
||||
|
||||
// All should be able to negotiate with each other
|
||||
for (i, client1) in clients.enumerated() {
|
||||
for (j, client2) in clients.enumerated() {
|
||||
let agreed = ProtocolVersion.negotiateVersion(
|
||||
clientVersions: client1,
|
||||
serverVersions: client2
|
||||
)
|
||||
|
||||
XCTAssertNotNil(agreed, "Clients \(i) and \(j) should negotiate successfully")
|
||||
XCTAssertGreaterThanOrEqual(agreed ?? 0, 1, "Should at least agree on v1")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testFutureClientWithUnsupportedVersion() {
|
||||
// Scenario: Future client with only unsupported versions
|
||||
let futureClientVersions: [UInt8] = [10, 11, 12]
|
||||
let currentServerVersions: [UInt8] = Array(ProtocolVersion.supportedVersions)
|
||||
|
||||
let agreed = ProtocolVersion.negotiateVersion(
|
||||
clientVersions: futureClientVersions,
|
||||
serverVersions: currentServerVersions
|
||||
)
|
||||
|
||||
XCTAssertNil(agreed, "Should fail to negotiate with incompatible future client")
|
||||
}
|
||||
|
||||
// MARK: - Race Condition Tests
|
||||
|
||||
func testSimultaneousVersionHello() {
|
||||
// Scenario: Both peers send version hello at the same time
|
||||
// This tests that the protocol handles simultaneous negotiation
|
||||
|
||||
let hello1 = VersionHello(
|
||||
supportedVersions: [1, 2],
|
||||
preferredVersion: 2,
|
||||
clientVersion: "1.1.0",
|
||||
platform: "iOS"
|
||||
)
|
||||
|
||||
let hello2 = VersionHello(
|
||||
supportedVersions: [1, 2, 3],
|
||||
preferredVersion: 3,
|
||||
clientVersion: "1.2.0",
|
||||
platform: "macOS"
|
||||
)
|
||||
|
||||
// Both should be able to encode/decode regardless of order
|
||||
XCTAssertNotNil(hello1.encode())
|
||||
XCTAssertNotNil(hello2.encode())
|
||||
|
||||
// Version negotiation should be deterministic
|
||||
let agreed1 = ProtocolVersion.negotiateVersion(
|
||||
clientVersions: hello1.supportedVersions,
|
||||
serverVersions: hello2.supportedVersions
|
||||
)
|
||||
|
||||
let agreed2 = ProtocolVersion.negotiateVersion(
|
||||
clientVersions: hello2.supportedVersions,
|
||||
serverVersions: hello1.supportedVersions
|
||||
)
|
||||
|
||||
XCTAssertEqual(agreed1, agreed2, "Negotiation should be symmetric")
|
||||
XCTAssertEqual(agreed1, 2, "Should agree on highest common version")
|
||||
}
|
||||
|
||||
// MARK: - Error Recovery Tests
|
||||
|
||||
func testRecoveryFromFailedNegotiation() {
|
||||
// Test that a peer can retry after failed negotiation
|
||||
var state = VersionNegotiationState.failed(reason: "Network error")
|
||||
|
||||
// Reset state for retry
|
||||
state = .none
|
||||
|
||||
// Should be able to start new negotiation
|
||||
state = .helloSent
|
||||
|
||||
if case .helloSent = state {
|
||||
// Success - can retry after failure
|
||||
} else {
|
||||
XCTFail("Should be able to retry after failed negotiation")
|
||||
}
|
||||
}
|
||||
|
||||
func testPartialMessageHandling() {
|
||||
// Test handling of truncated version messages
|
||||
let truncatedData = Data([123, 34]) // Partial JSON
|
||||
|
||||
XCTAssertNil(VersionHello.decode(from: truncatedData))
|
||||
XCTAssertNil(VersionAck.decode(from: truncatedData))
|
||||
|
||||
// Should not crash, just return nil
|
||||
}
|
||||
|
||||
// MARK: - Platform Compatibility Tests
|
||||
|
||||
func testCrossPlatformNegotiation() {
|
||||
let platforms = ["iOS", "macOS", "iPadOS", "Unknown"]
|
||||
|
||||
for platform1 in platforms {
|
||||
for platform2 in platforms {
|
||||
let hello1 = VersionHello(
|
||||
clientVersion: "1.0.0",
|
||||
platform: platform1
|
||||
)
|
||||
|
||||
let hello2 = VersionHello(
|
||||
clientVersion: "1.0.0",
|
||||
platform: platform2
|
||||
)
|
||||
|
||||
// All platforms should be able to negotiate
|
||||
XCTAssertNotNil(hello1.encode())
|
||||
XCTAssertNotNil(hello2.encode())
|
||||
|
||||
// Platform difference should not affect version negotiation
|
||||
let agreed = ProtocolVersion.negotiateVersion(
|
||||
clientVersions: hello1.supportedVersions,
|
||||
serverVersions: hello2.supportedVersions
|
||||
)
|
||||
|
||||
XCTAssertNotNil(agreed, "\(platform1) and \(platform2) should negotiate")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Capability Tests
|
||||
|
||||
func testCapabilityNegotiation() {
|
||||
// Test future capability negotiation
|
||||
let clientCapabilities = ["noise", "compression", "multipath"]
|
||||
let serverCapabilities = ["noise", "compression", "federation"]
|
||||
|
||||
_ = VersionHello(
|
||||
clientVersion: "1.0.0",
|
||||
platform: "iOS",
|
||||
capabilities: clientCapabilities
|
||||
)
|
||||
|
||||
_ = VersionAck(
|
||||
agreedVersion: 1,
|
||||
serverVersion: "1.0.0",
|
||||
platform: "macOS",
|
||||
capabilities: serverCapabilities
|
||||
)
|
||||
|
||||
// Find common capabilities (for future use)
|
||||
let commonCapabilities = Set(clientCapabilities).intersection(Set(serverCapabilities))
|
||||
XCTAssertEqual(commonCapabilities, ["noise", "compression"])
|
||||
}
|
||||
|
||||
func testEmptyCapabilityHandling() {
|
||||
// Test peers with no capabilities
|
||||
let hello1 = VersionHello(
|
||||
clientVersion: "1.0.0",
|
||||
platform: "iOS",
|
||||
capabilities: nil
|
||||
)
|
||||
|
||||
let hello2 = VersionHello(
|
||||
clientVersion: "1.0.0",
|
||||
platform: "macOS",
|
||||
capabilities: []
|
||||
)
|
||||
|
||||
XCTAssertNotNil(hello1.encode())
|
||||
XCTAssertNotNil(hello2.encode())
|
||||
|
||||
// Should still negotiate successfully
|
||||
let agreed = ProtocolVersion.negotiateVersion(
|
||||
clientVersions: hello1.supportedVersions,
|
||||
serverVersions: hello2.supportedVersions
|
||||
)
|
||||
|
||||
XCTAssertNotNil(agreed)
|
||||
}
|
||||
|
||||
// MARK: - Stress Tests
|
||||
|
||||
func testManyVersionsNegotiation() {
|
||||
// Test with many supported versions
|
||||
let manyVersions = Array<UInt8>(1...100)
|
||||
let someVersions = Array<UInt8>([1, 50, 75, 100])
|
||||
|
||||
let agreed = ProtocolVersion.negotiateVersion(
|
||||
clientVersions: manyVersions,
|
||||
serverVersions: someVersions
|
||||
)
|
||||
|
||||
XCTAssertEqual(agreed, 100, "Should pick highest common version")
|
||||
}
|
||||
|
||||
func testRapidConnectionDisconnection() {
|
||||
// Test rapid connect/disconnect cycles
|
||||
var states: [String: VersionNegotiationState] = [:]
|
||||
|
||||
for i in 0..<100 {
|
||||
let peerID = "peer\(i)"
|
||||
|
||||
// Connect
|
||||
states[peerID] = .helloSent
|
||||
|
||||
// Negotiate
|
||||
states[peerID] = .ackReceived(version: 1)
|
||||
|
||||
// Disconnect
|
||||
states.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
XCTAssertTrue(states.isEmpty, "All states should be cleaned up")
|
||||
}
|
||||
|
||||
// MARK: - Security Tests
|
||||
|
||||
func testLargeVersionListDoS() {
|
||||
// Test protection against DoS with huge version lists
|
||||
let hugeVersionList = Array<UInt8>(0...255) // All possible versions
|
||||
|
||||
let hello = VersionHello(
|
||||
supportedVersions: hugeVersionList,
|
||||
preferredVersion: 255,
|
||||
clientVersion: "1.0.0",
|
||||
platform: "iOS"
|
||||
)
|
||||
|
||||
// Should handle without performance issues
|
||||
let startTime = Date()
|
||||
_ = hello.encode()
|
||||
let encodingTime = Date().timeIntervalSince(startTime)
|
||||
|
||||
XCTAssertLessThan(encodingTime, 0.1, "Encoding should be fast even with large version list")
|
||||
}
|
||||
|
||||
func testVersionDowngradeAttack() {
|
||||
// Test that negotiation always picks highest common version
|
||||
// to prevent downgrade attacks
|
||||
let clientVersions: [UInt8] = [1, 2, 3]
|
||||
let serverVersions: [UInt8] = [1, 2, 3]
|
||||
|
||||
let agreed = ProtocolVersion.negotiateVersion(
|
||||
clientVersions: clientVersions,
|
||||
serverVersions: serverVersions
|
||||
)
|
||||
|
||||
XCTAssertEqual(agreed, 3, "Should not allow downgrade to lower version")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user