Verify Nostr inbound signatures once, off the main actor

Every inbound relay event was Schnorr-verified TWICE, both times on the
main actor: once in NostrRelayManager.handleParsedMessage and again in
each NostrInboundPipeline / GeoPresenceTracker handler. Each
verification re-serializes the event JSON, hashes it, and runs a
secp256k1 Schnorr verify, so busy geohashes paid double crypto on the
UI thread. Geohash gift-wrap NIP-17 decryption (two ECDH+ChaCha layers)
also ran on the main actor.

Changes:
- NostrRelayManager now owns a serial off-main inbound pipeline
  (AsyncStream + single consumer task): frames are parsed and verified
  in arrival order off the main actor, preserving per-subscription
  delivery order. This is the single verification point for the whole
  inbound path; downstream handlers only ever see verified events.
- Dedup stays two-phase and unpoisonable: a cheap main-actor duplicate
  LOOKUP runs before verification (duplicate fan-in from several relays
  never pays for crypto — previously every duplicate was verified), and
  events are RECORDED as seen only after the signature verifies, so a
  forged-signature copy can never suppress the genuine event.
- NostrInboundPipeline and GeoPresenceTracker drop their redundant
  re-verification; geohash gift-wrap decryption moves off the main
  actor following the existing account-mailbox Task.detached pattern
  (atomic main-actor check-and-record, then decrypt off-main, then hop
  back for state updates).
- Tests: relay-level coverage for tampered gift wraps and for in-order
  delivery of back-to-back frames; pipeline-level tampered-signature
  tests move to the relay boundary where the invariant now lives; the
  mock relay connection queues frames emitted before receive re-arms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-01 23:40:23 +02:00
