bitchat/bitchatTests/Integration/IntegrationTests.swift
jack 9c84d4ce4f
Fail closed on unverifiable handshake peer IDs + check SecRandomCopyBytes results (#1645)
Two hardening fixes from the repo evaluation:

- NoiseSessionManager.authenticatedRemoteKey returned true for peer IDs
  that are neither 16-hex wire IDs nor full Noise-key IDs — an
  accept-any-key fallback kept for test harnesses. It now fails closed;
  the Noise/integration/E2E tests that relied on it address peers by
  key-derived wire IDs instead (the pattern NoiseCoverageTests already
  used), and a new regression test pins the rejection.

- Four SecRandomCopyBytes call sites discarded the return status. The
  two verification nonces now fail their operation on error, the Nostr
  device identity seed uses CryptoKit key generation (cannot fail, and
  can no longer silently persist an all-zero seed), and BIP-340 aux
  randomness throws on failure like the adjacent nonce path.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:12:27 +02:00

531 lines
21 KiB
Swift

//
// IntegrationTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import CryptoKit
import Testing
@testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat
@Suite("Integration Tests", .serialized)
struct IntegrationTests {
private var helper = TestNetworkHelper()
init() {
helper.createNode("Alice")
helper.createNode("Bob")
helper.createNode("Charlie")
helper.createNode("David")
}
// MARK: - Multi-Peer Scenarios
@Test func fullMeshCommunication() async throws {
helper.connectFullMesh()
var messageMatrix: [String: Set<String>] = [:]
for (senderName, _) in helper.nodes { messageMatrix[senderName] = [] }
for (receiverName, receiver) in helper.nodes {
receiver.messageDeliveryHandler = { message in
let parts = message.content.components(separatedBy: " ")
if let last = parts.last, message.content.contains("Hello from") {
if receiverName != last {
messageMatrix[last]?.insert(receiverName)
}
}
}
}
for (name, node) in helper.nodes {
node.sendMessage("Hello from \(name)")
}
// Each sender should have reached all other nodes
for (sender, receivers) in messageMatrix {
let expectedReceivers = Set(helper.nodes.keys.filter { $0 != sender })
#expect(receivers == expectedReceivers, "\(sender) didn't reach all nodes")
}
}
@Test func dynamicTopologyChanges() async throws {
// Start with Alice -> Bob -> Charlie
helper.connect("Alice", "Bob")
helper.connect("Bob", "Charlie")
try await confirmation("Topology changes handled") { receiveMessage in
var phase = 1
helper.nodes["Charlie"]!.messageDeliveryHandler = { message in
if phase == 1 && message.sender == "Alice" {
// Now change topology: disconnect Bob, connect Alice-Charlie
helper.disconnect("Alice", "Bob")
helper.disconnect("Bob", "Charlie")
helper.connect("Alice", "Charlie")
phase = 2
// Send another message
helper.nodes["Alice"]!.sendMessage("Direct message")
} else if phase == 2 && message.content == "Direct message" {
receiveMessage()
}
}
// Allow relay handler to be set before first send
try await sleep(0.05)
helper.nodes["Alice"]!.sendMessage("Relayed message")
}
}
@Test func networkPartitionRecovery() async throws {
// Create two partitions
helper.connect("Alice", "Bob")
helper.connect("Charlie", "David")
let messagesBeforeMerge = 0
var messagesAfterMerge = 0
try await confirmation("Partitions merge and communicate") { receiveMessage in
// Monitor cross-partition messages
helper.nodes["David"]!.messageDeliveryHandler = { message in
if message.sender == "Alice" {
messagesAfterMerge += 1
if messagesAfterMerge == 1 {
receiveMessage()
}
}
}
// Try to send across partition (should fail)
helper.nodes["Alice"]!.sendMessage("Before merge")
// Merge partitions after delay
try await sleep(0.05)
// Connect partitions
helper.connect("Bob", "Charlie")
// Enable relay
helper.setupRelay("Bob", nextHops: ["Charlie"])
helper.setupRelay("Charlie", nextHops: ["David"])
// Send message across merged network
helper.nodes["Alice"]!.sendMessage("After merge")
}
#expect(messagesBeforeMerge == 0)
#expect(messagesAfterMerge == 1)
}
// MARK: - Mixed Message Type Scenarios
@Test func mixedPublicPrivateMessages() async throws {
helper.connectFullMesh()
var publicCount = 0
var privateCount = 0
await confirmation("Mixed messages handled correctly") { completion in
// Bob monitors messages
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
if message.isPrivate && message.recipientNickname == "Bob" {
privateCount += 1
} else if !message.isPrivate {
publicCount += 1
}
if publicCount == 2 && privateCount == 1 {
completion()
}
}
// Alice sends mixed messages
helper.nodes["Alice"]!.sendMessage("Public 1")
helper.nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
helper.nodes["Alice"]!.sendMessage("Public 2")
}
#expect(publicCount == 2)
#expect(privateCount == 1)
}
@Test func encryptedAndUnencryptedMix() async throws {
helper.connect("Alice", "Bob")
// Setup Noise session
try helper.establishNoiseSession("Alice", "Bob")
var plainCount = 0
var encryptedCount = 0
try await confirmation("Both encrypted and plain messages work") { completion in
// Plain path: send public message and count at Bob
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
if message.content == "Plain message" {
plainCount += 1
}
if plainCount == 1 && encryptedCount == 1 {
completion()
}
}
// Encrypted path: use NoiseSessionManager explicitly
let plaintext = Data("Encrypted message".utf8)
let ciphertext = try helper.noiseManagers["Alice"]!.encrypt(plaintext, for: helper.nodes["Bob"]!.peerID)
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
if packet.type == MessageType.noiseEncrypted.rawValue {
if let data = try? helper.noiseManagers["Bob"]!.decrypt(ciphertext, from: helper.nodes["Alice"]!.peerID),
data == plaintext {
encryptedCount = 1
if plainCount == 1 {
completion()
}
}
}
}
helper.nodes["Alice"]!.sendMessage("Plain message")
// Deliver encrypted packet directly
let encPacket = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
helper.nodes["Bob"]!.simulateIncomingPacket(encPacket)
}
}
// MARK: - Network Resilience Tests
@Test func messageDeliveryUnderChurn() async throws {
// Start with stable network
helper.connectFullMesh()
let totalMessages = 10
try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in
// David tracks received messages
helper.nodes["David"]!.messageDeliveryHandler = { _ in
completion()
}
// Send messages while churning network
for i in 0..<totalMessages {
helper.nodes["Alice"]!.sendMessage("Message \(i)")
// Simulate churn
if i % 3 == 0 {
// Disconnect and reconnect random connection
let pairs = [("Alice", "Bob"), ("Bob", "Charlie"), ("Charlie", "David")]
let randomPair = pairs.randomElement()!
helper.disconnect(randomPair.0, randomPair.1)
try await sleep(0.01)
helper.connect(randomPair.0, randomPair.1)
}
}
}
}
@Test func peerPresenceTrackingAndReconnection() async throws {
helper.connect("Alice", "Bob")
await confirmation("Delivery after reconnection") { delivered in
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
if message.content == "After reconnect" {
delivered()
}
}
// Simulate disconnect (out of range)
helper.disconnect("Alice", "Bob")
// Reconnect
helper.connect("Alice", "Bob")
// Send after reconnection
helper.nodes["Alice"]!.sendMessage("After reconnect")
}
}
@Test func encryptedMessageAfterPeerRestart() async throws {
helper.connect("Alice", "Bob")
do {
try helper.establishNoiseSession("Alice", "Bob")
} catch {
Issue.record("Failed to establish Noise session: \(error)")
}
// Exchange an encrypted message
await confirmation("First message received") { received in
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
if message.content == "Before restart" && message.isPrivate {
received()
}
}
helper.nodes["Alice"]!.sendPrivateMessage("Before restart", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
}
// Simulate Bob restart by recreating his Noise manager. A new static
// key means a new key-derived wire ID, just like production.
let bobKey = Curve25519.KeyAgreement.PrivateKey()
helper.noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: helper.mockKeychain)
helper.nodes["Bob"]!.myPeerID = PeerID(publicKey: bobKey.publicKey.rawRepresentation)
// Re-establish Noise handshake explicitly via managers
do {
let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID)
let m2 = try #require(
try helper.noiseManagers["Alice"]!.handleIncomingHandshake(
from: helper.nodes["Bob"]!.peerID,
message: m1
)
)
let m3 = try #require(
try helper.noiseManagers["Bob"]!.handleIncomingHandshake(
from: helper.nodes["Alice"]!.peerID,
message: m2
)
)
_ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3)
} catch {
Issue.record("Failed to re-establish Noise session after restart: \(error)")
}
// Now messages should work again - simulate encrypted packet
await confirmation("Message after restart received") { received in
helper.nodes["Alice"]!.messageDeliveryHandler = { message in
if message.content == "After restart success" && message.isPrivate {
received()
}
}
do {
let plaintext = Data("After restart success".utf8)
let ciphertext = try helper.noiseManagers["Bob"]!.encrypt(plaintext, for: helper.nodes["Alice"]!.peerID)
let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
helper.nodes["Alice"]!.packetDeliveryHandler = { pkt in
if pkt.type == MessageType.noiseEncrypted.rawValue {
if let data = try? helper.noiseManagers["Alice"]!.decrypt(pkt.payload, from: helper.nodes["Bob"]!.peerID),
String(data: data, encoding: .utf8) == "After restart success" {
received()
}
}
}
helper.nodes["Alice"]!.simulateIncomingPacket(packet)
} catch {
Issue.record("Encryption after restart failed: \(error)")
}
}
}
@Test func largeScaleNetwork() async throws {
// Create larger network
for i in 5...10 {
helper.createNode("Node\(i)")
}
// Connect in ring topology with cross-connections
let allNodes = Array(helper.nodes.keys).sorted()
for i in 0..<allNodes.count {
// Ring connection
helper.connect(allNodes[i], allNodes[(i + 1) % allNodes.count])
// Cross connection
if i + 3 < allNodes.count {
helper.connect(allNodes[i], allNodes[i + 3])
}
}
await confirmation("Large network handles broadcast", expectedCount: helper.nodes.count - 1) { nodeReaced in
// All nodes except Alice listen
for (name, node) in helper.nodes where name != "Alice" {
node.messageDeliveryHandler = { message in
if message.content == "Broadcast test" {
nodeReaced()
}
}
}
// Alice broadcasts
helper.nodes["Alice"]!.sendMessage("Broadcast test")
}
}
// MARK: - Stress Tests
@Test func highLoadScenario() async throws {
helper.connectFullMesh()
let messagesPerNode = 25
let expectedTotal = messagesPerNode * helper.nodes.count * (helper.nodes.count - 1)
await confirmation("High load handled", expectedCount: expectedTotal) { received in
// Each node tracks messages
for (_, node) in helper.nodes {
node.messageDeliveryHandler = { _ in
received()
}
}
// All nodes send many messages simultaneously
await withTaskGroup(of: Void.self) { group in
for (name, node) in helper.nodes {
group.addTask {
for i in 0..<messagesPerNode {
node.sendMessage("\(name) message \(i)")
}
}
}
await group.waitForAll()
}
}
}
@Test func mixedTrafficPatterns() async throws {
helper.connectFullMesh()
var metrics = [
"public": 0,
"private": 0,
"mentions": 0,
"relayed": 0
]
// Setup complex handlers
for (name, node) in helper.nodes {
node.messageDeliveryHandler = { message in
if message.isPrivate {
metrics["private"]! += 1
} else {
metrics["public"]! += 1
}
if message.mentions?.contains(name) ?? false {
metrics["mentions"]! += 1
}
if message.isRelay {
metrics["relayed"]! += 1
}
}
}
// Generate mixed traffic
helper.nodes["Alice"]!.sendMessage("Public broadcast")
helper.nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
helper.nodes["Bob"]!.sendMessage("Mentioning @Charlie", mentions: ["Charlie"])
// Disconnect to force relay
helper.disconnect("Alice", "David")
helper.nodes["Alice"]!.sendMessage("Needs relay to David")
#expect(metrics["public", default: 0] > 0)
#expect(metrics["private", default: 0] > 0)
#expect(metrics["mentions", default: 0] > 0)
}
// MARK: - Security Integration Tests
// Replacement for the legacy NACK test: verifies that after a
// decryption failure, peers can rehandshake via NoiseSessionManager
// and resume secure communication.
@Test func rehandshakeAfterDecryptionFailure() throws {
// Alice <-> Bob connected
helper.connect("Alice", "Bob")
// Establish initial Noise session
try helper.establishNoiseSession("Alice", "Bob")
guard let aliceManager = helper.noiseManagers["Alice"],
let bobManager = helper.noiseManagers["Bob"],
let alicePeerID = helper.nodes["Alice"]?.peerID,
let bobPeerID = helper.nodes["Bob"]?.peerID
else {
Issue.record("Missing managers or peer IDs")
return
}
// Baseline: encrypt from Alice, decrypt at Bob
let plaintext1 = Data("hello-secure".utf8)
let encrypted1 = try aliceManager.encrypt(plaintext1, for: bobPeerID)
let decrypted1 = try bobManager.decrypt(encrypted1, from: alicePeerID)
#expect(decrypted1 == plaintext1)
// Simulate decryption failure by corrupting ciphertext
let corrupted = encrypted1.prefix(15)
#expect(throws: NoiseError.invalidCiphertext) {
_ = try bobManager.decrypt(corrupted, from: alicePeerID)
}
// Bob initiates a new handshake; clear Bob's session first so initiateHandshake won't throw
bobManager.removeSession(for: alicePeerID)
try helper.establishNoiseSession("Bob", "Alice")
// After rehandshake, encryption/decryption works again
let plaintext2 = Data("hello-again".utf8)
let encrypted2 = try aliceManager.encrypt(plaintext2, for: bobPeerID)
let decrypted2 = try bobManager.decrypt(encrypted2, from: alicePeerID)
#expect(decrypted2 == plaintext2)
}
@Test func endToEndSecurityScenario() async throws {
helper.connect("Alice", "Bob")
helper.connect("Bob", "Charlie") // Charlie will try to eavesdrop
// Establish secure session between Alice and Bob only
try helper.establishNoiseSession("Alice", "Bob")
await confirmation("Secure communication maintained", expectedCount: 2) { receivedPacket in
// Setup encryption at Alice
helper.nodes["Alice"]!.packetDeliveryHandler = { packet in
if packet.type == 0x01,
let message = BitchatMessage(packet.payload),
message.isPrivate && packet.recipientID != nil {
// Encrypt private messages
if let encrypted = try? helper.noiseManagers["Alice"]!.encrypt(packet.payload, for: helper.nodes["Bob"]!.peerID) {
let encPacket = BitchatPacket(
type: 0x02,
senderID: packet.senderID,
recipientID: packet.recipientID,
timestamp: packet.timestamp,
payload: encrypted,
signature: packet.signature,
ttl: packet.ttl
)
helper.nodes["Bob"]!.simulateIncomingPacket(encPacket)
}
}
}
// Bob can decrypt
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
if packet.type == 0x02 {
receivedPacket()
if let decrypted = try? helper.noiseManagers["Bob"]!.decrypt(packet.payload, from: helper.nodes["Alice"]!.peerID) {
#expect(BitchatMessage(decrypted)?.content == "Secret message")
} else {
Issue.record("Bob was unable to decrypt the message")
}
// Relay encrypted packet to Charlie
helper.nodes["Charlie"]!.simulateIncomingPacket(packet)
}
}
// Charlie cannot decrypt
helper.nodes["Charlie"]!.packetDeliveryHandler = { packet in
if packet.type == 0x02 {
receivedPacket()
#expect(throws: NoiseSessionError.sessionNotFound, "Charlie should not be able to decrypt") {
_ = try helper.noiseManagers["Charlie"]?.decrypt(packet.payload, from: helper.nodes["Alice"]!.peerID)
}
}
}
// Send encrypted private message
helper.nodes["Alice"]!.sendPrivateMessage("Secret message", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
}
}
}