mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 06:45:22 +00:00
* Deflake two iOS-sim CI tests with unbounded timing assumptions Both failed on main-adjacent CI runs during this session's PRs. Neither was a product bug; both asserted things about real time that a loaded runner is under no obligation to honour. **NetworkReachabilityGateTests: a wall-clock upper bound.** test_monitor_duplicateUpdatesDoNotPostponeOfflineCommit slept 500ms for real, then asserted total elapsed time was under 1.4s to prove a duplicate mid-window had not restarted the 1.0s debounce. One CI run took 3.75s. No wall-clock bound can separate "deadline preserved" from "runner is slow", because Task.sleep and the asyncAfter flush are both real time and neither is bounded above. The deadline property was already covered deterministically one level down: test_debounce_duplicateObservationsPreservePendingDeadline drives ReachabilityDebounce with injected timestamps and checks pendingRemaining directly. So the monitor test now asserts only what needs a real monitor — that a duplicate still yields exactly one committed false through the debounce — with an injected clock for the arithmetic and a generous liveness budget. Renamed to say what it actually checks. No coverage lost, and it runs in 0.14s instead of ~3.8s because the real sleep is gone. **NoiseEncryptionServiceTests: injected timeouts that also arm during setup.** #1483 diagnosed and fixed exactly this in the quarantine-restore test, but two sibling tests kept the shape. Their injected ordinaryResponderHandshakeTimeout (0.04 and 0.06) also arms during the establishSessions setup handshake, where bob is the responder — so a preempted runner fires it mid-setup, tears down the half-open responder, and message 3 gets answered as a fresh initiation. The CI failure named setup's own `#expect(finalMessage == nil)` seeing a 96-byte message 2, which is precisely the signature #1483 recorded. Raised both to 1.0s, matching #1483's remedy: the scenarios still need the responder timeout to fire, and it still does, just with room for setup to complete first. Thin 1-second waitUntil budgets in the file now use the shared TestConstants.longTimeout; every one of them backs a positive assertion, so waitUntil still returns the moment the condition holds and nothing gets slower in the passing case. No assertion weakened in either file. Verified 3x sequentially and 3x with all 18 cores saturated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Raise two more starvation-prone test deadlines Both surfaced on the CI runs for this PR and #1487, both in tests neither PR touches, both the same shape as the two already fixed here: a deadline sized for the work rather than for a runner executing many suites at once. VoiceNotePlaybackControllerTests waited 5s for a @MainActor Task that playback schedules for the session acquire and its failure path. When that Task is not scheduled in time the helper reports *two* failures — the wait, and the `!isPlaying` the un-run failure path has not reset yet — which reads like a playback bug rather than a starved scheduler. That is exactly what CI showed. GeoRelayDirectoryTests waited 10s for work the directory runs in Task.detached(priority: .utility); utility priority competes with every other suite. The retry-scheduling case timed out at exactly 10.06s with the retry never scheduled, reading like a missing retry rather than a starved background task. Raised to 30s each with the reasoning recorded at the helper. Both helpers return as soon as their condition holds, so nothing slows down when tests pass — only the genuine-failure case takes longer to report. Verified 3x with all cores saturated: both suites clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Make the flake class unrepeatable, not just fixed Raising four deadlines fixed the four tests that happened to fire. The class was still there: fourteen separate waitUntil helpers, most defaulting to 1.0s, plus wait call sites with literal budgets. The fifth instance would have landed the same way, on someone else's unrelated PR. The rule, now written down in TestConstants.settleTimeout: **a wait deadline is not a latency budget.** It exists so a genuine hang eventually fails the suite, so size it for the worst-case scheduler, never for how long the operation should take. Waits return as soon as their condition holds, so a generous deadline is free in the passing case and only extends genuine failures. - Every wait helper now defaults to TestConstants.settleTimeout (30s), and the literal wait call sites below the floor were converted too — sixteen sites across ten files. - TestTimingHygieneTests enforces it by scanning the test sources: wait defaults and wait call sites must be at least minimumSettleTimeout, and no test may assert an upper bound on elapsed wall-clock time (the assertion that started this, which cannot separate correct behaviour from a slow machine). - Both rules waive per line with "test-timing-ok: <reason>", accepted on the line or in the comment block above it so the reason has room to be a sentence. One legitimate use so far: a NEGATIVE wait in NoiseCoverageTests asserting a promotion has *not* completed within 50ms, where a long deadline would only make the suite slow while still passing. Injected production timeouts are deliberately not matched — the Noise handshake timeouts are the behaviour under test, and short values are correct there. Verified the guard actually fails: a canary file with both banned shapes was flagged with file and line, and the suite went green again once it was removed. Full suite passes with all 18 cores saturated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Close the guard's blind spot: named short timeouts A fifth flake landed on CI while the first guard was in review — GossipSyncBoardTests timing out at 1.03s — and the guard did not catch it, because the deadline was `TestConstants.shortTimeout` rather than a literal. A literals-only scan cannot see a short value behind a symbol, which is the more common way it is written: 81 wait sites used shortTimeout (1s) or defaultTimeout (5s), every one of them below the floor. Rather than guess which of those were safe to raise, all 81 were converted and the suite timed. Runtime went 15s -> 72s, which located the genuine negative waits precisely: ten tests that assert something does *not* happen and therefore always run their deadline out. Measurement instead of a heuristic, since a mis-guess in either direction is invisible — too short reintroduces the flake, too long silently costs a minute a run. Those 28 sites now use `TestConstants.negativeWaitWindow`, a named constant whose doc explains the inverted reasoning: for a negative wait, starvation can only make the assertion *more* likely to hold, so short is correct, and the name states the polarity instead of leaving a bare literal that reads like the mistake. Suite is back to 15.3s. The guard now also rejects `timeout: TestConstants.shortTimeout` and `defaultTimeout`, while accepting `negativeWaitWindow` by name. Re-verified with a canary carrying every banned shape — literal default, both named constants, and an elapsed-time upper bound. All four were flagged with file and line; the suite went green again on removal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Delete shortTimeout now that nothing may use it The hygiene guard bans `TestConstants.shortTimeout` at every wait site and the last users were converted, so Periphery correctly flagged the constant itself as dead and failed CI. Remove it from both TestConstants copies; the banned-name entry stays so the symbol cannot quietly return. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1585 lines
63 KiB
Swift
1585 lines
63 KiB
Swift
//
|
|
// BLEServiceCoreTests.swift
|
|
// bitchatTests
|
|
//
|
|
// Focused BLEService tests for packet handling behavior.
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
import CoreBluetooth
|
|
import BitFoundation
|
|
@testable import bitchat
|
|
|
|
struct BLEServiceCoreTests {
|
|
|
|
@Test
|
|
func duplicatePacket_isDeduped() async throws {
|
|
let ble = makeService()
|
|
let delegate = PublicCaptureDelegate()
|
|
ble.delegate = delegate
|
|
|
|
// Public messages must carry a valid signature from the claimed sender;
|
|
// sign the packet and preseed the sender's signing key so the receiver
|
|
// can verify it (production `sendMessage` signs public broadcasts too).
|
|
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
|
let sender = PeerID(str: "1122334455667788")
|
|
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
let unsigned = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
|
|
let packet = try #require(signer.signPacket(unsigned), "Failed to sign public message")
|
|
let signingKey = signer.getSigningPublicKeyData()
|
|
|
|
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
|
|
let receivedFirst = await TestHelpers.waitUntil(
|
|
{ delegate.publicMessagesSnapshot().count == 1 },
|
|
timeout: TestConstants.longTimeout
|
|
)
|
|
#expect(receivedFirst)
|
|
|
|
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
|
|
let receivedDuplicate = await TestHelpers.waitUntil(
|
|
{ delegate.publicMessagesSnapshot().count > 1 },
|
|
timeout: TestConstants.negativeWaitWindow
|
|
)
|
|
#expect(!receivedDuplicate)
|
|
|
|
let messages = delegate.publicMessagesSnapshot()
|
|
#expect(messages.count == 1)
|
|
#expect(messages.first?.content == "Hello")
|
|
}
|
|
|
|
@Test
|
|
func staleBroadcast_isIgnored() async {
|
|
let ble = makeService()
|
|
let delegate = PublicCaptureDelegate()
|
|
ble.delegate = delegate
|
|
|
|
let sender = PeerID(str: "A1B2C3D4E5F60708")
|
|
let oldTimestamp = UInt64(Date().addingTimeInterval(-901).timeIntervalSince1970 * 1000)
|
|
let packet = makePublicPacket(content: "Old", sender: sender, timestamp: oldTimestamp)
|
|
|
|
ble._test_handlePacket(packet, fromPeerID: sender)
|
|
|
|
let didReceive = await TestHelpers.waitUntil({ !delegate.publicMessagesSnapshot().isEmpty }, timeout: 0.3)
|
|
#expect(!didReceive)
|
|
#expect(delegate.publicMessagesSnapshot().isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func announceSenderMismatch_isRejected() async throws {
|
|
let ble = makeService()
|
|
|
|
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
|
let announcement = AnnouncementPacket(
|
|
nickname: "Spoof",
|
|
noisePublicKey: signer.getStaticPublicKeyData(),
|
|
signingPublicKey: signer.getSigningPublicKeyData(),
|
|
directNeighbors: nil
|
|
)
|
|
let payload = try #require(announcement.encode(), "Failed to encode announcement")
|
|
|
|
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
|
|
let wrongFirst = derivedPeerID.bare.first == "0" ? "1" : "0"
|
|
let wrongBare = String(wrongFirst) + String(derivedPeerID.bare.dropFirst())
|
|
let wrongPeerID = PeerID(str: wrongBare)
|
|
let packet = BitchatPacket(
|
|
type: MessageType.announce.rawValue,
|
|
senderID: Data(hexString: wrongPeerID.id) ?? Data(),
|
|
recipientID: nil,
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: payload,
|
|
signature: nil,
|
|
ttl: 7
|
|
)
|
|
let signed = try #require(signer.signPacket(packet), "Failed to sign announce packet")
|
|
|
|
ble._test_handlePacket(signed, fromPeerID: wrongPeerID, preseedPeer: false)
|
|
|
|
_ = await TestHelpers.waitUntil({ !ble.currentPeerSnapshots().isEmpty }, timeout: 0.3)
|
|
#expect(ble.currentPeerSnapshots().isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func unsignedAndBadSignatureLeaveDoNotEvictOrRelayClaimedPeer() async throws {
|
|
let ble = makeService()
|
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
|
let outbound = OutboundPacketTap()
|
|
ble._test_onOutboundPacket = outbound.record
|
|
|
|
let unsigned = makeLeavePacket(sender: alicePeerID, marker: "unsigned")
|
|
ble._test_handlePacket(
|
|
unsigned,
|
|
fromPeerID: alicePeerID,
|
|
signingPublicKey: alice.getSigningPublicKeyData()
|
|
)
|
|
|
|
let unsignedRelayed = await TestHelpers.waitUntil(
|
|
{ outbound.count(ofType: .leave) > 0 },
|
|
timeout: TestConstants.negativeWaitWindow
|
|
)
|
|
#expect(!unsignedRelayed)
|
|
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
|
|
|
let badSignature = try #require(
|
|
mallory.signPacket(makeLeavePacket(sender: alicePeerID, marker: "bad-signature"))
|
|
)
|
|
ble._test_handlePacket(
|
|
badSignature,
|
|
fromPeerID: alicePeerID,
|
|
signingPublicKey: alice.getSigningPublicKeyData()
|
|
)
|
|
|
|
let badSignatureRelayed = await TestHelpers.waitUntil(
|
|
{ outbound.count(ofType: .leave) > 0 },
|
|
timeout: TestConstants.negativeWaitWindow
|
|
)
|
|
#expect(!badSignatureRelayed)
|
|
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
|
}
|
|
|
|
@Test
|
|
func validSignedLeaveEvictsSessionAndRelays() async throws {
|
|
let ble = makeService()
|
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
|
|
|
// Establish a real session so the leave regression also verifies that
|
|
// stale secure-delivery state is retired, not just the peer-list row.
|
|
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)
|
|
#expect(ble.canDeliverSecurely(to: alicePeerID))
|
|
let centralUUID = "central-valid-leave"
|
|
ble._test_bindCentral(centralUUID, to: alicePeerID)
|
|
ble._test_markNoiseAuthenticatedCentral(centralUUID, to: alicePeerID)
|
|
#expect(ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID))
|
|
|
|
let outbound = OutboundPacketTap()
|
|
ble._test_onOutboundPacket = outbound.record
|
|
let signedLeave = try #require(
|
|
alice.signPacket(makeLeavePacket(sender: alicePeerID, marker: "valid"))
|
|
)
|
|
ble._test_handlePacket(
|
|
signedLeave,
|
|
fromPeerID: alicePeerID,
|
|
signingPublicKey: alice.getSigningPublicKeyData()
|
|
)
|
|
|
|
let evicted = await TestHelpers.waitUntil(
|
|
{
|
|
!ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID }
|
|
&& !ble.canDeliverSecurely(to: alicePeerID)
|
|
&& !ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID)
|
|
},
|
|
timeout: TestConstants.longTimeout
|
|
)
|
|
#expect(evicted)
|
|
let relayed = await TestHelpers.waitUntil(
|
|
{ outbound.count(ofType: .leave) == 1 },
|
|
timeout: TestConstants.longTimeout
|
|
)
|
|
#expect(relayed)
|
|
}
|
|
|
|
@Test
|
|
func ingressAllowsRelayedSenderOnBoundLink() async throws {
|
|
let ble = makeService()
|
|
let boundPeer = PeerID(str: "1122334455667788")
|
|
let relayedSender = PeerID(str: "8899aabbccddeeff")
|
|
let packet = makePublicPacket(
|
|
content: "Relayed",
|
|
sender: relayedSender,
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
|
|
)
|
|
|
|
#expect(ble._test_acceptsIngress(packet: packet, boundPeerID: boundPeer))
|
|
}
|
|
|
|
@Test
|
|
func ingressAllowsDirectAnnounceThatConflictsWithBoundLink() async throws {
|
|
// Peer-ID rotation heal: the announce must reach signature
|
|
// verification, which decides whether the link rebinds.
|
|
let ble = makeService()
|
|
let boundPeer = PeerID(str: "1122334455667788")
|
|
let claimedPeer = PeerID(str: "8899aabbccddeeff")
|
|
let packet = BitchatPacket(
|
|
type: MessageType.announce.rawValue,
|
|
senderID: Data(hexString: claimedPeer.id) ?? Data(),
|
|
recipientID: nil,
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: Data(),
|
|
signature: nil,
|
|
ttl: 7
|
|
)
|
|
|
|
#expect(ble._test_acceptsIngress(packet: packet, boundPeerID: boundPeer))
|
|
}
|
|
|
|
@Test
|
|
func ingressRejectsRequestSyncThatConflictsWithBoundLink() async throws {
|
|
let ble = makeService()
|
|
let boundPeer = PeerID(str: "1122334455667788")
|
|
let claimedPeer = PeerID(str: "8899aabbccddeeff")
|
|
let packet = BitchatPacket(
|
|
type: MessageType.requestSync.rawValue,
|
|
senderID: Data(hexString: claimedPeer.id) ?? Data(),
|
|
recipientID: nil,
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: Data(),
|
|
signature: nil,
|
|
ttl: 0
|
|
)
|
|
|
|
#expect(!ble._test_acceptsIngress(packet: packet, boundPeerID: boundPeer))
|
|
}
|
|
|
|
@Test
|
|
func verifiedDirectAnnounceRebindsRotatedLinkAndRetiresOldPeer() async throws {
|
|
let ble = makeService()
|
|
let oldPeerID = PeerID(str: "1122334455667788")
|
|
let centralUUID = "central-rotation"
|
|
|
|
// A connected peer whose link binding predates its relaunch.
|
|
ble._test_seedConnectedPeer(oldPeerID, nickname: "alice")
|
|
ble._test_bindCentral(centralUUID, to: oldPeerID)
|
|
|
|
// The relaunched device re-announces its rotated identity over the
|
|
// still-open link.
|
|
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
|
let announcement = AnnouncementPacket(
|
|
nickname: "alice",
|
|
noisePublicKey: signer.getStaticPublicKeyData(),
|
|
signingPublicKey: signer.getSigningPublicKeyData(),
|
|
directNeighbors: nil
|
|
)
|
|
let payload = try #require(announcement.encode(), "Failed to encode announcement")
|
|
let newPeerID = PeerID(publicKey: announcement.noisePublicKey)
|
|
let unsigned = BitchatPacket(
|
|
type: MessageType.announce.rawValue,
|
|
senderID: Data(hexString: newPeerID.id) ?? Data(),
|
|
recipientID: nil,
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: payload,
|
|
signature: nil,
|
|
ttl: 7
|
|
)
|
|
let packet = try #require(signer.signPacket(unsigned), "Failed to sign announce packet")
|
|
|
|
#expect(ble._test_recordIngressIfNew(packet: packet, linkID: centralUUID))
|
|
ble._test_handlePacket(packet, fromPeerID: newPeerID, preseedPeer: false)
|
|
|
|
let rebound = await TestHelpers.waitUntil(
|
|
{ ble._test_centralBinding(centralUUID) == newPeerID },
|
|
timeout: TestConstants.longTimeout
|
|
)
|
|
#expect(rebound)
|
|
|
|
let retired = await TestHelpers.waitUntil(
|
|
{
|
|
let peerIDs = ble.currentPeerSnapshots().map(\.peerID)
|
|
return peerIDs.contains(newPeerID) && !peerIDs.contains(oldPeerID)
|
|
},
|
|
timeout: TestConstants.longTimeout
|
|
)
|
|
#expect(retired)
|
|
}
|
|
|
|
@Test
|
|
func replayedDirectAnnounceCannotStealBoundIdentity() async throws {
|
|
let ble = makeService()
|
|
let attackerPeerID = PeerID(str: "1122334455667788")
|
|
let victimLink = "central-victim"
|
|
let attackerLink = "central-attacker"
|
|
|
|
// The victim's identity, genuinely bound on its own link.
|
|
let victimSigner = NoiseEncryptionService(keychain: MockKeychain())
|
|
let announcement = AnnouncementPacket(
|
|
nickname: "victim",
|
|
noisePublicKey: victimSigner.getStaticPublicKeyData(),
|
|
signingPublicKey: victimSigner.getSigningPublicKeyData(),
|
|
directNeighbors: nil
|
|
)
|
|
let payload = try #require(announcement.encode(), "Failed to encode announcement")
|
|
let victimPeerID = PeerID(publicKey: announcement.noisePublicKey)
|
|
let courierStore = CourierStore(persistsToDisk: false)
|
|
ble.courierStore = courierStore
|
|
let carriedEnvelope = CourierEnvelope(
|
|
recipientTag: CourierEnvelope.recipientTag(
|
|
noiseStaticKey: announcement.noisePublicKey,
|
|
epochDay: CourierEnvelope.epochDay(for: Date())
|
|
),
|
|
expiry: UInt64(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000),
|
|
ciphertext: Data(repeating: 0xA5, count: 128)
|
|
)
|
|
#expect(courierStore.deposit(
|
|
carriedEnvelope,
|
|
from: Data(repeating: 0xC0, count: 32),
|
|
tier: .favorite
|
|
))
|
|
let sprayRecipientKey = Data(repeating: 0xB4, count: 32)
|
|
let sprayEnvelope = CourierEnvelope(
|
|
recipientTag: CourierEnvelope.recipientTag(
|
|
noiseStaticKey: sprayRecipientKey,
|
|
epochDay: CourierEnvelope.epochDay(for: Date())
|
|
),
|
|
expiry: UInt64(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000),
|
|
ciphertext: Data(repeating: 0xB5, count: 128),
|
|
copies: 4
|
|
)
|
|
#expect(courierStore.deposit(
|
|
sprayEnvelope,
|
|
from: Data(repeating: 0xC0, count: 32),
|
|
tier: .favorite
|
|
))
|
|
ble._test_seedConnectedPeer(victimPeerID, nickname: "victim")
|
|
ble._test_bindCentral(victimLink, to: victimPeerID)
|
|
ble._test_seedConnectedPeer(attackerPeerID, nickname: "attacker")
|
|
ble._test_bindCentral(attackerLink, to: attackerPeerID)
|
|
|
|
// Preserve the hard case: a valid victim session still exists on the
|
|
// victim's own physical link when the announce is replayed elsewhere.
|
|
let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID)
|
|
let message2 = try #require(
|
|
try victimSigner.processHandshakeMessage(from: ble.myPeerID, message: message1)
|
|
)
|
|
let message3 = try #require(
|
|
try ble._test_noiseProcessHandshakeMessage(from: victimPeerID, message: message2)
|
|
)
|
|
_ = try victimSigner.processHandshakeMessage(from: ble.myPeerID, message: message3)
|
|
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
|
ble._test_markNoiseAuthenticatedCentral(victimLink, to: victimPeerID)
|
|
|
|
// The victim's fresh signed announce replayed on the attacker's bound
|
|
// link with its direct TTL restored (TTL is excluded from signing, so
|
|
// the signature still verifies).
|
|
let unsigned = BitchatPacket(
|
|
type: MessageType.announce.rawValue,
|
|
senderID: Data(hexString: victimPeerID.id) ?? Data(),
|
|
recipientID: nil,
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: payload,
|
|
signature: nil,
|
|
ttl: 7
|
|
)
|
|
let packet = try #require(victimSigner.signPacket(unsigned), "Failed to sign announce packet")
|
|
|
|
#expect(ble._test_recordIngressIfNew(packet: packet, linkID: attackerLink))
|
|
ble._test_handlePacket(packet, fromPeerID: victimPeerID, preseedPeer: false)
|
|
|
|
// The rebind must be refused: the identity already owns a live link.
|
|
let stolen = await TestHelpers.waitUntil(
|
|
{ ble._test_centralBinding(attackerLink) == victimPeerID },
|
|
timeout: 0.3
|
|
)
|
|
#expect(!stolen)
|
|
#expect(ble._test_centralBinding(attackerLink) == attackerPeerID)
|
|
#expect(ble._test_centralBinding(victimLink) == victimPeerID)
|
|
// A valid signature authenticates the announce contents, not the
|
|
// unsigned direct TTL. Without a Noise-authenticated session on the
|
|
// ingress link, the replay must not retire mail or consume spray state.
|
|
#expect(!courierStore.isEmpty)
|
|
await Task.yield()
|
|
await Task.yield()
|
|
let stillEligibleForSpray = courierStore.takeSprayCopies(for: announcement.noisePublicKey)
|
|
#expect(stillEligibleForSpray.map(\.copies) == [2])
|
|
#expect(courierStore.takeEnvelopes(for: announcement.noisePublicKey) == [carriedEnvelope])
|
|
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
|
// And the replay must not retire the link's real bound peer.
|
|
#expect(ble.currentPeerSnapshots().map(\.peerID).contains(attackerPeerID))
|
|
}
|
|
|
|
@Test
|
|
func replayedDirectAnnounceForAbsentPeerNeverYieldsSecureDelivery() async throws {
|
|
// Residual heal-path gap: the victim has NO live link, so the
|
|
// identity-owns-a-link containment cannot refuse the rebind. The
|
|
// replay steals the link binding, and because a successful rebind
|
|
// promotes its new owner to connected (a legitimate rotation heal
|
|
// requires that), the absent victim may read as connected. That
|
|
// forged presence is display-only and accepted — the invariant that
|
|
// holds is that the stolen link can never produce an established
|
|
// Noise session, so MessageRouter's canDeliverSecurely gate routes
|
|
// DMs through retain + courier instead of trusting it outright.
|
|
let ble = makeService()
|
|
let attackerPeerID = PeerID(str: "1122334455667788")
|
|
let attackerLink = "central-attacker-absent-victim"
|
|
ble._test_seedConnectedPeer(attackerPeerID, nickname: "attacker")
|
|
ble._test_bindCentral(attackerLink, to: attackerPeerID)
|
|
|
|
// The absent victim's fresh signed announce, replayed on the
|
|
// attacker's bound link with its direct TTL restored.
|
|
let victimSigner = NoiseEncryptionService(keychain: MockKeychain())
|
|
let announcement = AnnouncementPacket(
|
|
nickname: "victim",
|
|
noisePublicKey: victimSigner.getStaticPublicKeyData(),
|
|
signingPublicKey: victimSigner.getSigningPublicKeyData(),
|
|
directNeighbors: nil
|
|
)
|
|
let payload = try #require(announcement.encode(), "Failed to encode announcement")
|
|
let victimPeerID = PeerID(publicKey: announcement.noisePublicKey)
|
|
let unsigned = BitchatPacket(
|
|
type: MessageType.announce.rawValue,
|
|
senderID: Data(hexString: victimPeerID.id) ?? Data(),
|
|
recipientID: nil,
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: payload,
|
|
signature: nil,
|
|
ttl: 7
|
|
)
|
|
let packet = try #require(victimSigner.signPacket(unsigned), "Failed to sign announce packet")
|
|
|
|
#expect(ble._test_recordIngressIfNew(packet: packet, linkID: attackerLink))
|
|
ble._test_handlePacket(packet, fromPeerID: victimPeerID, preseedPeer: false)
|
|
|
|
// The rebind steals the link (no live link owns the victim's
|
|
// identity, so containment cannot refuse) …
|
|
let rebound = await TestHelpers.waitUntil(
|
|
{ ble._test_centralBinding(attackerLink) == victimPeerID },
|
|
timeout: TestConstants.longTimeout
|
|
)
|
|
#expect(rebound)
|
|
// … and the promote marks the absent victim connected: the accepted,
|
|
// display-only forged-presence residue (documented at
|
|
// BLEAnnounceHandler's linkBoundToOtherPeer check) …
|
|
let forgedPresence = await TestHelpers.waitUntil(
|
|
{ ble.isPeerConnected(victimPeerID) },
|
|
timeout: TestConstants.longTimeout
|
|
)
|
|
#expect(forgedPresence)
|
|
// … but secure delivery stays impossible — the DM gate holds, and
|
|
// MessageRouter retains + couriers instead of trusting the link.
|
|
#expect(!ble.canDeliverSecurely(to: victimPeerID))
|
|
}
|
|
|
|
@Test
|
|
func replayedDirectAnnounceWithStaleVictimSessionCannotBridgeThroughForeignLink() async throws {
|
|
let ble = makeService()
|
|
// Keep announce handling out of the carried-mail path: the regression
|
|
// is specifically BridgeCourierService's direct delivery preflight.
|
|
ble.courierStore = CourierStore(persistsToDisk: false)
|
|
let attackerPeerID = PeerID(str: "1122334455667788")
|
|
let attackerLink = "central-attacker-stale-victim-session"
|
|
ble._test_seedConnectedPeer(attackerPeerID, nickname: "attacker")
|
|
ble._test_bindCentral(attackerLink, to: attackerPeerID)
|
|
|
|
let victim = NoiseEncryptionService(keychain: MockKeychain())
|
|
let announcement = AnnouncementPacket(
|
|
nickname: "victim",
|
|
noisePublicKey: victim.getStaticPublicKeyData(),
|
|
signingPublicKey: victim.getSigningPublicKeyData(),
|
|
directNeighbors: nil
|
|
)
|
|
let victimPeerID = PeerID(publicKey: announcement.noisePublicKey)
|
|
|
|
// Establish a real peer-level victim session without associating it
|
|
// with the attacker's physical link. This is the stale-session case
|
|
// that a plain `canDeliverSecurely` check cannot distinguish.
|
|
let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID)
|
|
let message2 = try #require(
|
|
try victim.processHandshakeMessage(from: ble.myPeerID, message: message1)
|
|
)
|
|
let message3 = try #require(
|
|
try ble._test_noiseProcessHandshakeMessage(from: victimPeerID, message: message2)
|
|
)
|
|
_ = try victim.processHandshakeMessage(from: ble.myPeerID, message: message3)
|
|
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
|
|
|
let payload = try #require(announcement.encode(), "Failed to encode announcement")
|
|
let unsigned = BitchatPacket(
|
|
type: MessageType.announce.rawValue,
|
|
senderID: Data(hexString: victimPeerID.id) ?? Data(),
|
|
recipientID: nil,
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: payload,
|
|
signature: nil,
|
|
ttl: 7
|
|
)
|
|
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 announcePaused = await TestHelpers.waitUntil(
|
|
{ rebindGate.hasPaused },
|
|
timeout: TestConstants.longTimeout
|
|
)
|
|
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) }
|
|
let envelope = CourierEnvelope(
|
|
recipientTag: CourierEnvelope.recipientTag(
|
|
noiseStaticKey: announcement.noisePublicKey,
|
|
epochDay: CourierEnvelope.epochDay(for: Date())
|
|
),
|
|
expiry: UInt64(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000),
|
|
ciphertext: Data(repeating: 0xA5, count: 128)
|
|
)
|
|
|
|
#expect(!ble.deliverBridgedEnvelope(envelope, to: victimPeerID))
|
|
// Reject before even entering the outbound pipeline: otherwise a
|
|
// real attacker CBCentral could accept the opaque courier packet and
|
|
// cause the relay drop's persisted seen ID to be consumed forever.
|
|
#expect(outbound.count(ofType: .courierEnvelope) == 0)
|
|
}
|
|
|
|
@Test
|
|
func replacementXXMessageOneWithPayloadCannotAuthenticateIngressLink() async throws {
|
|
let ble = makeService()
|
|
let victim = NoiseEncryptionService(keychain: MockKeychain())
|
|
let victimPeerID = PeerID(publicKey: victim.getStaticPublicKeyData())
|
|
|
|
// Preserve a working victim session while an unauthenticated
|
|
// replacement candidate arrives on a newly bound physical link.
|
|
// Establish BLE as responder so the replacement candidate below is
|
|
// not coalesced by the initiator-completion grace path.
|
|
let message1 = try victim.initiateHandshake(with: ble.myPeerID)
|
|
let message2 = try #require(
|
|
try ble._test_noiseProcessHandshakeMessage(
|
|
from: victimPeerID,
|
|
message: message1
|
|
)
|
|
)
|
|
let message3 = try #require(
|
|
try victim.processHandshakeMessage(
|
|
from: ble.myPeerID,
|
|
message: message2
|
|
)
|
|
)
|
|
_ = try ble._test_noiseProcessHandshakeMessage(
|
|
from: victimPeerID,
|
|
message: message3
|
|
)
|
|
await ble._test_drainNoiseMessagePipeline()
|
|
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
|
|
|
let centralUUID = "central-replacement-xx-message-one"
|
|
ble._test_bindCentral(centralUUID, to: victimPeerID)
|
|
#expect(
|
|
!ble._test_isNoiseAuthenticatedCentral(
|
|
centralUUID,
|
|
for: victimPeerID
|
|
)
|
|
)
|
|
|
|
// XX message one may legally carry a payload, so its length is not a
|
|
// reliable signal that the replacement handshake completed.
|
|
let unauthenticatedInitiator = NoiseHandshakeState(
|
|
role: .initiator,
|
|
pattern: .XX,
|
|
keychain: MockKeychain()
|
|
)
|
|
let replacementMessage1 = try unauthenticatedInitiator.writeMessage(
|
|
payload: Data([0xA5])
|
|
)
|
|
#expect(
|
|
replacementMessage1.count
|
|
> NoiseSecurityConstants.xxInitialMessageSize
|
|
)
|
|
|
|
let packet = BitchatPacket(
|
|
type: MessageType.noiseHandshake.rawValue,
|
|
senderID: Data(hexString: victimPeerID.id) ?? Data(),
|
|
recipientID: Data(hexString: ble.myPeerID.id),
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: replacementMessage1,
|
|
signature: nil,
|
|
ttl: TransportConfig.messageTTLDefault
|
|
)
|
|
#expect(
|
|
ble._test_recordIngressIfNew(
|
|
packet: packet,
|
|
linkID: centralUUID
|
|
)
|
|
)
|
|
|
|
let outbound = OutboundPacketTap()
|
|
ble._test_onOutboundPacket = outbound.record
|
|
ble._test_handlePacket(
|
|
packet,
|
|
fromPeerID: victimPeerID,
|
|
preseedPeer: false
|
|
)
|
|
|
|
// Waiting for the responder's message two proves the candidate was
|
|
// processed before checking its exact authentication result.
|
|
let candidateProcessed = await TestHelpers.waitUntil(
|
|
{ outbound.count(ofType: .noiseHandshake) == 1 },
|
|
timeout: TestConstants.longTimeout
|
|
)
|
|
#expect(candidateProcessed)
|
|
#expect(
|
|
!ble._test_isNoiseAuthenticatedCentral(
|
|
centralUUID,
|
|
for: 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 failedInboundReconnectRestoresAndDrainsWaitingWorkOnce() async throws {
|
|
let ble = makeService()
|
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
|
|
|
// Establish BLE as responder so the following inbound reconnect is
|
|
// not intentionally coalesced by the initiator-completion grace path.
|
|
let message1 = try alice.initiateHandshake(with: ble.myPeerID)
|
|
let message2 = try #require(
|
|
try ble._test_noiseProcessHandshakeMessage(
|
|
from: alicePeerID,
|
|
message: message1
|
|
)
|
|
)
|
|
let message3 = try #require(
|
|
try alice.processHandshakeMessage(
|
|
from: ble.myPeerID,
|
|
message: message2
|
|
)
|
|
)
|
|
_ = try ble._test_noiseProcessHandshakeMessage(
|
|
from: alicePeerID,
|
|
message: message3
|
|
)
|
|
await ble._test_drainNoiseMessagePipeline()
|
|
#expect(ble.canDeliverSecurely(to: alicePeerID))
|
|
// The establishment transition enqueues its forced announce as a
|
|
// separate serialized phase; drain again so it lands before the tap
|
|
// and cannot masquerade as restore-driven announce traffic below.
|
|
await ble._test_drainNoiseMessagePipeline()
|
|
|
|
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
|
|
&& PeerID(hexData: $0.senderID) == ble.myPeerID
|
|
&& $0.payload.count
|
|
!= NoiseSecurityConstants.xxInitialMessageSize
|
|
}
|
|
},
|
|
timeout: TestConstants.longTimeout
|
|
)
|
|
try #require(responseReady)
|
|
let forgedMessage2 = try #require(
|
|
outbound.snapshot().first {
|
|
$0.type == MessageType.noiseHandshake.rawValue
|
|
&& PeerID(hexData: $0.senderID) == ble.myPeerID
|
|
&& $0.payload.count
|
|
!= NoiseSecurityConstants.xxInitialMessageSize
|
|
}?.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 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) + 2,
|
|
payload: forgedMessage3,
|
|
signature: nil,
|
|
ttl: 7
|
|
)
|
|
ble._test_handlePacket(thirdPacket, fromPeerID: alicePeerID)
|
|
|
|
// 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) >= 2 },
|
|
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 == 2)
|
|
#expect(
|
|
plaintexts.filter {
|
|
$0.first == NoisePayloadType.authenticatedPeerState.rawValue
|
|
}.isEmpty
|
|
)
|
|
#expect(
|
|
plaintexts.filter {
|
|
$0.first == NoisePayloadType.privateMessage.rawValue
|
|
}.count == 1
|
|
)
|
|
#expect(
|
|
plaintexts.filter {
|
|
$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)
|
|
}
|
|
|
|
/// The message-loss interleaving behind a legitimate peer restart: the
|
|
/// remote completes a replacement handshake (discarding the old keys)
|
|
/// but its completion never arrives, so the local responder timeout
|
|
/// restores the quarantined OLD generation and requests one convergence
|
|
/// retry — both dispatched unordered. When the restore handler wins the
|
|
/// race, it must NOT drain the pending private-message/typed-payload
|
|
/// queues under the restored keys the remote no longer holds; the drain
|
|
/// has to wait for the convergence handshake and use its new session.
|
|
@Test
|
|
func timeoutRestoredSessionDefersQueueDrainUntilConvergence() async throws {
|
|
let ble = makeService(noiseResponderHandshakeTimeout: 0.3)
|
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
|
|
|
// Establish BLE as responder so the inbound reconnect below is not
|
|
// coalesced by the initiator-completion grace path.
|
|
let message1 = try alice.initiateHandshake(with: ble.myPeerID)
|
|
let message2 = try #require(
|
|
try ble._test_noiseProcessHandshakeMessage(
|
|
from: alicePeerID,
|
|
message: message1
|
|
)
|
|
)
|
|
let message3 = try #require(
|
|
try alice.processHandshakeMessage(
|
|
from: ble.myPeerID,
|
|
message: message2
|
|
)
|
|
)
|
|
_ = try ble._test_noiseProcessHandshakeMessage(
|
|
from: alicePeerID,
|
|
message: message3
|
|
)
|
|
await ble._test_drainNoiseMessagePipeline()
|
|
#expect(ble.canDeliverSecurely(to: alicePeerID))
|
|
|
|
// The convergence retry only prepares for reachable peers.
|
|
ble._test_seedConnectedPeer(alicePeerID, nickname: "Alice")
|
|
|
|
let reconciled = SessionReconcileCounter()
|
|
ble._test_onPrivateMediaSessionReconciled = reconciled.record
|
|
// Park the convergence-recovery callback on its global-queue thread
|
|
// before it can enqueue onto messageQueue: the restore handler
|
|
// deterministically wins the dispatch race this test exercises.
|
|
let recoveryGate = HandshakeRecoveryEnqueueGate()
|
|
defer { recoveryGate.release() }
|
|
ble._test_beforeHandshakeRecoveryEnqueued = { _ in recoveryGate.pause() }
|
|
let outbound = OutboundPacketTap()
|
|
ble._test_onOutboundPacket = outbound.record
|
|
|
|
// Park the traffic the race would lose directly in the pending
|
|
// queues — the same place live sends land during quarantine — so no
|
|
// assertion below depends on outrunning the responder deadline under
|
|
// parallel test load. Nothing drains these queues while the current
|
|
// session stays untouched.
|
|
ble._test_enqueuePendingPrivateMessage(
|
|
content: "deferred private message",
|
|
messageID: "deferred-pm-\(UUID().uuidString)",
|
|
for: alicePeerID
|
|
)
|
|
ble._test_enqueuePendingNoisePayload(
|
|
NoisePayload(
|
|
type: .groupInvite,
|
|
data: Data("queued-during-quarantine".utf8)
|
|
).encode(),
|
|
transferId: "deferred-invite-\(UUID().uuidString)",
|
|
for: alicePeerID
|
|
)
|
|
|
|
// An unauthenticated message 1 quarantines the established transport.
|
|
// Its message 3 never arrives, modeling the restarted peer whose
|
|
// completion was lost after it already discarded the old keys.
|
|
let reconnectMessage1 = try mallory.initiateHandshake(with: ble.myPeerID)
|
|
let reconnectPacket = BitchatPacket(
|
|
type: MessageType.noiseHandshake.rawValue,
|
|
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
|
recipientID: Data(hexString: ble.myPeerID.id),
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000),
|
|
payload: reconnectMessage1,
|
|
signature: nil,
|
|
ttl: 7
|
|
)
|
|
ble._test_handlePacket(reconnectPacket, fromPeerID: alicePeerID)
|
|
// The responder's message 2 is a monotonic quarantine signal; the
|
|
// secure-delivery dip itself only lasts until the responder deadline,
|
|
// which parallel test load can outrun. (The recovery gate keeps the
|
|
// convergence retry's message 1 out of the tap until released.)
|
|
let responderReady = await TestHelpers.waitUntil(
|
|
{ outbound.count(ofType: .noiseHandshake) >= 1 },
|
|
timeout: TestConstants.longTimeout
|
|
)
|
|
try #require(responderReady)
|
|
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
|
|
|
|
// The responder timeout restores the quarantined generation; the
|
|
// gate guarantees its handler runs before the convergence retry.
|
|
let restoreRan = await TestHelpers.waitUntil(
|
|
{ reconciled.count(for: alicePeerID) == 1 },
|
|
timeout: TestConstants.longTimeout
|
|
)
|
|
try #require(restoreRan)
|
|
#expect(ble.canDeliverSecurely(to: alicePeerID))
|
|
await ble._test_drainNoiseMessagePipeline()
|
|
// The parked queues must not have been encrypted under the restored
|
|
// old generation the remote may no longer be able to read.
|
|
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
|
|
|
|
// Release the mandatory convergence retry: it retires the restored
|
|
// session and starts a fresh XX exchange with the live peer.
|
|
recoveryGate.release()
|
|
let retryStarted = await TestHelpers.waitUntil(
|
|
{
|
|
outbound.snapshot().contains {
|
|
$0.type == MessageType.noiseHandshake.rawValue
|
|
&& PeerID(hexData: $0.senderID) == ble.myPeerID
|
|
&& $0.payload.count
|
|
== NoiseSecurityConstants.xxInitialMessageSize
|
|
}
|
|
},
|
|
timeout: TestConstants.longTimeout
|
|
)
|
|
try #require(retryStarted)
|
|
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
|
|
let retryMessage1 = try #require(
|
|
outbound.snapshot().last {
|
|
$0.type == MessageType.noiseHandshake.rawValue
|
|
&& PeerID(hexData: $0.senderID) == ble.myPeerID
|
|
&& $0.payload.count
|
|
== NoiseSecurityConstants.xxInitialMessageSize
|
|
}?.payload
|
|
)
|
|
|
|
// Alice answers the retry as the restarted peer she models: her old
|
|
// session is gone, so the retry is a fresh responder exchange (and
|
|
// never the initiator-completion grace deferral, whose lower-peerID
|
|
// arm would otherwise coalesce the retry for random key orderings).
|
|
alice.clearSession(for: ble.myPeerID)
|
|
let retryMessage2 = try #require(
|
|
try alice.processHandshakeMessage(
|
|
from: ble.myPeerID,
|
|
message: retryMessage1
|
|
)
|
|
)
|
|
let retryPacket = 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: retryMessage2,
|
|
signature: nil,
|
|
ttl: 7
|
|
)
|
|
ble._test_handlePacket(retryPacket, fromPeerID: alicePeerID)
|
|
let drained = await TestHelpers.waitUntil(
|
|
{ outbound.count(ofType: .noiseEncrypted) >= 3 },
|
|
timeout: TestConstants.longTimeout
|
|
)
|
|
try #require(drained)
|
|
await ble._test_drainNoiseMessagePipeline()
|
|
|
|
// Alice completes with message 3, then must be able to decrypt every
|
|
// drained payload — proving nothing left under the old generation.
|
|
let retryMessage3 = try #require(
|
|
outbound.snapshot().last {
|
|
$0.type == MessageType.noiseHandshake.rawValue
|
|
&& PeerID(hexData: $0.senderID) == ble.myPeerID
|
|
&& $0.payload.count
|
|
!= NoiseSecurityConstants.xxInitialMessageSize
|
|
}?.payload
|
|
)
|
|
_ = try alice.processHandshakeMessage(
|
|
from: ble.myPeerID,
|
|
message: retryMessage3
|
|
)
|
|
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
|
|
/// bound to the OLD ID, so its registry upsert stores the new peer
|
|
/// disconnected. The successful rebind must promote it: a healed
|
|
/// rotation with a live link has to read as connected again for routing
|
|
/// and outbox flushes.
|
|
@Test
|
|
func rotationHealPromotesRotatedPeerToConnected() async throws {
|
|
let ble = makeService()
|
|
let oldPeerID = PeerID(str: "1122334455667788")
|
|
let centralUUID = "central-rotation-promote"
|
|
|
|
ble._test_seedConnectedPeer(oldPeerID, nickname: "alice")
|
|
ble._test_bindCentral(centralUUID, to: oldPeerID)
|
|
|
|
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
|
let announcement = AnnouncementPacket(
|
|
nickname: "alice",
|
|
noisePublicKey: signer.getStaticPublicKeyData(),
|
|
signingPublicKey: signer.getSigningPublicKeyData(),
|
|
directNeighbors: nil
|
|
)
|
|
let payload = try #require(announcement.encode(), "Failed to encode announcement")
|
|
let newPeerID = PeerID(publicKey: announcement.noisePublicKey)
|
|
let unsigned = BitchatPacket(
|
|
type: MessageType.announce.rawValue,
|
|
senderID: Data(hexString: newPeerID.id) ?? Data(),
|
|
recipientID: nil,
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: payload,
|
|
signature: nil,
|
|
ttl: 7
|
|
)
|
|
let packet = try #require(signer.signPacket(unsigned), "Failed to sign announce packet")
|
|
|
|
#expect(ble._test_recordIngressIfNew(packet: packet, linkID: centralUUID))
|
|
ble._test_handlePacket(packet, fromPeerID: newPeerID, preseedPeer: false)
|
|
|
|
let rebound = await TestHelpers.waitUntil(
|
|
{ ble._test_centralBinding(centralUUID) == newPeerID },
|
|
timeout: TestConstants.longTimeout
|
|
)
|
|
#expect(rebound)
|
|
|
|
let connected = await TestHelpers.waitUntil(
|
|
{ ble.isPeerConnected(newPeerID) },
|
|
timeout: TestConstants.longTimeout
|
|
)
|
|
#expect(connected)
|
|
}
|
|
|
|
/// Noise sessions are keyed by the short wire ID, but routers may key
|
|
/// sends by the full 64-hex Noise key (favorites resolution does). The
|
|
/// secure-delivery gate must normalize like isPeerConnected, or an
|
|
/// established session is misread as insecure and every DM needlessly
|
|
/// retains + couriers until an ack.
|
|
@Test
|
|
func canDeliverSecurelyNormalizesFullNoiseKeyPeerIDs() async throws {
|
|
let ble = makeService()
|
|
let remote = NoiseEncryptionService(keychain: MockKeychain())
|
|
let remoteKey = remote.getStaticPublicKeyData()
|
|
let shortID = PeerID(publicKey: remoteKey)
|
|
let fullKeyID = PeerID(hexData: remoteKey)
|
|
#expect(fullKeyID.toShort() == shortID)
|
|
#expect(!ble.canDeliverSecurely(to: shortID))
|
|
|
|
// Full XX handshake; the local side keys the session by the short
|
|
// wire ID, exactly as packets present it in production.
|
|
let m1 = try ble._test_noiseInitiateHandshake(with: shortID)
|
|
let m2 = try #require(try remote.processHandshakeMessage(from: ble.myPeerID, message: m1))
|
|
let m3 = try #require(try ble._test_noiseProcessHandshakeMessage(from: shortID, message: m2))
|
|
_ = try remote.processHandshakeMessage(from: ble.myPeerID, message: m3)
|
|
|
|
#expect(ble.canDeliverSecurely(to: shortID))
|
|
#expect(ble.canDeliverSecurely(to: fullKeyID))
|
|
}
|
|
|
|
@Test
|
|
func ingressRejectsSelfLoopbackBeforeSpoofChecks() async throws {
|
|
let ble = makeService()
|
|
let packet = makePublicPacket(
|
|
content: "Loopback",
|
|
sender: ble.myPeerID,
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
|
|
)
|
|
|
|
#expect(!ble._test_acceptsIngress(packet: packet, boundPeerID: PeerID(str: "1122334455667788")))
|
|
}
|
|
|
|
@Test
|
|
func ingressAllowsSelfAuthoredRSRWithTTLZeroFromBoundPeer() async throws {
|
|
let ble = makeService()
|
|
var packet = makePublicPacket(
|
|
content: "Recovered by sync",
|
|
sender: ble.myPeerID,
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
|
|
)
|
|
packet.isRSR = true
|
|
packet.ttl = 0
|
|
|
|
#expect(ble._test_acceptsIngress(packet: packet, boundPeerID: PeerID(str: "1122334455667788")))
|
|
}
|
|
|
|
@Test
|
|
func ingressRecordSuppressesSecondLinkDuplicate() async throws {
|
|
let ble = makeService()
|
|
let packet = makePublicPacket(
|
|
content: "Duplicate link copy",
|
|
sender: PeerID(str: "1122334455667788"),
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
|
|
)
|
|
|
|
#expect(ble._test_recordIngressIfNew(packet: packet, linkID: "central-a"))
|
|
#expect(!ble._test_recordIngressIfNew(packet: packet, linkID: "central-b"))
|
|
}
|
|
|
|
@Test
|
|
func panicReset_rotatesPeerIDDerivedFromNewNoiseFingerprint() async throws {
|
|
let ble = makeService()
|
|
let originalPeerID = ble.myPeerID
|
|
let originalFingerprint = ble.noiseIdentityFingerprint()
|
|
#expect(originalPeerID == PeerID(str: originalFingerprint.prefix(16)))
|
|
|
|
ble.resetIdentityForPanic(currentNickname: "anon")
|
|
|
|
// The Noise identity is regenerated and the peer ID swaps with it
|
|
// (atomically, behind a messageQueue barrier).
|
|
let newFingerprint = ble.noiseIdentityFingerprint()
|
|
#expect(newFingerprint != originalFingerprint)
|
|
#expect(ble.myPeerID != originalPeerID)
|
|
#expect(ble.myPeerID == PeerID(str: newFingerprint.prefix(16)))
|
|
}
|
|
|
|
@Test
|
|
func panicSuspension_dropsLateOutboundWorkUntilCommit() async {
|
|
let ble = makeService()
|
|
let outbound = OutboundPacketTap()
|
|
ble._test_onOutboundPacket = outbound.record
|
|
let packet = makePublicPacket(
|
|
content: "late callback",
|
|
sender: ble.myPeerID,
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
|
|
)
|
|
|
|
ble.suspendForPanicReset()
|
|
ble.sendPacket(packet)
|
|
#expect(outbound.count(ofType: .message) == 0)
|
|
|
|
ble.completePanicReset(restartServices: false)
|
|
ble.sendPacket(packet)
|
|
#expect(outbound.count(ofType: .message) == 1)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func panicSuspension_invalidatesQueuedMainActorIngress() async {
|
|
let ble = makeService()
|
|
let delegate = TransportEventCaptureDelegate()
|
|
ble.eventDelegate = delegate
|
|
let message = BitchatMessage(
|
|
id: "pre-panic-ingress",
|
|
sender: "Peer",
|
|
content: "must not survive panic",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: "Me",
|
|
senderPeerID: PeerID(str: "1122334455667788")
|
|
)
|
|
|
|
// The test already owns MainActor, so this task cannot run until the
|
|
// synchronous panic boundary below has invalidated its generation.
|
|
ble._test_emitTransportEvent(.messageReceived(message))
|
|
ble.suspendForPanicReset()
|
|
await Task.yield()
|
|
#expect(delegate.messageIDs.isEmpty)
|
|
|
|
ble.completePanicReset(restartServices: false)
|
|
ble._test_emitTransportEvent(.messageReceived(message))
|
|
await Task.yield()
|
|
#expect(delegate.messageIDs == [message.id])
|
|
}
|
|
|
|
@Test @MainActor
|
|
func panicSuspension_rejectsPausedBLEReceiveBeforeMessageQueueHandoff() async {
|
|
let ble = makeService()
|
|
let gate = ReceivePacketHandoffGate()
|
|
ble._test_beforeReceivePacketHandoff = gate.pause
|
|
ble._test_onReceivePacketHandoff = gate.recordHandoff
|
|
defer {
|
|
gate.release()
|
|
ble._test_beforeReceivePacketHandoff = nil
|
|
ble._test_onReceivePacketHandoff = nil
|
|
}
|
|
|
|
let sender = PeerID(str: "1122334455667788")
|
|
let packet = makePublicPacket(
|
|
content: "must not cross panic",
|
|
sender: sender,
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
|
|
)
|
|
ble._test_handlePacketFromBLEQueue(packet, fromPeerID: sender)
|
|
#expect(await TestHelpers.waitUntil(
|
|
{ gate.hasPaused },
|
|
timeout: TestConstants.longTimeout
|
|
))
|
|
|
|
// Panic closes the lifecycle before waiting for the paused bleQueue
|
|
// callback. Releasing it afterward lets the callback enqueue its
|
|
// messageQueue handoff, where the captured generation must be rejected
|
|
// before packet processing starts.
|
|
let panicIngressObserver = PanicIngressObserver(service: ble)
|
|
let didObservePanicClosure = await withCheckedContinuation { continuation in
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
let didObserveClosure = panicIngressObserver.waitUntilClosed(
|
|
timeout: TestConstants.settleTimeout
|
|
)
|
|
gate.release()
|
|
continuation.resume(returning: didObserveClosure)
|
|
}
|
|
ble.suspendForPanicReset()
|
|
}
|
|
|
|
#expect(didObservePanicClosure)
|
|
#expect(gate.handoffCount == 0)
|
|
|
|
// A packet captured under the reopened lifecycle still crosses the
|
|
// same handoff, proving the test did not merely disable the hook.
|
|
ble.completePanicReset(restartServices: false)
|
|
ble._test_handlePacketFromBLEQueue(packet, fromPeerID: sender)
|
|
#expect(await TestHelpers.waitUntil(
|
|
{ gate.handoffCount == 1 },
|
|
timeout: TestConstants.longTimeout
|
|
))
|
|
}
|
|
|
|
@Test @MainActor
|
|
func panicSuspension_finalizesStaleTransportEventsAsRejected() async {
|
|
let ble = makeService()
|
|
let delegate = TransportEventCaptureDelegate()
|
|
ble.eventDelegate = delegate
|
|
let message = BitchatMessage(
|
|
id: "pre-panic-finalization",
|
|
sender: "Peer",
|
|
content: "must be rejected",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: "Me",
|
|
senderPeerID: PeerID(str: "1122334455667788")
|
|
)
|
|
var completions = 0
|
|
var outcomes: [TransportEventDeliveryOutcome] = []
|
|
|
|
ble._test_emitTransportEvent(
|
|
.messageReceived(message),
|
|
completion: { completions += 1 },
|
|
finalization: { outcomes.append($0) }
|
|
)
|
|
ble.suspendForPanicReset()
|
|
ble._test_emitTransportEvent(
|
|
.messageReceived(message),
|
|
completion: { completions += 1 },
|
|
finalization: { outcomes.append($0) }
|
|
)
|
|
for _ in 0..<4 {
|
|
await Task.yield()
|
|
}
|
|
|
|
#expect(delegate.messageIDs.isEmpty)
|
|
#expect(completions == 0)
|
|
#expect(outcomes == [.rejected, .rejected])
|
|
}
|
|
|
|
@Test
|
|
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
|
|
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
|
|
|
#expect(BLEService._test_shouldRediscoverBitChatService(
|
|
invalidatedServiceUUIDs: [BLEService.serviceUUID],
|
|
cachedServiceUUIDs: [otherService]
|
|
))
|
|
}
|
|
|
|
@Test
|
|
func modifiedServices_rediscoverWhenCachedServicesNoLongerIncludeBitChat() async throws {
|
|
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
|
|
|
#expect(BLEService._test_shouldRediscoverBitChatService(
|
|
invalidatedServiceUUIDs: [otherService],
|
|
cachedServiceUUIDs: [otherService]
|
|
))
|
|
}
|
|
|
|
@Test
|
|
func modifiedServices_ignoreUnrelatedInvalidationWhenBitChatIsStillCached() async throws {
|
|
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
|
|
|
#expect(!BLEService._test_shouldRediscoverBitChatService(
|
|
invalidatedServiceUUIDs: [otherService],
|
|
cachedServiceUUIDs: [BLEService.serviceUUID, otherService]
|
|
))
|
|
}
|
|
|
|
/// Pings are unsigned, so their claimed sender is attacker-controlled.
|
|
/// The pong budget must be keyed on the ingress link (the directly
|
|
/// connected peer that delivered the packet): rotating forged sender IDs
|
|
/// over one link exhausts one budget instead of resetting it, so a single
|
|
/// malicious link cannot turn /ping into an amplification primitive.
|
|
@Test
|
|
func meshPingResponseBudget_isPerIngressLinkNotClaimedSender() async throws {
|
|
let ble = makeService()
|
|
let outbound = OutboundPacketTap()
|
|
ble._test_onOutboundPacket = outbound.record
|
|
|
|
let link = PeerID(str: "1122334455667788")
|
|
let budget = TransportConfig.meshPingInboundMaxPerLink
|
|
let myRecipientData = try #require(Data(hexString: ble.myPeerID.id))
|
|
|
|
for i in 0..<(budget * 2) {
|
|
// A fresh forged sender for every ping, all arriving on one link.
|
|
let forgedSender = PeerID(str: String(format: "%016x", 0xA0_0000 + i))
|
|
var nonce = Data(repeating: 0, count: MeshPingPayload.nonceLength)
|
|
nonce[0] = UInt8(i)
|
|
let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 7))
|
|
let packet = BitchatPacket(
|
|
type: MessageType.ping.rawValue,
|
|
senderID: Data(hexString: forgedSender.id) ?? Data(),
|
|
recipientID: myRecipientData,
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: payload.encode(),
|
|
signature: nil,
|
|
ttl: 7
|
|
)
|
|
ble._test_handlePacket(packet, fromPeerID: link, preseedPeer: false)
|
|
}
|
|
|
|
let reachedBudget = await TestHelpers.waitUntil(
|
|
{ outbound.count(ofType: .pong) >= budget },
|
|
timeout: TestConstants.longTimeout
|
|
)
|
|
#expect(reachedBudget)
|
|
// Give any over-budget pong a chance to surface, then confirm the
|
|
// rotated sender IDs never bought a sixth response.
|
|
let exceededBudget = await TestHelpers.waitUntil(
|
|
{ outbound.count(ofType: .pong) > budget },
|
|
timeout: TestConstants.negativeWaitWindow
|
|
)
|
|
#expect(!exceededBudget)
|
|
#expect(outbound.count(ofType: .pong) == budget)
|
|
}
|
|
}
|
|
|
|
/// Thread-safe capture of packets leaving the service under test.
|
|
private final class OutboundPacketTap {
|
|
private let lock = NSLock()
|
|
private var packets: [BitchatPacket] = []
|
|
|
|
func record(_ packet: BitchatPacket) {
|
|
lock.lock(); packets.append(packet); lock.unlock()
|
|
}
|
|
|
|
func count(ofType type: MessageType) -> Int {
|
|
lock.lock(); defer { lock.unlock() }
|
|
return packets.filter { $0.type == type.rawValue }.count
|
|
}
|
|
|
|
func snapshot() -> [BitchatPacket] {
|
|
lock.lock(); defer { lock.unlock() }
|
|
return packets
|
|
}
|
|
}
|
|
|
|
/// Blocks the convergence-recovery callback on its global-queue thread so a
|
|
/// test can prove the quarantine-restore handler wins the messageQueue race.
|
|
private final class HandshakeRecoveryEnqueueGate: @unchecked Sendable {
|
|
private let condition = NSCondition()
|
|
private var released = false
|
|
|
|
func pause() {
|
|
condition.lock()
|
|
while !released {
|
|
condition.wait()
|
|
}
|
|
condition.unlock()
|
|
}
|
|
|
|
func release() {
|
|
condition.lock()
|
|
released = true
|
|
condition.broadcast()
|
|
condition.unlock()
|
|
}
|
|
}
|
|
|
|
/// Thread-safe counter for `_test_onPrivateMediaSessionReconciled` firings.
|
|
private final class SessionReconcileCounter: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var reconciles: [PeerID] = []
|
|
|
|
func record(_ peerID: PeerID) {
|
|
lock.lock()
|
|
reconciles.append(peerID)
|
|
lock.unlock()
|
|
}
|
|
|
|
func count(for peerID: PeerID) -> Int {
|
|
lock.lock(); defer { lock.unlock() }
|
|
return reconciles.filter { $0 == peerID }.count
|
|
}
|
|
}
|
|
|
|
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 {
|
|
private let condition = NSCondition()
|
|
private var paused = false
|
|
private var released = false
|
|
private var recordedHandoffCount = 0
|
|
|
|
var hasPaused: Bool {
|
|
condition.lock()
|
|
defer { condition.unlock() }
|
|
return paused
|
|
}
|
|
|
|
var handoffCount: Int {
|
|
condition.lock()
|
|
defer { condition.unlock() }
|
|
return recordedHandoffCount
|
|
}
|
|
|
|
func pause() {
|
|
condition.lock()
|
|
paused = true
|
|
condition.broadcast()
|
|
while !released {
|
|
condition.wait()
|
|
}
|
|
condition.unlock()
|
|
}
|
|
|
|
func release() {
|
|
condition.lock()
|
|
released = true
|
|
condition.broadcast()
|
|
condition.unlock()
|
|
}
|
|
|
|
func recordHandoff() {
|
|
condition.lock()
|
|
recordedHandoffCount += 1
|
|
condition.unlock()
|
|
}
|
|
}
|
|
|
|
/// Lets a dedicated dispatch worker observe the lock-protected panic gate
|
|
/// without treating the full BLE service as generally Sendable.
|
|
private final class PanicIngressObserver: @unchecked Sendable {
|
|
private let service: BLEService
|
|
|
|
init(service: BLEService) {
|
|
self.service = service
|
|
}
|
|
|
|
func waitUntilClosed(timeout: TimeInterval) -> Bool {
|
|
let deadline = DispatchTime.now().uptimeNanoseconds
|
|
+ UInt64(timeout * 1_000_000_000)
|
|
while service._test_isPanicIngressOpen,
|
|
DispatchTime.now().uptimeNanoseconds < deadline {
|
|
Thread.sleep(forTimeInterval: 0.001)
|
|
}
|
|
return !service._test_isPanicIngressOpen
|
|
}
|
|
}
|
|
|
|
private func makeService(
|
|
noiseResponderHandshakeTimeout: TimeInterval =
|
|
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
|
) -> BLEService {
|
|
let keychain = MockKeychain()
|
|
let identityManager = MockIdentityManager(keychain)
|
|
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
|
return BLEService(
|
|
keychain: keychain,
|
|
idBridge: idBridge,
|
|
identityManager: identityManager,
|
|
initializeBluetoothManagers: false,
|
|
noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout
|
|
)
|
|
}
|
|
|
|
private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket {
|
|
BitchatPacket(
|
|
type: MessageType.message.rawValue,
|
|
senderID: Data(hexString: sender.id) ?? Data(),
|
|
recipientID: nil,
|
|
timestamp: timestamp,
|
|
payload: Data(content.utf8),
|
|
signature: nil,
|
|
ttl: 3
|
|
)
|
|
}
|
|
|
|
private func makeLeavePacket(sender: PeerID, marker: String) -> BitchatPacket {
|
|
BitchatPacket(
|
|
type: MessageType.leave.rawValue,
|
|
senderID: Data(hexString: sender.id) ?? Data(),
|
|
recipientID: nil,
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: Data(marker.utf8),
|
|
signature: nil,
|
|
ttl: TransportConfig.messageTTLDefault
|
|
)
|
|
}
|
|
|
|
private final class PublicCaptureDelegate: BitchatDelegate {
|
|
private let lock = NSLock()
|
|
private(set) var publicMessages: [BitchatMessage] = []
|
|
|
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
|
let message = BitchatMessage(
|
|
id: messageID,
|
|
sender: nickname,
|
|
content: content,
|
|
timestamp: timestamp,
|
|
isRelay: false,
|
|
originalSender: nil,
|
|
isPrivate: false,
|
|
recipientNickname: nil,
|
|
senderPeerID: peerID,
|
|
mentions: nil
|
|
)
|
|
lock.lock()
|
|
publicMessages.append(message)
|
|
lock.unlock()
|
|
}
|
|
|
|
func didReceiveMessage(_ message: BitchatMessage) {}
|
|
func didConnectToPeer(_ peerID: PeerID) {}
|
|
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
|
func didUpdatePeerList(_ peers: [PeerID]) {}
|
|
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
|
|
|
func publicMessagesSnapshot() -> [BitchatMessage] {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return publicMessages
|
|
}
|
|
|
|
}
|
|
|
|
@MainActor
|
|
private final class TransportEventCaptureDelegate: TransportEventDelegate {
|
|
private(set) var messageIDs: [String] = []
|
|
|
|
func didReceiveTransportEvent(_ event: TransportEvent) {
|
|
guard case .messageReceived(let message) = event else { return }
|
|
messageIDs.append(message.id)
|
|
}
|
|
}
|