Files
bitchat/bitchatTests/Services/BLENoisePacketHandlerTests.swift
T

1288 lines
47 KiB
Swift

import BitFoundation
import CryptoKit
import Foundation
import Testing
@testable import bitchat
struct BLENoisePacketHandlerTests {
private struct TestError: Error {}
private final class Recorder {
var handshakeResult: Result<Data?, Error> = .success(nil)
var hasSession = false
let sessionGeneration = UUID()
var awaitingResponderHandshake = false
var decryptResult: Result<Data, Error> = .success(Data())
var currentDate = Date(timeIntervalSince1970: 1_000)
var transportGenerationReady = false
var forcedServiceDecryptError: Error?
var processedHandshakes: [(peerID: PeerID, message: Data)] = []
var hasSessionQueries: [PeerID] = []
var initiatedHandshakes: [PeerID] = []
var broadcastPackets: [BitchatPacket] = []
var lastSeenUpdates: [PeerID] = []
var decryptCalls: [(payload: Data, peerID: PeerID)] = []
var clearedSessions: [PeerID] = []
var authenticatedPeerStates: [(peerID: PeerID, payload: Data, generation: UUID)] = []
var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = []
/// Ordered side-effect log to assert recovery sequencing.
var events: [String] = []
}
private let localPeerID = PeerID(str: "0102030405060708")
private let remotePeerID = PeerID(str: "1122334455667788")
private let localPeerIDData = Data(hexString: "0102030405060708") ?? Data()
private func makeHandler(
recorder: Recorder,
now: Date = Date(timeIntervalSince1970: 1_000)
) -> BLENoisePacketHandler {
recorder.currentDate = now
let environment = BLENoisePacketHandlerEnvironment(
localPeerID: { [localPeerID] in localPeerID },
localPeerIDData: { [localPeerIDData] in localPeerIDData },
messageTTL: TransportConfig.messageTTLDefault,
now: { recorder.currentDate },
processHandshakeMessage: { peerID, message in
recorder.processedHandshakes.append((peerID, message))
return try recorder.handshakeResult.get()
},
hasNoiseSession: { peerID in
recorder.hasSessionQueries.append(peerID)
return recorder.hasSession
},
isAwaitingResponderHandshakeCompletion: { _ in
recorder.awaitingResponderHandshake
},
initiateHandshake: { peerID in
recorder.initiatedHandshakes.append(peerID)
recorder.events.append("initiateHandshake")
},
broadcastPacket: { packet in
recorder.broadcastPackets.append(packet)
},
updatePeerLastSeen: { peerID in
recorder.lastSeenUpdates.append(peerID)
},
decrypt: { payload, peerID in
recorder.decryptCalls.append((payload, peerID))
return BLENoiseDecryptionResult(
plaintext: try recorder.decryptResult.get(),
sessionGeneration: recorder.sessionGeneration
)
},
clearSession: { peerID in
recorder.clearedSessions.append(peerID)
recorder.events.append("clearSession")
},
handleAuthenticatedPeerState: { peerID, payload, generation in
recorder.authenticatedPeerStates.append((peerID, payload, generation))
},
deliverNoisePayload: { peerID, type, payload, timestamp in
recorder.deliveries.append((peerID, type, payload, timestamp))
}
)
return BLENoisePacketHandler(environment: environment)
}
private func makeServiceBackedHandler(
service: NoiseEncryptionService,
localPeerID: PeerID,
recorder: Recorder,
transportGenerationIsReady:
@escaping (UUID) -> Bool
) -> BLENoisePacketHandler {
BLENoisePacketHandler(
environment: BLENoisePacketHandlerEnvironment(
localPeerID: { localPeerID },
localPeerIDData: {
Data(hexString: localPeerID.id) ?? Data()
},
messageTTL: TransportConfig.messageTTLDefault,
now: { recorder.currentDate },
processHandshakeMessage: { peerID, message in
try service.processHandshakeMessage(
from: peerID,
message: message
)
},
hasNoiseSession: { peerID in
service.hasSession(with: peerID)
},
isAwaitingResponderHandshakeCompletion: { peerID in
service.isAwaitingResponderHandshakeCompletion(
with: peerID
)
},
initiateHandshake: { peerID in
recorder.initiatedHandshakes.append(peerID)
},
broadcastPacket: { packet in
recorder.broadcastPackets.append(packet)
},
updatePeerLastSeen: { peerID in
recorder.lastSeenUpdates.append(peerID)
},
decrypt: { payload, peerID in
recorder.decryptCalls.append((payload, peerID))
if let error = recorder.forcedServiceDecryptError {
throw error
}
let result =
try service.decryptWithSessionGeneration(
payload,
from: peerID,
establishedGenerationIsReady:
transportGenerationIsReady
)
return BLENoiseDecryptionResult(
plaintext: result.plaintext,
sessionGeneration: result.sessionGeneration
)
},
clearSession: { peerID in
recorder.clearedSessions.append(peerID)
service.clearSession(for: peerID)
},
handleAuthenticatedPeerState: {
peerID, payload, generation in
recorder.authenticatedPeerStates.append(
(peerID, payload, generation)
)
},
deliverNoisePayload: {
peerID, type, payload, timestamp in
recorder.deliveries.append(
(peerID, type, payload, timestamp)
)
}
)
)
}
private func establishedServices() throws -> (
sender: NoiseEncryptionService,
receiver: NoiseEncryptionService,
senderPeerID: PeerID,
receiverPeerID: PeerID
) {
let sender = NoiseEncryptionService(keychain: MockKeychain())
let receiver = NoiseEncryptionService(keychain: MockKeychain())
let senderPeerID = PeerID(
publicKey: sender.getStaticPublicKeyData()
)
let receiverPeerID = PeerID(
publicKey: receiver.getStaticPublicKeyData()
)
let message1 = try sender.initiateHandshake(with: receiverPeerID)
let message2 = try #require(
try receiver.processHandshakeMessage(
from: senderPeerID,
message: message1
)
)
let message3 = try #require(
try sender.processHandshakeMessage(
from: receiverPeerID,
message: message2
)
)
_ = try receiver.processHandshakeMessage(
from: senderPeerID,
message: message3
)
return (
sender,
receiver,
senderPeerID,
receiverPeerID
)
}
// MARK: Handshake
@Test
func handshakeForUsBroadcastsResponsePacket() {
let now = Date(timeIntervalSince1970: 1_000)
let recorder = Recorder()
recorder.handshakeResult = .success(Data([0xAA, 0xBB]))
let handler = makeHandler(recorder: recorder, now: now)
let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id))
handler.handleHandshake(packet, from: remotePeerID)
#expect(recorder.processedHandshakes.count == 1)
#expect(recorder.processedHandshakes.first?.peerID == remotePeerID)
#expect(recorder.processedHandshakes.first?.message == packet.payload)
#expect(recorder.broadcastPackets.count == 1)
let response = recorder.broadcastPackets.first
#expect(response?.type == MessageType.noiseHandshake.rawValue)
#expect(response?.senderID == localPeerIDData)
#expect(response?.recipientID == Data(hexString: remotePeerID.id))
#expect(response?.payload == Data([0xAA, 0xBB]))
#expect(response?.signature == nil)
#expect(response?.ttl == TransportConfig.messageTTLDefault)
#expect(response?.timestamp == UInt64(now.timeIntervalSince1970 * 1000))
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func handshakeWithoutResponseDoesNotBroadcast() {
let recorder = Recorder()
recorder.handshakeResult = .success(nil)
let handler = makeHandler(recorder: recorder)
let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id))
handler.handleHandshake(packet, from: remotePeerID)
#expect(recorder.processedHandshakes.count == 1)
#expect(recorder.broadcastPackets.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func handshakeForAnotherPeerIsIgnored() {
let recorder = Recorder()
let handler = makeHandler(recorder: recorder)
let packet = makeHandshakePacket(recipientID: Data(hexString: remotePeerID.id))
handler.handleHandshake(packet, from: remotePeerID)
#expect(recorder.processedHandshakes.isEmpty)
#expect(recorder.broadcastPackets.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func handshakeFailureInitiatesNewHandshakeWhenNoSession() {
let recorder = Recorder()
recorder.handshakeResult = .failure(TestError())
recorder.hasSession = false
let handler = makeHandler(recorder: recorder)
let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id))
handler.handleHandshake(packet, from: remotePeerID)
#expect(recorder.hasSessionQueries == [remotePeerID])
#expect(recorder.initiatedHandshakes == [remotePeerID])
#expect(recorder.broadcastPackets.isEmpty)
}
@Test
func handshakeFailureSkipsInitiateWhenSessionExists() {
let recorder = Recorder()
recorder.handshakeResult = .failure(TestError())
recorder.hasSession = true
let handler = makeHandler(recorder: recorder)
let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id))
handler.handleHandshake(packet, from: remotePeerID)
#expect(recorder.hasSessionQueries == [remotePeerID])
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func peerIdentityMismatchDoesNotRecreateHandshakeState() {
let recorder = Recorder()
recorder.handshakeResult = .failure(NoiseSessionError.peerIdentityMismatch)
recorder.hasSession = false
let handler = makeHandler(recorder: recorder)
let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id))
#expect(!handler.handleHandshake(packet, from: remotePeerID))
#expect(recorder.hasSessionQueries.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
#expect(recorder.broadcastPackets.isEmpty)
}
@Test
func managedHandshakeFailureDoesNotStartASecondRecovery() {
let recorder = Recorder()
recorder.handshakeResult = .failure(
NoiseManagedHandshakeFailure(underlying: TestError())
)
recorder.hasSession = false
let handler = makeHandler(recorder: recorder)
let packet = makeHandshakePacket(
recipientID: Data(hexString: localPeerID.id)
)
#expect(!handler.handleHandshake(packet, from: remotePeerID))
#expect(recorder.hasSessionQueries.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
#expect(recorder.broadcastPackets.isEmpty)
}
// MARK: Encrypted
@Test
func encryptedWithoutRecipientIsDropped() {
let recorder = Recorder()
let handler = makeHandler(recorder: recorder)
let packet = makeEncryptedPacket(recipientID: nil)
handler.handleEncrypted(packet, from: remotePeerID)
#expect(recorder.lastSeenUpdates.isEmpty)
#expect(recorder.decryptCalls.isEmpty)
#expect(recorder.deliveries.isEmpty)
}
@Test
func encryptedForAnotherPeerIsDropped() {
let recorder = Recorder()
let handler = makeHandler(recorder: recorder)
let packet = makeEncryptedPacket(recipientID: Data(hexString: remotePeerID.id))
handler.handleEncrypted(packet, from: remotePeerID)
#expect(recorder.lastSeenUpdates.isEmpty)
#expect(recorder.decryptCalls.isEmpty)
#expect(recorder.deliveries.isEmpty)
}
@Test
func decryptedPayloadIsDeliveredWithTypeAndTimestamp() {
let now = Date(timeIntervalSince1970: 1_000)
let recorder = Recorder()
recorder.decryptResult = .success(Data([NoisePayloadType.privateMessage.rawValue, 0x01, 0x02, 0x03]))
let handler = makeHandler(recorder: recorder, now: now)
let sentAt = Date(timeIntervalSince1970: 900)
let packet = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
timestamp: UInt64(sentAt.timeIntervalSince1970 * 1000)
)
handler.handleEncrypted(packet, from: remotePeerID)
#expect(recorder.lastSeenUpdates == [remotePeerID])
#expect(recorder.decryptCalls.count == 1)
#expect(recorder.decryptCalls.first?.payload == packet.payload)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.peerID == remotePeerID)
#expect(recorder.deliveries.first?.type == .privateMessage)
#expect(recorder.deliveries.first?.payload == Data([0x01, 0x02, 0x03]))
#expect(recorder.deliveries.first?.timestamp == sentAt)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func authenticatedPeerStateIsConsumedByTransportNotDeliveredToUI() {
let recorder = Recorder()
recorder.decryptResult = .success(Data([
NoisePayloadType.authenticatedPeerState.rawValue,
0x01, 0x02, 0x03
]))
let handler = makeHandler(recorder: recorder)
let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id))
handler.handleEncrypted(packet, from: remotePeerID)
#expect(recorder.authenticatedPeerStates.count == 1)
#expect(recorder.authenticatedPeerStates.first?.peerID == remotePeerID)
#expect(recorder.authenticatedPeerStates.first?.payload == Data([0x01, 0x02, 0x03]))
#expect(recorder.authenticatedPeerStates.first?.generation == recorder.sessionGeneration)
#expect(recorder.deliveries.isEmpty)
}
@Test
func emptyDecryptedPayloadIsIgnored() {
let recorder = Recorder()
recorder.decryptResult = .success(Data())
let handler = makeHandler(recorder: recorder)
let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id))
handler.handleEncrypted(packet, from: remotePeerID)
#expect(recorder.decryptCalls.count == 1)
#expect(recorder.deliveries.isEmpty)
#expect(recorder.clearedSessions.isEmpty)
}
@Test
func unknownNoisePayloadTypeIsIgnored() {
let recorder = Recorder()
recorder.decryptResult = .success(Data([0xEE, 0x01]))
let handler = makeHandler(recorder: recorder)
let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id))
handler.handleEncrypted(packet, from: remotePeerID)
#expect(recorder.decryptCalls.count == 1)
#expect(recorder.deliveries.isEmpty)
}
@Test
func missingSessionInitiatesHandshakeWithoutClearing() {
let recorder = Recorder()
recorder.decryptResult = .failure(NoiseEncryptionError.sessionNotEstablished)
recorder.hasSession = false
let handler = makeHandler(recorder: recorder)
let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id))
handler.handleEncrypted(packet, from: remotePeerID)
#expect(recorder.hasSessionQueries == [remotePeerID])
#expect(recorder.initiatedHandshakes == [remotePeerID])
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.deliveries.isEmpty)
}
@Test
func missingSessionSkipsInitiateWhenSessionAppeared() {
let recorder = Recorder()
recorder.decryptResult = .failure(NoiseEncryptionError.sessionNotEstablished)
recorder.hasSession = true
let handler = makeHandler(recorder: recorder)
let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id))
handler.handleEncrypted(packet, from: remotePeerID)
#expect(recorder.initiatedHandshakes.isEmpty)
#expect(recorder.clearedSessions.isEmpty)
}
@Test
func decryptFailureClearsSessionThenReinitiatesHandshake() {
let recorder = Recorder()
recorder.decryptResult = .failure(TestError())
// Even with a live session, recovery clears it and re-initiates unconditionally.
recorder.hasSession = true
let handler = makeHandler(recorder: recorder)
let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id))
handler.handleEncrypted(packet, from: remotePeerID)
#expect(recorder.clearedSessions == [remotePeerID])
#expect(recorder.initiatedHandshakes == [remotePeerID])
// Session-recovery order must stay clear → re-initiate.
#expect(recorder.events == ["clearSession", "initiateHandshake"])
#expect(recorder.deliveries.isEmpty)
}
@Test
func earlyCiphertextIsRetriedAfterResponderHandshakeCompletes() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let encrypted = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
)
handler.handleEncrypted(encrypted, from: remotePeerID)
#expect(recorder.decryptCalls.count == 1)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.privateMessage.rawValue, 0xCA, 0xFE])
)
handler.handleSessionAuthenticated(remotePeerID)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.type == .privateMessage)
#expect(recorder.deliveries.first?.payload == Data([0xCA, 0xFE]))
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func panicResetDiscardsDeferredCiphertextBeforeFutureAuthentication() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let prePanicCiphertext = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
payload: Data(
count: NoiseSecurityConstants.maxMessageSize
+ NoiseSecurityConstants.transportCiphertextOverhead
)
)
handler.handleEncrypted(prePanicCiphertext, from: remotePeerID)
#expect(recorder.decryptCalls.count == 1)
handler.resetForPanic()
// Three maximum-sized packets fit only when reset also zeroed the
// global byte accounting. They model ciphertext received under the
// replacement identity before that responder handshake completes.
for index in 0..<3 {
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
timestamp: UInt64(901_000 + index),
payload: Data(
count: NoiseSecurityConstants.maxMessageSize
+ NoiseSecurityConstants.transportCiphertextOverhead
)
),
from: remotePeerID
)
}
#expect(recorder.decryptCalls.count == 4)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.privateMessage.rawValue, 0xCA, 0xFE])
)
handler.handleSessionAuthenticated(remotePeerID)
// Only the three post-reset packets replay; the pre-panic packet does
// not survive into the replacement session.
#expect(recorder.decryptCalls.count == 7)
#expect(recorder.deliveries.count == 3)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func ciphertextQueuedAheadOfEstablishmentCallbackDoesNotConsumeNonce()
throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(
publicKey: alice.getStaticPublicKeyData()
)
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
let message1 = try alice.initiateHandshake(with: bobPeerID)
let message2 = try #require(
try bob.processHandshakeMessage(
from: alicePeerID,
message: message1
)
)
let message3 = try #require(
try alice.processHandshakeMessage(
from: bobPeerID,
message: message2
)
)
let typedPayload = Data([
NoisePayloadType.privateMessage.rawValue,
0xCA, 0xFE
])
let ciphertext = try alice.encrypt(
typedPayload,
for: bobPeerID
)
// Manager promotion has completed, but the serialized BLE callback is
// deliberately still behind this ciphertext.
_ = try bob.processHandshakeMessage(
from: alicePeerID,
message: message3
)
let recorder = Recorder()
recorder.transportGenerationReady = false
let handler = makeServiceBackedHandler(
service: bob,
localPeerID: bobPeerID,
recorder: recorder,
transportGenerationIsReady: { _ in
recorder.transportGenerationReady
}
)
let packet = makeEncryptedPacket(
recipientID: Data(hexString: bobPeerID.id),
payload: ciphertext
)
handler.handleEncrypted(packet, from: alicePeerID)
#expect(recorder.deliveries.isEmpty)
#expect(recorder.clearedSessions.isEmpty)
// The exact ciphertext must still authenticate, proving the readiness
// rejection happened before the receive nonce was consumed.
recorder.transportGenerationReady = true
handler.handleSessionAuthenticated(alicePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.type == .privateMessage)
#expect(recorder.deliveries.first?.payload == Data([0xCA, 0xFE]))
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func ciphertextQueuedAheadOfRestoreCallbackDoesNotConsumeNonce()
throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let mallory = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(
publicKey: alice.getStaticPublicKeyData()
)
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
let initial1 = try alice.initiateHandshake(with: bobPeerID)
let initial2 = try #require(
try bob.processHandshakeMessage(
from: alicePeerID,
message: initial1
)
)
let initial3 = try #require(
try alice.processHandshakeMessage(
from: bobPeerID,
message: initial2
)
)
_ = try bob.processHandshakeMessage(
from: alicePeerID,
message: initial3
)
let typedPayload = Data([
NoisePayloadType.privateMessage.rawValue,
0xBE, 0xEF
])
let delayedCiphertext = try alice.encrypt(
typedPayload,
for: bobPeerID
)
let forged1 = try mallory.initiateHandshake(with: bobPeerID)
let forged2 = try #require(
try bob.processHandshakeMessage(
from: alicePeerID,
message: forged1
)
)
let forged3 = try #require(
try mallory.processHandshakeMessage(
from: bobPeerID,
message: forged2
)
)
#expect(throws: NoiseSessionError.peerIdentityMismatch) {
try bob.processHandshakeMessage(
from: alicePeerID,
message: forged3
)
}
#expect(bob.hasEstablishedSession(with: alicePeerID))
let recorder = Recorder()
recorder.transportGenerationReady = false
let handler = makeServiceBackedHandler(
service: bob,
localPeerID: bobPeerID,
recorder: recorder,
transportGenerationIsReady: { _ in
recorder.transportGenerationReady
}
)
let packet = makeEncryptedPacket(
recipientID: Data(hexString: bobPeerID.id),
payload: delayedCiphertext
)
// Manager rollback is visible, while the BLE restore callback is
// deliberately still queued behind this ciphertext.
handler.handleEncrypted(packet, from: alicePeerID)
#expect(recorder.deliveries.isEmpty)
#expect(recorder.clearedSessions.isEmpty)
recorder.transportGenerationReady = true
handler.handleSessionAuthenticated(alicePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.type == .privateMessage)
#expect(recorder.deliveries.first?.payload == Data([0xBE, 0xEF]))
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func oversizedCiphertextCannotEvictEstablishedTransport() throws {
let pair = try establishedServices()
let recorder = Recorder()
recorder.transportGenerationReady = true
let handler = makeServiceBackedHandler(
service: pair.receiver,
localPeerID: pair.receiverPeerID,
recorder: recorder,
transportGenerationIsReady: { _ in
recorder.transportGenerationReady
}
)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: Data(
count:
NoiseSecurityConstants
.maxPrivateFileCiphertextSize + 1
)
),
from: pair.senderPeerID
)
#expect(
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
)
#expect(recorder.clearedSessions.isEmpty)
let valid = try pair.sender.encrypt(
Data([NoisePayloadType.privateMessage.rawValue, 0x01]),
for: pair.receiverPeerID
)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: valid
),
from: pair.senderPeerID
)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.payload == Data([0x01]))
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func forgedAuthenticationFailureCannotEvictEstablishedTransport()
throws {
let pair = try establishedServices()
let recorder = Recorder()
recorder.transportGenerationReady = true
let handler = makeServiceBackedHandler(
service: pair.receiver,
localPeerID: pair.receiverPeerID,
recorder: recorder,
transportGenerationIsReady: { _ in
recorder.transportGenerationReady
}
)
let valid = try pair.sender.encrypt(
Data([NoisePayloadType.privateMessage.rawValue, 0x02]),
for: pair.receiverPeerID
)
var forged = valid
forged[forged.index(before: forged.endIndex)] ^= 0xFF
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: forged
),
from: pair.senderPeerID
)
#expect(
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
)
#expect(recorder.clearedSessions.isEmpty)
// Authentication failure leaves nonce state untouched, so the exact
// original ciphertext remains valid.
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: valid
),
from: pair.senderPeerID
)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.payload == Data([0x02]))
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func replayCannotEvictEstablishedTransportOrBlockNextNonce() throws {
let pair = try establishedServices()
let recorder = Recorder()
recorder.transportGenerationReady = true
let handler = makeServiceBackedHandler(
service: pair.receiver,
localPeerID: pair.receiverPeerID,
recorder: recorder,
transportGenerationIsReady: { _ in
recorder.transportGenerationReady
}
)
let first = try pair.sender.encrypt(
Data([NoisePayloadType.privateMessage.rawValue, 0x03]),
for: pair.receiverPeerID
)
let firstPacket = makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: first
)
handler.handleEncrypted(firstPacket, from: pair.senderPeerID)
handler.handleEncrypted(firstPacket, from: pair.senderPeerID)
#expect(
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
)
#expect(recorder.clearedSessions.isEmpty)
let next = try pair.sender.encrypt(
Data([NoisePayloadType.privateMessage.rawValue, 0x04]),
for: pair.receiverPeerID
)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: next
),
from: pair.senderPeerID
)
#expect(recorder.deliveries.count == 2)
#expect(recorder.deliveries.map { $0.payload } == [
Data([0x03]), Data([0x04])
])
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func rateLimitFailureCannotEvictEstablishedTransportOrConsumeNonce()
throws {
let pair = try establishedServices()
let recorder = Recorder()
recorder.transportGenerationReady = true
recorder.forcedServiceDecryptError =
NoiseSecurityError.rateLimitExceeded
let handler = makeServiceBackedHandler(
service: pair.receiver,
localPeerID: pair.receiverPeerID,
recorder: recorder,
transportGenerationIsReady: { _ in
recorder.transportGenerationReady
}
)
let valid = try pair.sender.encrypt(
Data([NoisePayloadType.privateMessage.rawValue, 0x05]),
for: pair.receiverPeerID
)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: Data(repeating: 0xA5, count: 20)
),
from: pair.senderPeerID
)
#expect(
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
)
#expect(recorder.clearedSessions.isEmpty)
recorder.forcedServiceDecryptError = nil
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: valid
),
from: pair.senderPeerID
)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.payload == Data([0x05]))
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func maximumPrivateFileCiphertextIsEligibleForDeferredRetry() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let encrypted = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
payload: Data(
count: NoiseSecurityConstants.maxPrivateFileCiphertextSize
)
)
handler.handleEncrypted(encrypted, from: remotePeerID)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.privateFile.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.type == .privateFile)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func oversizedEarlyCiphertextIsNotDeferred() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let encrypted = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
payload: Data(
count:
NoiseSecurityConstants.maxPrivateFileCiphertextSize + 1
)
)
handler.handleEncrypted(encrypted, from: remotePeerID)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 1)
#expect(recorder.deliveries.isEmpty)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func missingSessionCiphertextIsRetriedAfterResponderHandshakeCompletes() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
NoiseEncryptionError.sessionNotEstablished
)
let handler = makeHandler(recorder: recorder)
let encrypted = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
)
handler.handleEncrypted(encrypted, from: remotePeerID)
#expect(recorder.decryptCalls.count == 1)
#expect(recorder.initiatedHandshakes.isEmpty)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func lowNonceCiphertextIsRetriedAfterResponderHandshakeCompletes() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(NoiseError.replayDetected)
let handler = makeHandler(recorder: recorder)
let encrypted = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
)
handler.handleEncrypted(encrypted, from: remotePeerID)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.readReceipt.rawValue, 0x02])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.type == .readReceipt)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func invalidDeferredCiphertextDoesNotClearAuthenticatedSession() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let encrypted = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
)
handler.handleEncrypted(encrypted, from: remotePeerID)
recorder.awaitingResponderHandshake = false
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.isEmpty)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func nonCipherFailureDuringResponderHandshakeIsDroppedNotDeferred() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(TestError())
let handler = makeHandler(recorder: recorder)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
),
from: remotePeerID
)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 1)
#expect(recorder.deliveries.isEmpty)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func earlyCiphertextBufferIsBoundedPerPeer() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
for index in 0..<5 {
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
timestamp: UInt64(900_000 + index)
),
from: remotePeerID
)
}
#expect(recorder.decryptCalls.count == 5)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 9)
#expect(recorder.deliveries.count == 4)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func earlyCiphertextBufferIsBoundedGlobally() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let peers = (1...33).map {
PeerID(str: String(format: "%016llx", UInt64($0)))
}
let packet = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
)
for peerID in peers {
handler.handleEncrypted(packet, from: peerID)
}
#expect(recorder.decryptCalls.count == 33)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
for peerID in peers {
handler.handleSessionAuthenticated(peerID)
}
#expect(recorder.decryptCalls.count == 65)
#expect(recorder.deliveries.count == 32)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func earlyCiphertextBufferKeepsPrivateFileRoomAndByteBound() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let peers = [
PeerID(str: "0000000000000001"),
PeerID(str: "0000000000000002"),
PeerID(str: "0000000000000003")
]
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
payload: Data(
count:
NoiseSecurityConstants.maxPrivateFileCiphertextSize
)
),
from: peers[0]
)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
payload: Data(count: 256 * 1024)
),
from: peers[1]
)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
payload: Data([0x01])
),
from: peers[2]
)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
for peerID in peers {
handler.handleSessionAuthenticated(peerID)
}
#expect(recorder.decryptCalls.count == 5)
#expect(recorder.deliveries.count == 2)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func expiredEarlyCiphertextIsNotRetried() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
),
from: remotePeerID
)
recorder.currentDate =
recorder.currentDate.addingTimeInterval(
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
+ 0.001
)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 1)
#expect(recorder.deliveries.isEmpty)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func earlyCiphertextSurvivesResponderHandshakeWindow() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
),
from: remotePeerID
)
recorder.currentDate =
recorder.currentDate.addingTimeInterval(
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
- 0.001
)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
private func makeHandshakePacket(recipientID: Data?) -> BitchatPacket {
BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: Data(hexString: remotePeerID.id) ?? Data(),
recipientID: recipientID,
timestamp: 900_000,
payload: Data([0x01, 0x02, 0x03]),
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
}
private func makeEncryptedPacket(
recipientID: Data?,
timestamp: UInt64 = 900_000,
payload: Data = Data([0xC0, 0xFF, 0xEE])
) -> BitchatPacket {
BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: remotePeerID.id) ?? Data(),
recipientID: recipientID,
timestamp: timestamp,
payload: payload,
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
}
}