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:
jack
2025-07-15 13:15:31 +02:00
parent 6d39222ea0
commit 3070a4d307
42 changed files with 10952 additions and 2233 deletions
+41
View File
@@ -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)
}
}
+203
View File
@@ -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()
}
}
+185
View File
@@ -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)
}
}
+140
View File
@@ -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()
}
}
}
+386
View File
@@ -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
}
+369
View File
@@ -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))
}
}
+206
View File
@@ -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"))
}
}
+507
View File
@@ -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"))
}
}
+287
View File
@@ -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)")
}
}
}