mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 09:05:20 +00:00
Preserve early Noise ciphertext across reconnects
This commit is contained in:
@@ -542,7 +542,7 @@ struct BLEServiceCoreTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func failedInboundReconnectRestoresAndDrainsTypedPayloadQueue() async throws {
|
||||
func failedInboundReconnectRestoresAndDrainsWaitingWorkOnce() async throws {
|
||||
let ble = makeService()
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
@@ -625,21 +625,48 @@ struct BLEServiceCoreTests {
|
||||
message: forgedMessage2
|
||||
)
|
||||
)
|
||||
let forgedEarlyPayload = try #require(
|
||||
BLENoisePayloadFactory.privateMessage(
|
||||
content: "forged early message",
|
||||
messageID: "forged-early"
|
||||
)
|
||||
)
|
||||
try #require(
|
||||
mallory.hasEstablishedSession(with: ble.myPeerID),
|
||||
"forged initiator did not establish after producing message three"
|
||||
)
|
||||
let forgedEarlyCiphertext = try mallory.encrypt(
|
||||
forgedEarlyPayload,
|
||||
for: ble.myPeerID
|
||||
)
|
||||
let earlyPacket = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: ble.myPeerID.id),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 1,
|
||||
payload: forgedEarlyCiphertext,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
ble._test_handlePacket(earlyPacket, fromPeerID: alicePeerID)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
|
||||
let thirdPacket = BitchatPacket(
|
||||
type: MessageType.noiseHandshake.rawValue,
|
||||
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: ble.myPeerID.id),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 1,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 2,
|
||||
payload: forgedMessage3,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
ble._test_handlePacket(thirdPacket, fromPeerID: alicePeerID)
|
||||
|
||||
// Restore re-enters the generation-bound authentication transition:
|
||||
// authenticated state and both outbound queues drain exactly once.
|
||||
// Rollback restores the same generation. It retries the bounded early
|
||||
// ciphertext and drains both outbound queues, but must not repeat a
|
||||
// new-generation capability proof or forced announce.
|
||||
let drained = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .noiseEncrypted) >= 3 },
|
||||
{ outbound.count(ofType: .noiseEncrypted) >= 2 },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
try #require(drained)
|
||||
@@ -647,11 +674,11 @@ struct BLEServiceCoreTests {
|
||||
let plaintexts = try outbound.snapshot()
|
||||
.filter { $0.type == MessageType.noiseEncrypted.rawValue }
|
||||
.map { try alice.decrypt($0.payload, from: ble.myPeerID) }
|
||||
#expect(plaintexts.count == 3)
|
||||
#expect(plaintexts.count == 2)
|
||||
#expect(
|
||||
plaintexts.filter {
|
||||
$0.first == NoisePayloadType.authenticatedPeerState.rawValue
|
||||
}.count == 1
|
||||
}.isEmpty
|
||||
)
|
||||
#expect(
|
||||
plaintexts.filter {
|
||||
@@ -663,6 +690,13 @@ struct BLEServiceCoreTests {
|
||||
$0.first == NoisePayloadType.groupInvite.rawValue
|
||||
}.count == 1
|
||||
)
|
||||
#expect(outbound.count(ofType: .announce) == 0)
|
||||
|
||||
// A duplicate ready callback cannot replay either buffer.
|
||||
ble._test_reconcileCurrentNoiseSession(for: alicePeerID)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
#expect(outbound.count(ofType: .noiseEncrypted) == 2)
|
||||
#expect(outbound.count(ofType: .announce) == 0)
|
||||
}
|
||||
|
||||
/// A legitimate rotation announce necessarily arrives on a link still
|
||||
@@ -994,4 +1028,5 @@ private final class PublicCaptureDelegate: BitchatDelegate {
|
||||
defer { lock.unlock() }
|
||||
return publicMessages
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import BitFoundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
@@ -10,7 +11,11 @@ struct BLENoisePacketHandlerTests {
|
||||
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] = []
|
||||
@@ -33,11 +38,12 @@ struct BLENoisePacketHandlerTests {
|
||||
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: { now },
|
||||
now: { recorder.currentDate },
|
||||
processHandshakeMessage: { peerID, message in
|
||||
recorder.processedHandshakes.append((peerID, message))
|
||||
return try recorder.handshakeResult.get()
|
||||
@@ -46,6 +52,9 @@ struct BLENoisePacketHandlerTests {
|
||||
recorder.hasSessionQueries.append(peerID)
|
||||
return recorder.hasSession
|
||||
},
|
||||
isAwaitingResponderHandshakeCompletion: { _ in
|
||||
recorder.awaitingResponderHandshake
|
||||
},
|
||||
initiateHandshake: { peerID in
|
||||
recorder.initiatedHandshakes.append(peerID)
|
||||
recorder.events.append("initiateHandshake")
|
||||
@@ -77,6 +86,120 @@ struct BLENoisePacketHandlerTests {
|
||||
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
|
||||
@@ -341,6 +464,745 @@ struct BLENoisePacketHandlerTests {
|
||||
#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 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,
|
||||
@@ -355,14 +1217,15 @@ struct BLENoisePacketHandlerTests {
|
||||
|
||||
private func makeEncryptedPacket(
|
||||
recipientID: Data?,
|
||||
timestamp: UInt64 = 900_000
|
||||
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: Data([0xC0, 0xFF, 0xEE]),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
|
||||
@@ -1344,6 +1344,89 @@ struct NoiseEncryptionServiceTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Responder completion state spans ordinary XX message three")
|
||||
func responderCompletionStateTracksOrdinaryHandshake() 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)
|
||||
#expect(
|
||||
!bob.isAwaitingResponderHandshakeCompletion(with: alicePeerID)
|
||||
)
|
||||
|
||||
let message2 = try #require(
|
||||
try bob.processHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: message1
|
||||
)
|
||||
)
|
||||
#expect(
|
||||
bob.isAwaitingResponderHandshakeCompletion(with: alicePeerID)
|
||||
)
|
||||
|
||||
let message3 = try #require(
|
||||
try alice.processHandshakeMessage(
|
||||
from: bobPeerID,
|
||||
message: message2
|
||||
)
|
||||
)
|
||||
#expect(
|
||||
bob.isAwaitingResponderHandshakeCompletion(with: alicePeerID)
|
||||
)
|
||||
|
||||
_ = try bob.processHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: message3
|
||||
)
|
||||
#expect(
|
||||
!bob.isAwaitingResponderHandshakeCompletion(with: alicePeerID)
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Transport readiness rejection spends no message budget")
|
||||
func transportReadinessRejectionSpendsNoMessageBudget() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(
|
||||
publicKey: alice.getStaticPublicKeyData()
|
||||
)
|
||||
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||
try establishSessions(alice: alice, bob: bob)
|
||||
let plaintext = Data([
|
||||
NoisePayloadType.privateMessage.rawValue,
|
||||
0xAB, 0xCD
|
||||
])
|
||||
let ciphertext = try alice.encrypt(plaintext, for: bobPeerID)
|
||||
|
||||
for _ in 0...NoiseSecurityConstants.maxMessagesPerSecond {
|
||||
do {
|
||||
_ = try bob.decryptWithSessionGeneration(
|
||||
ciphertext,
|
||||
from: alicePeerID,
|
||||
establishedGenerationIsReady: { _ in false }
|
||||
)
|
||||
Issue.record(
|
||||
"Expected transport generation readiness rejection"
|
||||
)
|
||||
} catch NoiseEncryptionError.transportGenerationNotReady {
|
||||
// Expected: authorization and nonce mutation are both later.
|
||||
} catch {
|
||||
Issue.record("Unexpected readiness error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
let decrypted = try bob.decryptWithSessionGeneration(
|
||||
ciphertext,
|
||||
from: alicePeerID,
|
||||
establishedGenerationIsReady: { _ in true }
|
||||
)
|
||||
#expect(decrypted.plaintext == plaintext)
|
||||
}
|
||||
|
||||
@Test("NoiseMessage JSON and binary encoding round-trip")
|
||||
func noiseMessageRoundTrips() throws {
|
||||
let message = NoiseMessage(
|
||||
|
||||
Reference in New Issue
Block a user