mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:05:20 +00:00
Gate courier handover on direct announces and isolate store test
Envelopes are removed from the courier store optimistically, so releasing them on a relayed (multi-hop) announce risks losing carried mail to a speculative flood that never reaches the recipient. Handover now also requires the announce to have arrived directly (full TTL), i.e. an actual encounter with a live link; regression test builds a relayed copy of a genuinely signed announce (TTL is excluded from announce signatures). Also make CourierStore's on-disk location injectable so the persistence test round-trips through a temp directory instead of wiping the real Application Support store, and reattach BLEAnnounceHandler's doc comment to the class it describes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -59,9 +59,8 @@ struct BLEAnnounceHandlerEnvironment {
|
|||||||
let scheduleAfterglow: (TimeInterval) -> Void
|
let scheduleAfterglow: (TimeInterval) -> Void
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Orchestrates inbound announce packets: preflight validation, signature
|
/// Outcome of an accepted announce, surfaced so the service can run
|
||||||
/// trust, registry/topology updates, identity persistence, UI notification,
|
/// follow-up work (e.g. courier handover) that keys off the announce.
|
||||||
/// gossip tracking, and the reciprocal announce response.
|
|
||||||
struct BLEAnnounceHandlingResult {
|
struct BLEAnnounceHandlingResult {
|
||||||
let peerID: PeerID
|
let peerID: PeerID
|
||||||
let announcement: AnnouncementPacket
|
let announcement: AnnouncementPacket
|
||||||
@@ -69,6 +68,9 @@ struct BLEAnnounceHandlingResult {
|
|||||||
let isVerified: Bool
|
let isVerified: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Orchestrates inbound announce packets: preflight validation, signature
|
||||||
|
/// trust, registry/topology updates, identity persistence, UI notification,
|
||||||
|
/// gossip tracking, and the reciprocal announce response.
|
||||||
final class BLEAnnounceHandler {
|
final class BLEAnnounceHandler {
|
||||||
private let environment: BLEAnnounceHandlerEnvironment
|
private let environment: BLEAnnounceHandlerEnvironment
|
||||||
|
|
||||||
|
|||||||
@@ -3172,9 +3172,13 @@ extension BLEService {
|
|||||||
|
|
||||||
// Courier handover: an announce is the moment we learn a peer's Noise
|
// Courier handover: an announce is the moment we learn a peer's Noise
|
||||||
// static key, so check whether we're carrying mail addressed to them.
|
// static key, so check whether we're carrying mail addressed to them.
|
||||||
|
// Direct announces only: envelopes are removed from the store
|
||||||
|
// optimistically, so handover must ride an established link rather
|
||||||
|
// than a speculative multi-hop send toward a relayed announce.
|
||||||
guard !courierStore.isEmpty,
|
guard !courierStore.isEmpty,
|
||||||
let result,
|
let result,
|
||||||
result.isVerified else { return }
|
result.isVerified,
|
||||||
|
result.isDirectAnnounce else { return }
|
||||||
deliverCourierMail(to: result.peerID, noiseKey: result.announcement.noisePublicKey)
|
deliverCourierMail(to: result.peerID, noiseKey: result.announcement.noisePublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,9 +54,11 @@ final class CourierStore {
|
|||||||
private let fileURL: URL?
|
private let fileURL: URL?
|
||||||
private let now: () -> Date
|
private let now: () -> Date
|
||||||
|
|
||||||
init(persistsToDisk: Bool = true, now: @escaping () -> Date = Date.init) {
|
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
|
||||||
|
/// when `persistsToDisk` is false.
|
||||||
|
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
|
||||||
self.now = now
|
self.now = now
|
||||||
self.fileURL = persistsToDisk ? Self.defaultFileURL() : nil
|
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
|
||||||
loadFromDisk()
|
loadFromDisk()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,16 +158,19 @@ struct CourierStoreTests {
|
|||||||
// MARK: - Persistence
|
// MARK: - Persistence
|
||||||
|
|
||||||
@Test func persistsAndReloadsAcrossInstances() throws {
|
@Test func persistsAndReloadsAcrossInstances() throws {
|
||||||
// Uses the real on-disk location; clean up around the test.
|
// Isolated on-disk location so the test never touches the real store.
|
||||||
let first = CourierStore(persistsToDisk: true, now: { Self.baseDate })
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
defer { first.wipe() }
|
.appendingPathComponent("courier-store-tests-\(UUID().uuidString)", isDirectory: true)
|
||||||
first.wipe()
|
.appendingPathComponent("envelopes.json")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) }
|
||||||
|
|
||||||
|
let first = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
|
||||||
|
|
||||||
let recipientKey = Data(repeating: 0xE0, count: 32)
|
let recipientKey = Data(repeating: 0xE0, count: 32)
|
||||||
let envelope = makeEnvelope(recipientKey: recipientKey)
|
let envelope = makeEnvelope(recipientKey: recipientKey)
|
||||||
#expect(first.deposit(envelope, from: depositorA))
|
#expect(first.deposit(envelope, from: depositorA))
|
||||||
|
|
||||||
let second = CourierStore(persistsToDisk: true, now: { Self.baseDate })
|
let second = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
|
||||||
#expect(second.takeEnvelopes(for: recipientKey) == [envelope])
|
#expect(second.takeEnvelopes(for: recipientKey) == [envelope])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ struct CourierEndToEndTests {
|
|||||||
lock.lock(); defer { lock.unlock() }
|
lock.lock(); defer { lock.unlock() }
|
||||||
return packets.filter { $0.type == type.rawValue }.count
|
return packets.filter { $0.type == type.rawValue }.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func all(ofType type: MessageType) -> [BitchatPacket] {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
return packets.filter { $0.type == type.rawValue }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class NoiseCaptureDelegate: BitchatDelegate {
|
private final class NoiseCaptureDelegate: BitchatDelegate {
|
||||||
@@ -234,6 +239,87 @@ struct CourierEndToEndTests {
|
|||||||
#expect(carol.courierStore.isEmpty)
|
#expect(carol.courierStore.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func relayedAnnounceDoesNotTriggerCourierHandover() async throws {
|
||||||
|
let alice = makeService()
|
||||||
|
let carol = makeService()
|
||||||
|
let bob = makeService()
|
||||||
|
carol.courierDepositPolicy = { _ in true }
|
||||||
|
|
||||||
|
let aliceOut = PacketTap()
|
||||||
|
alice._test_onOutboundPacket = aliceOut.record
|
||||||
|
let carolOut = PacketTap()
|
||||||
|
carol._test_onOutboundPacket = carolOut.record
|
||||||
|
let bobOut = PacketTap()
|
||||||
|
bob._test_onOutboundPacket = bobOut.record
|
||||||
|
|
||||||
|
preseedConnectedPeer(carol, in: alice)
|
||||||
|
|
||||||
|
#expect(alice.sendCourierMessage(
|
||||||
|
"hold for a direct encounter",
|
||||||
|
messageID: "courier-msg-relayed-announce",
|
||||||
|
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
|
||||||
|
via: [carol.myPeerID]
|
||||||
|
))
|
||||||
|
let deposited = await TestHelpers.waitUntil(
|
||||||
|
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(deposited)
|
||||||
|
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||||
|
|
||||||
|
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
|
||||||
|
let carried = await TestHelpers.waitUntil(
|
||||||
|
{ !carol.courierStore.isEmpty },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(carried)
|
||||||
|
|
||||||
|
bob.sendBroadcastAnnounce()
|
||||||
|
let announced = await TestHelpers.waitUntil(
|
||||||
|
{ bobOut.first(ofType: .announce) != nil },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(announced)
|
||||||
|
let directAnnounce = try #require(bobOut.first(ofType: .announce))
|
||||||
|
|
||||||
|
// A relayed copy has a decremented TTL but a still-valid signature
|
||||||
|
// (TTL is excluded from announce signatures). Envelopes are removed
|
||||||
|
// from the store optimistically, so handover must wait for a direct
|
||||||
|
// encounter instead of chasing a multi-hop path.
|
||||||
|
var relayedAnnounce = directAnnounce
|
||||||
|
relayedAnnounce.ttl = directAnnounce.ttl - 1
|
||||||
|
carol._test_handlePacket(relayedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||||
|
|
||||||
|
let leakedOnRelayedAnnounce = await TestHelpers.waitUntil(
|
||||||
|
{ carolOut.count(ofType: .courierEnvelope) > 0 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!leakedOnRelayedAnnounce)
|
||||||
|
#expect(!carol.courierStore.isEmpty)
|
||||||
|
|
||||||
|
// The relayed copy consumed the original announce's dedup key
|
||||||
|
// (sender/timestamp/payload — TTL excluded), so the direct handover
|
||||||
|
// needs a fresh announce. Wait out the 1s announce throttle first.
|
||||||
|
try await Task.sleep(nanoseconds: 1_100_000_000)
|
||||||
|
bob.sendBroadcastAnnounce()
|
||||||
|
let reannounced = await TestHelpers.waitUntil(
|
||||||
|
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp } },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(reannounced)
|
||||||
|
let freshAnnounce = try #require(
|
||||||
|
bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp }
|
||||||
|
)
|
||||||
|
carol._test_handlePacket(freshAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||||
|
|
||||||
|
let handedOver = await TestHelpers.waitUntil(
|
||||||
|
{ carolOut.count(ofType: .courierEnvelope) == 1 },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(handedOver)
|
||||||
|
#expect(carol.courierStore.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func sendCourierMessageRejectsInvalidRecipientKeyBeforeQueueing() async throws {
|
@Test func sendCourierMessageRejectsInvalidRecipientKeyBeforeQueueing() async throws {
|
||||||
let alice = makeService()
|
let alice = makeService()
|
||||||
let carol = makeService()
|
let carol = makeService()
|
||||||
|
|||||||
Reference in New Issue
Block a user