mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 22:25:19 +00:00
Fix cached Noise reconnects atomically
This commit is contained in:
@@ -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