co-authored by Claude Fable 5
parent f688e529f6
commit 8af6fc2d5f
8 changed files with 338 additions and 193 deletions
@@ -382,10 +382,12 @@ struct ChatNostrCoordinatorContextTests {
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
// The NIP-17 unwrap runs off the main actor; wait for the hop back.
let convKey = PeerID(nostr_: sender.publicKeyHex)
let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
#expect(routed)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
#expect(context.handledPrivateMessages.count == 1)
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
#expect(context.handledPrivateMessages.first?.convKey == convKey)
@@ -398,30 +400,37 @@ struct ChatNostrCoordinatorContextTests {
// The same gift wrap is dropped on replay.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
await drainMainQueue()
#expect(context.recordedNostrEventIDs == [giftWrap.id])
#expect(context.handledPrivateMessages.count == 1)
}
// NOTE: Inbound Schnorr signature verification (and the forged-copy
// dedup-poisoning invariant) is enforced once, off the main actor, at the
// relay boundary see NostrRelayManagerTests
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and
// `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`.
// The inbound pipeline only ever sees verified events.
@Test @MainActor
func processNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws {
func processNostrMessage_duplicateDeliveryProcessesOnce() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate()
context.nostrIdentity = recipient
let giftWrap = try NostrProtocol.createPrivateMessage(
content: "verify:noop",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
var invalidGiftWrap = giftWrap
invalidGiftWrap.sig = String(repeating: "0", count: 128)
// A forged-signature copy is rejected WITHOUT entering the dedup set...
await coordinator.inbound.processNostrMessage(invalidGiftWrap)
#expect(context.recordedNostrEventIDs.isEmpty)
// Fan-in of the same (already verified) gift wrap from several relays
// records and processes exactly once.
await coordinator.inbound.processNostrMessage(giftWrap)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
// ...so the genuine event with the same ID still processes and records.
await coordinator.inbound.processNostrMessage(giftWrap)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
}
+12 -27
View File
@@ -352,31 +352,11 @@ struct ChatViewModelNostrExtensionTests {
#expect(!viewModel.messages.contains { $0.content == "Blocked" })
}
@Test @MainActor
func handleNostrEvent_rejectsInvalidSignature() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let identity = try NostrIdentity.generate()
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [["g", geohash]],
content: "Valid"
)
var signed = try event.sign(with: identity.schnorrSigningKey())
signed.id = "deadbeef"
viewModel.handleNostrEvent(signed)
try? await Task.sleep(nanoseconds: 100_000_000)
viewModel.publicMessagePipeline.flushIfNeeded()
#expect(!viewModel.messages.contains { $0.content == "Tampered" })
}
// NOTE: Tampered-signature rejection is enforced once, off the main
// actor, at the relay boundary (events only reach the inbound pipeline
// after verification) see NostrRelayManagerTests
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and
// `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`.
@Test @MainActor
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
@@ -565,9 +545,14 @@ struct ChatViewModelNostrExtensionTests {
viewModel.handleGiftWrap(giftWrap, id: recipient)
try? await Task.sleep(nanoseconds: 50_000_000)
// Gift-wrap decryption runs off the main actor; wait for the ack
// (sent even for blocked senders) to know processing finished.
let didAck = await TestHelpers.waitUntil(
{ viewModel.sentGeoDeliveryAcks.contains(messageID) },
timeout: 5.0
)
#expect(didAck)
#expect(viewModel.privateChats[convKey] == nil)
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
}
@Test @MainActor
+5 -20
View File
@@ -326,26 +326,11 @@ struct ChatViewModelPresenceHandlingTests {
#expect(viewModel.geohashParticipantCount(for: activeGeohash) >= 1)
}
@Test func subscribeNostrEvent_samplingInvalidSignatureDoesNotPoisonDedup() async throws {
let (viewModel, _) = makeTestableViewModel()
let sampleGeohash = "u4pru"
let identity = try NostrIdentity.generate()
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .geohashPresence,
tags: [["g", sampleGeohash]],
content: ""
)
let signed = try event.sign(with: identity.schnorrSigningKey())
var invalid = signed
invalid.sig = String(repeating: "0", count: 128)
viewModel.subscribeNostrEvent(invalid, gh: sampleGeohash)
viewModel.subscribeNostrEvent(signed, gh: sampleGeohash)
#expect(viewModel.geohashParticipantCount(for: sampleGeohash) == 1)
}
// NOTE: Tampered-signature rejection (and the forged-copy dedup-poisoning
// invariant) is enforced once, off the main actor, at the relay boundary
// the sampling path only ever sees verified events. See
// NostrRelayManagerTests
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache`.
// MARK: - Test Helper
@@ -77,8 +77,10 @@ final class PerformanceBaselineTests: XCTestCase {
// MARK: - 1a. Nostr inbound event handling (fresh events)
/// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events
/// (kind 20000): signature verification, dedup record, presence/nickname
/// bookkeeping, and public-message ingest scheduling.
/// (kind 20000): dedup record, presence/nickname bookkeeping, and
/// public-message ingest scheduling. Schnorr signature verification is
/// NOT part of this path anymore it runs exactly once, off the main
/// actor, in `NostrRelayManager` before delivery.
func testNostrInboundEventHandling_freshEvents() throws {
let events = try Self.makeSignedGeohashEvents(count: 500)
// A fresh context per measure pass so every event takes the
@@ -106,8 +108,9 @@ final class PerformanceBaselineTests: XCTestCase {
/// The dedup-hit path: identical events replayed. Duplicates dominate
/// real relay traffic (the same event arrives from several relays), so
/// this path runs hundreds of times a minute in busy geohashes. Note it
/// still pays full Schnorr signature verification before the dedup check.
/// this path runs hundreds of times a minute in busy geohashes. It is a
/// pure dedup lookup: no crypto (verification happens upstream in
/// `NostrRelayManager`, and only for the first-seen copy).
func testNostrInboundEventHandling_duplicateEvents() throws {
let events = try Self.makeSignedGeohashEvents(count: 500)
let context = PerfNostrContext()
@@ -724,6 +724,77 @@ final class NostrRelayManagerTests: XCTestCase {
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)
let countedOnBothRelays = await waitUntil {
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
}
XCTAssertTrue(countedOnBothRelays)
XCTAssertEqual(receivedIDs, [giftWrap.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0)
}
/// Signature verification moved off-main into a single serial consumer;
/// several frames buffered on one socket must still be delivered to the
/// handler in 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))
}
func test_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws {
let relayURL = "wss://missing-handler.example"
let context = makeContext(permission: .denied)
@@ -1695,7 +1766,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) {
@@ -1728,15 +1803,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)
}
}
}