mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 21:45:22 +00:00
perf: verify Nostr inbound signatures once, off the main actor (#1352)
Moves inbound Nostr Schnorr verification and NIP-17 gift-wrap decryption off the main actor into per-relay bounded AsyncStream pipelines (parallel across relays, ordered per relay), dedups before crypto, and removes the redundant second re-verify in NostrInboundPipeline/GeoPresenceTracker. Adds a panic-wipe generation counter so in-flight decrypts are discarded after a wipe. Rebased onto main; #1451's 256 KiB pre-parse byte cap and tag limits are preserved and still enforced before any crypto/allocation.
This commit is contained in:
@@ -754,11 +754,15 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
|
||||
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
|
||||
|
||||
let countedOnBothRelays = await waitUntil {
|
||||
// Wait on the DELIVERY-side state: handler dispatch and the duplicate
|
||||
// drop both land on the second main hop, after off-main verification.
|
||||
let settled = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
|
||||
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
|
||||
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
|
||||
receivedIDs == [event.id] &&
|
||||
context.manager.debugDuplicateInboundEventDropCount == 1
|
||||
}
|
||||
XCTAssertTrue(countedOnBothRelays)
|
||||
XCTAssertTrue(settled)
|
||||
XCTAssertEqual(receivedIDs, [event.id])
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 1)
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 1)
|
||||
@@ -791,12 +795,17 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
let countedOnEveryRelay = await waitUntil {
|
||||
// Wait on the DELIVERY-side state: the winner's handler dispatch and
|
||||
// the losers' duplicate drops both land on the second main hop, after
|
||||
// off-main verification — messagesReceived (first hop) settles sooner.
|
||||
let settled = await waitUntil {
|
||||
relayURLs.allSatisfy { relayURL in
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1
|
||||
}
|
||||
} &&
|
||||
receivedIDs == [event.id] &&
|
||||
context.manager.debugDuplicateInboundEventDropCount == relayURLs.count - 1
|
||||
}
|
||||
XCTAssertTrue(countedOnEveryRelay)
|
||||
XCTAssertTrue(settled)
|
||||
XCTAssertEqual(receivedIDs, [event.id])
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, relayURLs.count - 1)
|
||||
XCTAssertEqual(
|
||||
@@ -829,16 +838,153 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: invalidEvent)
|
||||
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
|
||||
|
||||
let countedOnBothRelays = await waitUntil {
|
||||
// Wait on the DELIVERY-side state (second main hop, after off-main
|
||||
// verification), not just messagesReceived (first main hop) — the
|
||||
// handler only fires after verify + a second hop.
|
||||
let genuineDelivered = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
|
||||
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
|
||||
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
|
||||
receivedIDs == [event.id]
|
||||
}
|
||||
XCTAssertTrue(countedOnBothRelays)
|
||||
XCTAssertTrue(genuineDelivered)
|
||||
XCTAssertEqual(receivedIDs, [event.id])
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0)
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 0)
|
||||
}
|
||||
|
||||
/// The relay boundary is the single signature-verification point for the
|
||||
/// whole inbound path (downstream pipelines no longer re-verify), so a
|
||||
/// tampered gift wrap (kind 1059, the DM/mailbox path) must be dropped
|
||||
/// here — and must not poison the dedup cache against the genuine copy.
|
||||
func test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup() async throws {
|
||||
let firstRelayURL = "wss://giftwrap-one.example"
|
||||
let secondRelayURL = "wss://giftwrap-two.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: "psst",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
let tampered = invalidSignatureCopy(of: giftWrap)
|
||||
var receivedIDs: [String] = []
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "gift-wraps",
|
||||
relayUrls: [firstRelayURL, secondRelayURL]
|
||||
) { event in
|
||||
receivedIDs.append(event.id)
|
||||
}
|
||||
let subscriptionsSent = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: firstRelayURL)?.sentStrings.count == 1 &&
|
||||
context.sessionFactory.latestConnection(for: secondRelayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(subscriptionsSent)
|
||||
|
||||
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "gift-wraps", event: tampered)
|
||||
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "gift-wraps", event: giftWrap)
|
||||
|
||||
// Wait on the DELIVERY-side state (second main hop, after off-main
|
||||
// verification), not just messagesReceived (first main hop) — the
|
||||
// handler only fires after verify + a second hop.
|
||||
let genuineDelivered = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
|
||||
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
|
||||
receivedIDs == [giftWrap.id]
|
||||
}
|
||||
XCTAssertTrue(genuineDelivered)
|
||||
XCTAssertEqual(receivedIDs, [giftWrap.id])
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0)
|
||||
}
|
||||
|
||||
/// Signature verification runs off-main in a per-relay serial consumer;
|
||||
/// several frames buffered on one socket must still be delivered to the
|
||||
/// handler in that relay's arrival order.
|
||||
func test_receiveEvent_deliversBackToBackEventsInArrivalOrder() async throws {
|
||||
let relayURL = "wss://ordered.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let events = try (0..<12).map { try makeSignedEvent(content: "ordered-\($0)") }
|
||||
var receivedIDs: [String] = []
|
||||
|
||||
context.manager.subscribe(filter: makeFilter(), id: "ordered", relayUrls: [relayURL]) { event in
|
||||
receivedIDs.append(event.id)
|
||||
}
|
||||
let subscriptionSent = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(subscriptionSent)
|
||||
|
||||
for event in events {
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "ordered", event: event)
|
||||
}
|
||||
|
||||
let allDelivered = await waitUntil(timeout: 5.0) {
|
||||
receivedIDs.count == events.count
|
||||
}
|
||||
XCTAssertTrue(allDelivered)
|
||||
XCTAssertEqual(receivedIDs, events.map(\.id))
|
||||
}
|
||||
|
||||
/// Each relay owns its own off-main verify pipeline, so a large backlog of
|
||||
/// EVENT frames on one relay must NOT head-of-line-block a frame that
|
||||
/// arrives on a different relay. Under the previous single global consumer,
|
||||
/// relay B's single event (emitted after relay A's whole burst) could only
|
||||
/// be delivered once every frame in A's backlog had been Schnorr-verified;
|
||||
/// with per-relay pipelines B verifies concurrently and lands before A's
|
||||
/// backlog drains.
|
||||
func test_receiveEvent_busyRelayDoesNotBlockOtherRelayDelivery() async throws {
|
||||
let busyRelayURL = "wss://busy-relay.example"
|
||||
let quietRelayURL = "wss://quiet-relay.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
|
||||
// Distinct subscriptions per relay so dedup never coalesces A vs. B.
|
||||
let busyEvents = try (0..<200).map { try makeSignedEvent(content: "busy-\($0)") }
|
||||
let quietEvent = try makeSignedEvent(content: "quiet")
|
||||
|
||||
var busyDeliveredCount = 0
|
||||
var quietDeliveredAfterBusyCount = -1 // busy-count observed when B lands
|
||||
|
||||
context.manager.subscribe(filter: makeFilter(), id: "busy", relayUrls: [busyRelayURL]) { _ in
|
||||
busyDeliveredCount += 1
|
||||
}
|
||||
context.manager.subscribe(filter: makeFilter(), id: "quiet", relayUrls: [quietRelayURL]) { _ in
|
||||
if quietDeliveredAfterBusyCount < 0 {
|
||||
quietDeliveredAfterBusyCount = busyDeliveredCount
|
||||
}
|
||||
}
|
||||
let subscribed = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: busyRelayURL)?.sentStrings.count == 1 &&
|
||||
context.sessionFactory.latestConnection(for: quietRelayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(subscribed)
|
||||
|
||||
// Flood relay A first, then emit a single frame on relay B.
|
||||
for event in busyEvents {
|
||||
try context.sessionFactory.latestConnection(for: busyRelayURL)?.emitEventMessage(subscriptionID: "busy", event: event)
|
||||
}
|
||||
try context.sessionFactory.latestConnection(for: quietRelayURL)?.emitEventMessage(subscriptionID: "quiet", event: quietEvent)
|
||||
|
||||
let quietDelivered = await waitUntil(timeout: 5.0) { quietDeliveredAfterBusyCount >= 0 }
|
||||
XCTAssertTrue(quietDelivered, "relay B's event was never delivered")
|
||||
|
||||
// The signal: B did not have to wait for A's entire backlog. If the two
|
||||
// pipelines were globally serialized, B could only land after all 200 of
|
||||
// A's frames, so busyDeliveredCount would be 200 when B arrived.
|
||||
XCTAssertLessThan(
|
||||
quietDeliveredAfterBusyCount,
|
||||
busyEvents.count,
|
||||
"relay B was head-of-line blocked behind relay A's backlog"
|
||||
)
|
||||
|
||||
// Both relays still drain fully and in order.
|
||||
let allDelivered = await waitUntil(timeout: 5.0) {
|
||||
busyDeliveredCount == busyEvents.count
|
||||
}
|
||||
XCTAssertTrue(allDelivered)
|
||||
}
|
||||
|
||||
func test_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws {
|
||||
let relayURL = "wss://missing-handler.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
@@ -1840,7 +1986,11 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
}
|
||||
|
||||
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
||||
receiveHandler = completionHandler
|
||||
if !pendingResults.isEmpty {
|
||||
completionHandler(pendingResults.removeFirst())
|
||||
} else {
|
||||
receiveHandler = completionHandler
|
||||
}
|
||||
}
|
||||
|
||||
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
|
||||
@@ -1873,15 +2023,24 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
}
|
||||
|
||||
func emitRawString(_ string: String) throws {
|
||||
let handler = receiveHandler
|
||||
receiveHandler = nil
|
||||
handler?(.success(.string(string)))
|
||||
deliver(.success(.string(string)))
|
||||
}
|
||||
|
||||
private func emit(jsonObject: Any) throws {
|
||||
let data = try JSONSerialization.data(withJSONObject: jsonObject)
|
||||
let handler = receiveHandler
|
||||
receiveHandler = nil
|
||||
handler?(.success(.data(data)))
|
||||
deliver(.success(.data(data)))
|
||||
}
|
||||
|
||||
// Frames emitted before the manager re-arms `receive` are queued so
|
||||
// back-to-back emissions model a socket with several buffered frames.
|
||||
private var pendingResults: [Result<URLSessionWebSocketTask.Message, Error>] = []
|
||||
|
||||
private func deliver(_ result: Result<URLSessionWebSocketTask.Message, Error>) {
|
||||
if let handler = receiveHandler {
|
||||
receiveHandler = nil
|
||||
handler(result)
|
||||
} else {
|
||||
pendingResults.append(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user