Fix cached Noise reconnects atomically

This commit is contained in:
jack
2026-07-25 23:00:28 +02:00
parent 15d8194e28
commit 797494e075
11 changed files with 1011 additions and 111 deletions
+161 -5
View File
@@ -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())