mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 02:05:19 +00:00
Implement Noise Protocol Framework and peer ID rotation for enhanced security and privacy
This major update replaces the basic encryption with the Noise Protocol Framework and adds ephemeral peer ID rotation for enhanced privacy. Key Changes: Security Infrastructure: - Implemented Noise Protocol Framework (XX handshake pattern) - End-to-end encryption with forward secrecy and identity hiding - Session management with automatic rekey support - Channel encryption with password-derived keys Privacy Enhancements: - Ephemeral peer ID rotation (5-15 minute random intervals) - Persistent identity through public key fingerprints - Favorites and verification persist across ID rotations - Block list based on fingerprints, not ephemeral IDs Core Components Added: - NoiseEncryptionService: Main encryption service - NoiseSession: Individual peer session management - NoiseChannelEncryption: Password-protected channel support - SecureIdentityStateManager: Persistent identity storage - FingerprintView: Visual fingerprint verification UI Bug Fixes: - Fixed handshake storm with tie-breaker mechanism - Fixed missing connect messages during peer rotation - Fixed delivery ACK compression issues - Fixed race conditions in message queue - Fixed nickname resolution for rotated peer IDs Testing: - Comprehensive test suite for Noise implementation - Security validator tests - Channel encryption tests - Identity persistence tests - Rate limiter tests Documentation: - BRING_THE_NOISE.md: Technical implementation details - Updated WHITEPAPER.md: Simplified and focused on core innovations - Removed temporary debug documentation The implementation maintains backward compatibility while significantly improving security and privacy. All existing features (channels, private messages, favorites, blocking) work seamlessly with the new system.
This commit is contained in:
@@ -198,4 +198,45 @@ class BitchatMessageTests: XCTestCase {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//
|
||||
// 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,
|
||||
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?
|
||||
|
||||
override func sendChannelKeyVerifyResponse(_ response: ChannelKeyVerifyResponse, to peerID: String) {
|
||||
sentVerifyResponse = true
|
||||
lastVerifyResponse = response
|
||||
}
|
||||
|
||||
override func getNoiseService() -> NoiseEncryptionService {
|
||||
// Return actual noise service - tests should use real crypto
|
||||
return super.getNoiseService()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//
|
||||
// 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()
|
||||
_ = MessageRetentionService.shared
|
||||
|
||||
// Check UserDefaults for any sensitive data
|
||||
let keysToCheck = [
|
||||
"bitchat.noiseIdentityKey",
|
||||
"bitchat.messageRetentionKey",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
@@ -104,4 +104,144 @@ class MessagePaddingTests: XCTestCase {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//
|
||||
// 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
func testMessageRetentionKeyPersistence() {
|
||||
// Create first instance
|
||||
_ = MessageRetentionService.shared
|
||||
|
||||
// Get key from keychain
|
||||
let keyData1 = KeychainManager.shared.getIdentityKey(forKey: "messageRetentionKey")
|
||||
XCTAssertNotNil(keyData1, "Message retention key should be stored")
|
||||
|
||||
// Simulate app restart by clearing the singleton
|
||||
// (In real app, this would be a new process)
|
||||
|
||||
// Get key again
|
||||
let keyData2 = KeychainManager.shared.getIdentityKey(forKey: "messageRetentionKey")
|
||||
XCTAssertEqual(keyData1, keyData2, "Message retention key should persist")
|
||||
}
|
||||
|
||||
func testMessageRetentionKeyNotInUserDefaults() {
|
||||
// Ensure service is initialized
|
||||
_ = MessageRetentionService.shared
|
||||
|
||||
// Verify key is NOT in UserDefaults
|
||||
let userDefaultsData = UserDefaults.standard.data(forKey: "bitchat.messageRetentionKey")
|
||||
XCTAssertNil(userDefaultsData, "Message retention key should NOT be in UserDefaults")
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Post-Quantum Framework Tests
|
||||
|
||||
class NoisePostQuantumTests: XCTestCase {
|
||||
|
||||
func testHybridKeyGeneration() throws {
|
||||
let (publicKey, privateKey) = try HybridNoiseKeyExchange.generateKeyPair(algorithm: .classicalOnly)
|
||||
|
||||
XCTAssertNotNil(publicKey.classical)
|
||||
XCTAssertNil(publicKey.postQuantum) // No PQ component yet
|
||||
XCTAssertEqual(publicKey.serialized.count, 32) // Just Curve25519
|
||||
|
||||
XCTAssertNotNil(privateKey.classical)
|
||||
XCTAssertNil(privateKey.postQuantum)
|
||||
}
|
||||
|
||||
func testHybridKeyAgreement() throws {
|
||||
// Generate two keypairs
|
||||
let (alicePub, alicePriv) = try HybridNoiseKeyExchange.generateKeyPair(algorithm: .classicalOnly)
|
||||
let (bobPub, bobPriv) = try HybridNoiseKeyExchange.generateKeyPair(algorithm: .classicalOnly)
|
||||
|
||||
// Perform key agreement
|
||||
let aliceShared = try HybridNoiseKeyExchange.performKeyAgreement(
|
||||
localPrivate: alicePriv,
|
||||
remotePublic: bobPub,
|
||||
algorithm: .classicalOnly
|
||||
)
|
||||
|
||||
let bobShared = try HybridNoiseKeyExchange.performKeyAgreement(
|
||||
localPrivate: bobPriv,
|
||||
remotePublic: alicePub,
|
||||
algorithm: .classicalOnly
|
||||
)
|
||||
|
||||
// Shared secrets should match
|
||||
XCTAssertEqual(
|
||||
aliceShared.combinedSecret().withUnsafeBytes { Data($0) },
|
||||
bobShared.combinedSecret().withUnsafeBytes { Data($0) }
|
||||
)
|
||||
}
|
||||
|
||||
func testMigrationConfig() {
|
||||
let config = NoiseProtocolMigration.getMigrationConfig()
|
||||
|
||||
XCTAssertEqual(config.currentPhase, .classicalOnly)
|
||||
XCTAssertEqual(config.preferredAlgorithm, .classicalOnly)
|
||||
XCTAssertTrue(config.acceptedAlgorithms.contains(.classicalOnly))
|
||||
XCTAssertNil(config.migrationDeadline)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func testMockPostQuantum() throws {
|
||||
// Test mock PQ implementation
|
||||
let (publicKey, privateKey) = try MockPostQuantumKeyExchange.generateKeyPair()
|
||||
|
||||
XCTAssertEqual(publicKey.count, MockPostQuantumKeyExchange.publicKeySize)
|
||||
XCTAssertEqual(privateKey.count, 32) // Mock uses smaller private key
|
||||
|
||||
let (sharedSecret, ciphertext) = try MockPostQuantumKeyExchange.encapsulate(
|
||||
remotePublicKey: publicKey
|
||||
)
|
||||
|
||||
XCTAssertEqual(ciphertext.count, MockPostQuantumKeyExchange.ciphertextSize)
|
||||
XCTAssertEqual(sharedSecret.count, MockPostQuantumKeyExchange.sharedSecretSize)
|
||||
|
||||
let decapsulatedSecret = try MockPostQuantumKeyExchange.decapsulate(
|
||||
ciphertext: ciphertext,
|
||||
privateKey: privateKey
|
||||
)
|
||||
|
||||
// In real implementation, these would match
|
||||
// Mock just returns deterministic values
|
||||
XCTAssertEqual(decapsulatedSecret.count, 32)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
//
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//
|
||||
// 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"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
//
|
||||
// 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//
|
||||
// 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"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//
|
||||
// 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user