mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 03:25:19 +00:00
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.
507 lines
19 KiB
Swift
507 lines
19 KiB
Swift
//
|
|
// 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)")
|
|
}
|
|
}
|
|
} |