mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 08:25:19 +00:00
Fix cached Noise reconnects atomically
This commit is contained in:
@@ -502,14 +502,26 @@ struct BLEServiceCoreTests {
|
||||
)
|
||||
let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce")
|
||||
#expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink))
|
||||
let rebindGate = VerifiedDirectRebindGate()
|
||||
ble._test_afterVerifiedDirectRebindEnqueued = rebindGate.pause
|
||||
defer {
|
||||
rebindGate.release()
|
||||
ble._test_afterVerifiedDirectRebindEnqueued = nil
|
||||
}
|
||||
ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false)
|
||||
|
||||
let rebound = await TestHelpers.waitUntil(
|
||||
{ ble._test_centralBinding(attackerLink) == victimPeerID },
|
||||
let announcePaused = await TestHelpers.waitUntil(
|
||||
{ rebindGate.hasPaused },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(rebound)
|
||||
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
||||
try #require(announcePaused)
|
||||
|
||||
// Rebind and ordinary reconnect preparation are one bleQueue
|
||||
// critical section. Once the binding is visible, stale sending keys
|
||||
// must already be unavailable.
|
||||
#expect(ble._test_centralBinding(attackerLink) == victimPeerID)
|
||||
#expect(!ble.canDeliverSecurely(to: victimPeerID))
|
||||
rebindGate.release()
|
||||
|
||||
let outbound = OutboundPacketTap()
|
||||
ble._test_onOutboundPacket = { outbound.record($0) }
|
||||
@@ -614,7 +626,117 @@ struct BLEServiceCoreTests {
|
||||
for: victimPeerID
|
||||
)
|
||||
)
|
||||
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
||||
// Ordinary reconnect hardening quarantines the cached transport while
|
||||
// this candidate proves the claimed identity. It must be unavailable
|
||||
// for sending as well as unable to authenticate this ingress link.
|
||||
#expect(!ble.canDeliverSecurely(to: victimPeerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
func failedInboundReconnectRestoresAndDrainsTypedPayloadQueue() async throws {
|
||||
let ble = makeService()
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
|
||||
let message1 = try ble._test_noiseInitiateHandshake(with: alicePeerID)
|
||||
let message2 = try #require(
|
||||
try alice.processHandshakeMessage(from: ble.myPeerID, message: message1)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try ble._test_noiseProcessHandshakeMessage(from: alicePeerID, message: message2)
|
||||
)
|
||||
_ = try alice.processHandshakeMessage(from: ble.myPeerID, message: message3)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
#expect(ble.canDeliverSecurely(to: alicePeerID))
|
||||
|
||||
let outbound = OutboundPacketTap()
|
||||
ble._test_onOutboundPacket = outbound.record
|
||||
let forgedMessage1 = try mallory.initiateHandshake(with: ble.myPeerID)
|
||||
let firstPacket = BitchatPacket(
|
||||
type: MessageType.noiseHandshake.rawValue,
|
||||
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: ble.myPeerID.id),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000),
|
||||
payload: forgedMessage1,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
ble._test_handlePacket(firstPacket, fromPeerID: alicePeerID)
|
||||
|
||||
let responseReady = await TestHelpers.waitUntil(
|
||||
{
|
||||
outbound.snapshot().contains {
|
||||
$0.type == MessageType.noiseHandshake.rawValue
|
||||
}
|
||||
},
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
try #require(responseReady)
|
||||
let forgedMessage2 = try #require(
|
||||
outbound.snapshot().first {
|
||||
$0.type == MessageType.noiseHandshake.rawValue
|
||||
}?.payload
|
||||
)
|
||||
#expect(!ble.canDeliverSecurely(to: alicePeerID))
|
||||
|
||||
// Typed control traffic must queue behind the ordinary responder,
|
||||
// rather than attempting encryption and disappearing.
|
||||
let privateMessageID = "quarantine-pm-\(UUID().uuidString)"
|
||||
ble.sendPrivateMessage(
|
||||
"queued private message",
|
||||
to: alicePeerID,
|
||||
recipientNickname: "Alice",
|
||||
messageID: privateMessageID
|
||||
)
|
||||
ble.sendGroupInvite(Data("queued-during-quarantine".utf8), to: alicePeerID)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
|
||||
|
||||
let forgedMessage3 = try #require(
|
||||
try mallory.processHandshakeMessage(
|
||||
from: ble.myPeerID,
|
||||
message: forgedMessage2
|
||||
)
|
||||
)
|
||||
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,
|
||||
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.
|
||||
let drained = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .noiseEncrypted) >= 3 },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
try #require(drained)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
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.filter {
|
||||
$0.first == NoisePayloadType.authenticatedPeerState.rawValue
|
||||
}.count == 1
|
||||
)
|
||||
#expect(
|
||||
plaintexts.filter {
|
||||
$0.first == NoisePayloadType.privateMessage.rawValue
|
||||
}.count == 1
|
||||
)
|
||||
#expect(
|
||||
plaintexts.filter {
|
||||
$0.first == NoisePayloadType.groupInvite.rawValue
|
||||
}.count == 1
|
||||
)
|
||||
}
|
||||
|
||||
/// A legitimate rotation announce necessarily arrives on a link still
|
||||
@@ -942,6 +1064,40 @@ private final class OutboundPacketTap {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return packets.filter { $0.type == type.rawValue }.count
|
||||
}
|
||||
|
||||
func snapshot() -> [BitchatPacket] {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return packets
|
||||
}
|
||||
}
|
||||
|
||||
private final class VerifiedDirectRebindGate: @unchecked Sendable {
|
||||
private let condition = NSCondition()
|
||||
private var paused = false
|
||||
private var released = false
|
||||
|
||||
var hasPaused: Bool {
|
||||
condition.lock()
|
||||
defer { condition.unlock() }
|
||||
return paused
|
||||
}
|
||||
|
||||
func pause() {
|
||||
condition.lock()
|
||||
paused = true
|
||||
condition.broadcast()
|
||||
while !released {
|
||||
condition.wait()
|
||||
}
|
||||
condition.unlock()
|
||||
}
|
||||
|
||||
func release() {
|
||||
condition.lock()
|
||||
released = true
|
||||
condition.broadcast()
|
||||
condition.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private final class ReceivePacketHandoffGate: @unchecked Sendable {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("BLE Noise reconnect policy")
|
||||
struct BLENoiseReconnectPolicyTests {
|
||||
@Test("Revalidation requires a cached session and no authenticated link")
|
||||
func revalidationPreconditions() {
|
||||
var policy = BLENoiseReconnectPolicy()
|
||||
let link = BLEIngressLinkID.peripheral("peripheral-a")
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
|
||||
let withoutSession = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: false,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: now
|
||||
)
|
||||
#expect(!withoutSession)
|
||||
let authenticated = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: true,
|
||||
hasAuthenticatedPeerLink: true,
|
||||
now: now
|
||||
)
|
||||
#expect(!authenticated)
|
||||
let eligible = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: now
|
||||
)
|
||||
#expect(eligible)
|
||||
}
|
||||
|
||||
@Test("Revalidation is once per link epoch or after sixty seconds")
|
||||
func revalidationIsBoundPerLinkEpoch() {
|
||||
var policy = BLENoiseReconnectPolicy()
|
||||
let link = BLEIngressLinkID.central("central-a")
|
||||
let start = Date(timeIntervalSince1970: 2_000)
|
||||
|
||||
let initial = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: start
|
||||
)
|
||||
#expect(initial)
|
||||
let duringCooldown = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: start.addingTimeInterval(59.999)
|
||||
)
|
||||
#expect(!duringCooldown)
|
||||
let afterCooldown = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: start.addingTimeInterval(60)
|
||||
)
|
||||
#expect(afterCooldown)
|
||||
|
||||
policy.endLinkEpoch(link)
|
||||
let nextEpoch = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: start.addingTimeInterval(60.001)
|
||||
)
|
||||
#expect(nextEpoch)
|
||||
}
|
||||
|
||||
@Test("An authenticated sibling suppresses redundant reconnect")
|
||||
func authenticatedSiblingSuppressesReconnect() {
|
||||
var policy = BLENoiseReconnectPolicy()
|
||||
let link = BLEIngressLinkID.peripheral("unproven-sibling")
|
||||
let start = Date(timeIntervalSince1970: 3_000)
|
||||
|
||||
let suppressed = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: true,
|
||||
now: start
|
||||
)
|
||||
#expect(!suppressed)
|
||||
let eligible = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: start
|
||||
)
|
||||
#expect(eligible)
|
||||
}
|
||||
|
||||
@Test("Reserved replacement bit is not advertised")
|
||||
func reservedReplacementBitIsNotAdvertised() {
|
||||
#expect(
|
||||
!PeerCapabilities.localSupported.contains(
|
||||
.nonDestructiveNoiseReplacement
|
||||
)
|
||||
)
|
||||
#expect(PeerCapabilities.localSupported.contains(.privateMedia))
|
||||
}
|
||||
}
|
||||
@@ -171,8 +171,8 @@ struct NoiseEncryptionServiceTests {
|
||||
#expect(!emittedAuthentication)
|
||||
}
|
||||
|
||||
@Test("Failed forged replacement preserves the established peer session")
|
||||
func forgedReplacementPreservesEstablishedSession() async throws {
|
||||
@Test("Failed forged reconnect restores the established peer session")
|
||||
func forgedReconnectRestoresEstablishedSession() async throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
@@ -195,20 +195,20 @@ struct NoiseEncryptionServiceTests {
|
||||
let forgedMessage2 = try #require(
|
||||
try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage1)
|
||||
)
|
||||
// The replacement has not authenticated yet; the working Alice
|
||||
// transport session must remain available throughout the candidate.
|
||||
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||
// Outbound/session-generation APIs fail closed while the old
|
||||
// transport is retained solely for receive and bounded rollback.
|
||||
#expect(!receiver.hasEstablishedSession(with: alicePeerID))
|
||||
let forgedMessage3 = try #require(
|
||||
try mallory.processHandshakeMessage(from: receiverPeerID, message: forgedMessage2)
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage3)
|
||||
Issue.record("Expected forged replacement to fail peer binding")
|
||||
Issue.record("Expected forged reconnect to fail peer binding")
|
||||
} catch let error as NoiseSessionError {
|
||||
#expect(error == .peerIdentityMismatch)
|
||||
} catch {
|
||||
Issue.record("Unexpected replacement error: \(error)")
|
||||
Issue.record("Unexpected reconnect error: \(error)")
|
||||
}
|
||||
|
||||
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||
@@ -221,8 +221,8 @@ struct NoiseEncryptionServiceTests {
|
||||
#expect(!emittedReplacementAuthentication)
|
||||
}
|
||||
|
||||
@Test("Valid rehandshake atomically replaces the established session")
|
||||
func validRehandshakeReplacesEstablishedSession() throws {
|
||||
@Test("Valid ordinary rehandshake atomically replaces the established session")
|
||||
func validOrdinaryRehandshakeReplacesEstablishedSession() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
@@ -235,7 +235,7 @@ struct NoiseEncryptionServiceTests {
|
||||
let message2 = try #require(
|
||||
try receiver.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||
)
|
||||
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||
#expect(!receiver.hasEstablishedSession(with: alicePeerID))
|
||||
let message3 = try #require(
|
||||
try alice.processHandshakeMessage(from: receiverPeerID, message: message2)
|
||||
)
|
||||
@@ -353,6 +353,226 @@ struct NoiseEncryptionServiceTests {
|
||||
#expect(decrypted == typedPayload)
|
||||
}
|
||||
|
||||
@Test("Atomic reconnect retires old sending keys before message one")
|
||||
func atomicReconnectQueuesUntilOrdinaryHandshakeCompletes() 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 oldCiphertext = try alice.encrypt(Data("old transport".utf8), for: bobPeerID)
|
||||
#expect(try bob.decrypt(oldCiphertext, from: alicePeerID) == Data("old transport".utf8))
|
||||
|
||||
let initiation = try alice.initiateReconnectHandshake(with: bobPeerID)
|
||||
#expect(alice.hasSession(with: bobPeerID))
|
||||
#expect(!alice.hasEstablishedSession(with: bobPeerID))
|
||||
do {
|
||||
_ = try alice.encrypt(Data("must queue".utf8), for: bobPeerID)
|
||||
Issue.record("Expected encryption to wait for the reconnect")
|
||||
} catch NoiseEncryptionError.handshakeRequired {
|
||||
// Expected: BLE queues behind the ordinary handshaking session.
|
||||
} catch {
|
||||
Issue.record("Unexpected in-window encryption error: \(error)")
|
||||
}
|
||||
|
||||
let message1 = try #require(
|
||||
alice.claimHandshakeInitiation(initiation, for: bobPeerID)
|
||||
)
|
||||
bob.clearSession(for: alicePeerID)
|
||||
let message2 = try #require(
|
||||
try bob.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try alice.processHandshakeMessage(from: bobPeerID, message: message2)
|
||||
)
|
||||
_ = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
|
||||
|
||||
let fresh = try alice.encrypt(Data("fresh transport".utf8), for: bobPeerID)
|
||||
#expect(try bob.decrypt(fresh, from: alicePeerID) == Data("fresh transport".utf8))
|
||||
}
|
||||
|
||||
@Test("Inbound reconnect quarantines old sending keys until identity proof")
|
||||
func inboundReconnectQuarantinesOldTransport() throws {
|
||||
let restarted = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let retained = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let restartedPeerID = PeerID(publicKey: restarted.getStaticPublicKeyData())
|
||||
let retainedPeerID = PeerID(publicKey: retained.getStaticPublicKeyData())
|
||||
|
||||
try establishSessions(alice: restarted, bob: retained)
|
||||
let inFlightOldCiphertext = try restarted.encrypt(
|
||||
Data("old receive-only transport".utf8),
|
||||
for: retainedPeerID
|
||||
)
|
||||
restarted.clearSession(for: retainedPeerID)
|
||||
|
||||
let message1 = try restarted.initiateHandshake(with: retainedPeerID)
|
||||
let message2 = try #require(
|
||||
try retained.processHandshakeMessage(
|
||||
from: restartedPeerID,
|
||||
message: message1
|
||||
)
|
||||
)
|
||||
#expect(!retained.hasEstablishedSession(with: restartedPeerID))
|
||||
#expect(
|
||||
try retained.decrypt(
|
||||
inFlightOldCiphertext,
|
||||
from: restartedPeerID
|
||||
) == Data("old receive-only transport".utf8)
|
||||
)
|
||||
do {
|
||||
_ = try retained.encrypt(
|
||||
Data("must queue at responder".utf8),
|
||||
for: restartedPeerID
|
||||
)
|
||||
Issue.record("Expected quarantined responder encryption to wait")
|
||||
} catch NoiseEncryptionError.handshakeRequired {
|
||||
// Expected.
|
||||
} catch {
|
||||
Issue.record("Unexpected quarantine encryption error: \(error)")
|
||||
}
|
||||
|
||||
let message3 = try #require(
|
||||
try restarted.processHandshakeMessage(
|
||||
from: retainedPeerID,
|
||||
message: message2
|
||||
)
|
||||
)
|
||||
_ = try retained.processHandshakeMessage(
|
||||
from: restartedPeerID,
|
||||
message: message3
|
||||
)
|
||||
|
||||
let fresh = try retained.encrypt(
|
||||
Data("identity proved".utf8),
|
||||
for: restartedPeerID
|
||||
)
|
||||
#expect(
|
||||
try restarted.decrypt(fresh, from: retainedPeerID)
|
||||
== Data("identity proved".utf8)
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Forged reconnect restores the quarantined transport")
|
||||
func forgedReconnectRestoresQuarantinedTransport() 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())
|
||||
|
||||
try establishSessions(alice: alice, bob: bob)
|
||||
|
||||
let forgedMessage1 = try mallory.initiateHandshake(with: bobPeerID)
|
||||
let forgedMessage2 = try #require(
|
||||
try bob.processHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: forgedMessage1
|
||||
)
|
||||
)
|
||||
#expect(!bob.hasEstablishedSession(with: alicePeerID))
|
||||
let forgedMessage3 = try #require(
|
||||
try mallory.processHandshakeMessage(
|
||||
from: bobPeerID,
|
||||
message: forgedMessage2
|
||||
)
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try bob.processHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: forgedMessage3
|
||||
)
|
||||
Issue.record("Expected forged static identity to be rejected")
|
||||
} catch NoiseSessionError.peerIdentityMismatch {
|
||||
// Expected; the manager restores the quarantined transport.
|
||||
} catch {
|
||||
Issue.record("Unexpected forged reconnect error: \(error)")
|
||||
}
|
||||
|
||||
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
||||
let oldTransport = try alice.encrypt(
|
||||
Data("rollback survived".utf8),
|
||||
for: bobPeerID
|
||||
)
|
||||
#expect(
|
||||
try bob.decrypt(oldTransport, from: alicePeerID)
|
||||
== Data("rollback survived".utf8)
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Lost reconnect completion restores the quarantined transport")
|
||||
func timedOutReconnectRestoresQuarantinedTransport() async throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(
|
||||
keychain: MockKeychain(),
|
||||
ordinaryResponderHandshakeTimeout: 0.02
|
||||
)
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||
|
||||
try establishSessions(alice: alice, bob: bob)
|
||||
let forgedMessage1 = try mallory.initiateHandshake(with: bobPeerID)
|
||||
_ = try #require(
|
||||
try bob.processHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: forgedMessage1
|
||||
)
|
||||
)
|
||||
#expect(!bob.hasEstablishedSession(with: alicePeerID))
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
||||
let oldTransport = try alice.encrypt(
|
||||
Data("timeout rollback".utf8),
|
||||
for: bobPeerID
|
||||
)
|
||||
#expect(
|
||||
try bob.decrypt(oldTransport, from: alicePeerID)
|
||||
== Data("timeout rollback".utf8)
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Failed reconnect authorization preserves the established transport")
|
||||
func failedReconnectAuthorizationPreservesSession() 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)
|
||||
// The initiator exchange consumed two authorizations for this peer.
|
||||
for _ in 2..<NoiseSecurityConstants.maxHandshakesPerMinute {
|
||||
do {
|
||||
_ = try alice.initiateHandshake(with: bobPeerID)
|
||||
Issue.record("Expected the established-session guard")
|
||||
} catch NoiseSessionError.alreadyEstablished {
|
||||
// Expected.
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try alice.initiateReconnectHandshake(with: bobPeerID)
|
||||
Issue.record("Expected reconnect authorization to be rate limited")
|
||||
} catch NoiseSecurityError.rateLimitExceeded {
|
||||
// Expected before the working session moves.
|
||||
} catch {
|
||||
Issue.record("Unexpected reconnect authorization error: \(error)")
|
||||
}
|
||||
|
||||
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
||||
let ciphertext = try alice.encrypt(
|
||||
Data("still established".utf8),
|
||||
for: bobPeerID
|
||||
)
|
||||
#expect(
|
||||
try bob.decrypt(ciphertext, from: alicePeerID)
|
||||
== Data("still established".utf8)
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Encrypt without a session requests handshake and decrypt without session fails")
|
||||
func handshakeRequiredAndSessionNotEstablishedErrors() throws {
|
||||
let service = NoiseEncryptionService(keychain: MockKeychain())
|
||||
|
||||
Reference in New Issue
Block a user