Compare commits

...
Author SHA1 Message Date
jackandClaude Fable 5 10ba71b1f8 Fix panic-wipe window, frame byte bound, and delivery-side test waits
Three confirmed defects from adversarial review of the per-relay inbound
pipeline (PR #1352):

- MEDIUM-2: a detached gift-wrap decrypt spawned just before a panic wipe
  strongly captures the pre-wipe Nostr private key and could deliver
  plaintext into ChatViewModel after the wipe. Add a per-pipeline
  monotonic wipe generation (NostrInboundPipeline.wipeGeneration), bumped
  from panicClearAllData via invalidateInFlightDecrypts(); spawn sites
  capture it and every main-actor hop drops the task's result on
  mismatch. The account-mailbox path (processNostrMessage) had the same
  pre-existing hazard and gets the identical guard, capturing the
  generation atomically with the identity fetch.

- MEDIUM-1: the per-relay stream cap bounds FRAMES (256) but not BYTES;
  with the URLSession default of 1 MiB per WebSocket frame a hostile
  relay could pile up ~256 MiB. Set URLSessionWebSocketTask
  .maximumMessageSize to TransportConfig.nostrInboundMaxFrameBytes
  (512 KiB — an order of magnitude above any legitimate Nostr event or
  gift wrap we produce or expect), halving the worst case to 128 MiB,
  and correct the "cannot exhaust memory" comments to state the actual
  cap × maxFrameBytes bound.

- LOW-5: three NostrRelayManagerTests gated on messagesReceived (first
  main hop) then immediately asserted delivery-side state that only
  lands after off-main verification plus a second main hop. Wait on the
  delivery-side state (receivedIDs / duplicate-drop counts) directly,
  keeping all assertions.

Also: brief comment documenting pendingGiftWrapIDs growth (LOW-6) and a
regression test that a panic wipe issued after spawn drops the decrypted
result while leaving the pipeline usable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:08:29 +02:00
jackandClaude Fable 5 2d5250e49a Verify Nostr inbound per relay to avoid head-of-line blocking
The single global inbound consumer serialized ALL relay traffic behind
one Schnorr-verification queue, so a burst of EVENT frames from one
busy/malicious relay stalled DMs, OKs, EOSEs, and events from every other
relay (codex P2). Give each relay connection its own bounded AsyncStream +
detached serial consumer instead: N relays verify in parallel while each
relay's frames stay in arrival order (per-relay ordering preserves the
per-subscription ordering that actually matters, since a subscription's
events for a relay all arrive on that relay's socket).

Continuations live in a lock-guarded Sendable router so the non-isolated
socket receive callback can route a frame to the right relay stream with
no per-frame main hop; the main actor owns pipeline start/teardown, wired
into connect, disconnect, panic wipe, per-relay disconnect, retry, and the
default-relay revoke path. Streams use .bufferingNewest so a relay flooding
faster than it verifies sheds its OWN oldest frames — it can neither exhaust
memory nor starve other relays.

Security invariants are unchanged: signature verified exactly once, off
main; dedup pre-check-before / record-after-verify (forged copies still
can't poison the dedup set); the atomic main-actor check-and-record in
deliverVerifiedInboundEvent; the inner NIP-17 seal check untouched.

Tests: existing per-relay in-order-delivery and tampered-signature
dedup-poison tests still pass; add a cross-relay non-blocking test proving
a large backlog on relay A does not delay a later frame on relay B.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:08:55 +02:00
jackandGitHub fef775bae2 Merge branch 'main' into perf/nostr-inbound-offmain-verify-once 2026-07-01 23:52:20 +02:00
68eeba97ff Use Xcode-bundled Swift in CI instead of a standalone toolchain (#1353)
The unpinned setup-swift action installs Swift 6.1, which refuses the
SDK on runner images that have rolled to Xcode 26.5 ("this SDK is not
supported by the compiler"). Jobs passed or failed depending on which
image they landed on. The Xcode-bundled toolchain always matches the
image's SDK, and matches local development. Cache keys now include the
toolchain version so artifacts from one compiler are never restored
into builds with another.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:51:58 +02:00
jackandClaude Fable 5 8af6fc2d5f 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>
2026-07-01 23:40:23 +02:00
11 changed files with 667 additions and 213 deletions
+10 -5
View File
@@ -29,17 +29,22 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Set up Swift # Use the Xcode-bundled Swift toolchain: it always matches the SDK on
uses: swift-actions/setup-swift@v2 # the runner image. A standalone swift.org toolchain (setup-swift) broke
# whenever the image's Xcode moved ahead of it ("this SDK is not
# supported by the compiler").
- name: Note toolchain version (cache key)
id: swift-version
run: echo "version=$(swift --version 2>/dev/null | head -1 | shasum | cut -c1-12)" >> "$GITHUB_OUTPUT"
- name: Cache build artifacts - name: Cache build artifacts
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: ${{ matrix.path }}/.build path: ${{ matrix.path }}/.build
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }} key: ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
restore-keys: | restore-keys: |
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }} ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
${{ runner.os }}-${{ matrix.name }}- ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-
- name: Build tests - name: Build tests
# Built separately so the hang watchdog below times only test # Built separately so the hang watchdog below times only test
+240 -41
View File
@@ -48,7 +48,14 @@ private struct URLSessionAdapter: NostrRelaySessionProtocol {
let base: URLSession let base: URLSession
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol { func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url)) let task = base.webSocketTask(with: url)
// Byte bound per inbound frame; without it the per-relay buffer cap
// (nostrInboundPerRelayBufferCap) bounds FRAMES but not BYTES, and a
// hostile relay could pile up cap × 1 MiB (URLSession default) per
// connection. See TransportConfig.nostrInboundMaxFrameBytes for the
// sizing rationale. Oversized frames fail the receive with an error.
task.maximumMessageSize = TransportConfig.nostrInboundMaxFrameBytes
return URLSessionWebSocketTaskAdapter(base: task)
} }
} }
@@ -106,7 +113,10 @@ private extension NostrRelayManagerDependencies {
@MainActor @MainActor
final class NostrRelayManager: ObservableObject { final class NostrRelayManager: ObservableObject {
static let shared = NostrRelayManager() static let shared = NostrRelayManager()
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info // Track gift-wraps (kind 1059) we initiated so we can log OK acks at info.
// Entries are removed only on OK acks (or panic wipe); relays that never
// ack leave entries behind for the process lifetime. Observability-only
// state, bounded in practice by outbound DM volume.
private(set) static var pendingGiftWrapIDs = Set<String>() private(set) static var pendingGiftWrapIDs = Set<String>()
static func registerPendingGiftWrap(id: String) { static func registerPendingGiftWrap(id: String) {
pendingGiftWrapIDs.insert(id) pendingGiftWrapIDs.insert(id)
@@ -212,7 +222,33 @@ final class NostrRelayManager: ObservableObject {
// Bump generation to invalidate scheduled reconnects when we reset/disconnect // Bump generation to invalidate scheduled reconnects when we reset/disconnect
private var connectionGeneration: Int = 0 private var connectionGeneration: Int = 0
// Per-relay off-main inbound pipeline: raw socket frames are parsed and
// Schnorr-verified in arrival order OFF the main actor (this is the single
// signature verification for the whole inbound path downstream handlers
// receive only verified events), then hop back to the main actor for dedup
// recording and handler dispatch.
//
// Each relay connection owns its OWN AsyncStream + consumer task, so N
// relays verify in parallel while every relay's frames stay in arrival
// order (a single subscription's events for a relay all arrive on that
// relay's socket, so per-relay ordering preserves per-subscription
// ordering). A burst of EVENT frames from one busy/malicious relay only
// blocks that relay's own verification backlog DMs, OKs, EOSEs, and
// events from every other relay keep flowing on their own pipelines.
//
// Each stream is bounded (`.bufferingNewest`) so a relay flooding faster
// than its verification drains sheds its own oldest frames instead of
// growing memory without bound; it can never starve other relays.
//
// Continuations live in a lock-guarded, `Sendable` router (see
// `InboundFrameRouter` at file scope) so the raw socket receive callback
// (which is NOT main-actor isolated) can route a frame to the right relay
// stream without a per-frame main hop, while the main actor owns pipeline
// creation/teardown. The expensive work (Schnorr verify) is what runs
// off-main; the yield stays cheap.
private let inboundRouter = InboundFrameRouter()
init() { init() {
self.dependencies = .live() self.dependencies = .live()
hasMutualFavorites = dependencies.hasMutualFavorites() hasMutualFavorites = dependencies.hasMutualFavorites()
@@ -266,7 +302,70 @@ final class NostrRelayManager: ObservableObject {
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
deinit {
inboundRouter.finishAll()
}
/// Ensure a serial off-main consumer pipeline exists for a relay. Called on
/// the main actor when a socket is (re)armed for receiving. Idempotent.
///
/// Ordering within the relay is deliberate and security/performance-critical:
/// 1. `precheckInboundEvent` (main hop): per-relay stats plus a cheap
/// duplicate LOOKUP duplicate fan-in from multiple relays dominates
/// real traffic and must never pay for Schnorr verification.
/// 2. `isValidSignature()` runs here, off the main actor the ONLY
/// signature verification on the inbound path (JSON re-serialization +
/// SHA-256 + secp256k1 Schnorr per event).
/// 3. `deliverVerifiedInboundEvent` (main hop): authoritative
/// check-and-RECORD plus handler dispatch. Recording only after
/// verification means a forged-signature copy can never poison the
/// dedup cache and suppress the genuine event.
private func ensureRelayInboundPipeline(for relayUrl: String) {
let started = inboundRouter.startPipeline(for: relayUrl) { [weak self] stream in
Task.detached(priority: .userInitiated) {
for await frame in stream {
guard let parsed = ParsedInbound(frame.message) else { continue }
guard let self else { return }
switch parsed {
case .event(let subId, let event):
guard await self.precheckInboundEvent(
subscriptionID: subId,
eventID: event.id,
relayUrl: relayUrl
) else {
continue
}
guard event.isValidSignature() else {
SecureLogger.warning(
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
category: .session
)
continue
}
await self.deliverVerifiedInboundEvent(subscriptionID: subId, event: event, from: relayUrl)
case .eose, .ok, .notice:
await self.handleParsedMessage(parsed, from: relayUrl)
}
}
}
}
if started {
SecureLogger.debug("🧵 Started inbound verify pipeline for \(relayUrl)", category: .session)
}
}
/// Tear down a relay's inbound pipeline (socket gone or state wiped). The
/// consumer drains any already-buffered frames before finishing, so
/// in-flight verified events are still delivered.
private func teardownRelayInboundPipeline(for relayUrl: String) {
inboundRouter.finishPipeline(for: relayUrl)
}
private func teardownAllRelayInboundPipelines() {
inboundRouter.finishAll()
}
/// Connect to all configured relays /// Connect to all configured relays
func connect() { func connect() {
// Global network policy gate // Global network policy gate
@@ -281,6 +380,8 @@ final class NostrRelayManager: ObservableObject {
task.cancel(with: .goingAway, reason: nil) task.cancel(with: .goingAway, reason: nil)
} }
connections.removeAll() connections.removeAll()
// Sockets are gone; drop every relay's inbound verify pipeline.
teardownAllRelayInboundPipelines()
markRelaySocketsClosed(resetState: false) markRelaySocketsClosed(resetState: false)
// Sockets are gone, so per-relay subscription state is cleared but // Sockets are gone, so per-relay subscription state is cleared but
// durable intent (subscriptionRequestState, messageHandlers, parked // durable intent (subscriptionRequestState, messageHandlers, parked
@@ -310,6 +411,7 @@ final class NostrRelayManager: ObservableObject {
task.cancel(with: .goingAway, reason: nil) task.cancel(with: .goingAway, reason: nil)
} }
connections.removeAll() connections.removeAll()
teardownAllRelayInboundPipelines()
markRelaySocketsClosed(resetState: true) markRelaySocketsClosed(resetState: true)
subscriptions.removeAll() subscriptions.removeAll()
pendingSubscriptions.removeAll() pendingSubscriptions.removeAll()
@@ -560,6 +662,7 @@ final class NostrRelayManager: ObservableObject {
connection.cancel(with: .goingAway, reason: nil) connection.cancel(with: .goingAway, reason: nil)
} }
connections.removeValue(forKey: url) connections.removeValue(forKey: url)
teardownRelayInboundPipeline(for: url)
subscriptions.removeValue(forKey: url) subscriptions.removeValue(forKey: url)
pendingSubscriptions.removeValue(forKey: url) pendingSubscriptions.removeValue(forKey: url)
} }
@@ -899,7 +1002,11 @@ final class NostrRelayManager: ObservableObject {
connections[urlString] = task connections[urlString] = task
task.resume() task.resume()
// Bring up this relay's own serial verify pipeline before arming the
// socket, so inbound frames have somewhere to land.
ensureRelayInboundPipeline(for: urlString)
// Start receiving messages // Start receiving messages
receiveMessage(from: task, relayUrl: urlString) receiveMessage(from: task, relayUrl: urlString)
@@ -958,14 +1065,13 @@ final class NostrRelayManager: ObservableObject {
switch result { switch result {
case .success(let message): case .success(let message):
// Parse off-main to reduce UI jank, then hop back for state updates // Hand the raw frame to this relay's serial inbound pipeline:
Task.detached(priority: .utility) { // parsing and signature verification run off-main, in arrival
guard let parsed = ParsedInbound(message) else { return } // order, independently of every other relay's pipeline. Routing
await MainActor.run { // through the lock-guarded router keeps this off the main actor
self.handleParsedMessage(parsed, from: relayUrl) // (no per-frame main hop).
} self.inboundRouter.yield(InboundFrame(message: message), to: relayUrl)
}
// Continue receiving // Continue receiving
Task { @MainActor in Task { @MainActor in
self.receiveMessage(from: task, relayUrl: relayUrl) self.receiveMessage(from: task, relayUrl: relayUrl)
@@ -983,35 +1089,55 @@ final class NostrRelayManager: ObservableObject {
// Note: declared at file scope below to avoid MainActor isolation inside this class // Note: declared at file scope below to avoid MainActor isolation inside this class
// and keep parsing off the main actor. // and keep parsing off the main actor.
// Handle parsed message on MainActor (state updates and handlers) /// First main-actor hop for an inbound EVENT: per-relay stats plus a cheap
/// duplicate LOOKUP (no recording) so duplicate fan-in from multiple
/// relays never pays for Schnorr verification. Recording happens only
/// after the signature verifies (`deliverVerifiedInboundEvent`), so a
/// forged-signature copy can never poison the dedup cache and suppress
/// the genuine event.
private func precheckInboundEvent(subscriptionID: String, eventID: String, relayUrl: String) -> Bool {
if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
relays[index].messagesReceived += 1
}
guard !eventID.isEmpty else { return true }
let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID)
if recentInboundEventKeys.contains(key) {
recordDuplicateInboundEventDrop(subscriptionID: subscriptionID)
return false
}
return true
}
/// Second main-actor hop, after off-main signature verification:
/// authoritative check-and-record (the serial pipeline means the same
/// event is never in flight twice, but the record must stay atomic with
/// delivery) and handler dispatch.
private func deliverVerifiedInboundEvent(subscriptionID subId: String, event: NostrEvent, from relayUrl: String) {
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
return
}
if event.kind != 1059 {
// Per-event logging floods dev builds in busy geohashes; sample it.
inboundEventLogCount += 1
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
}
}
if let handler = self.messageHandlers[subId] {
handler(event)
} else {
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
}
}
// Handle parsed non-EVENT messages on MainActor (state updates and handlers)
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) { private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
switch parsed { switch parsed {
case .event(let subId, let event): case .event:
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) { // Events flow through the serial inbound pipeline (precheck
self.relays[index].messagesReceived += 1 // off-main signature verification deliverVerifiedInboundEvent)
} // and never reach this fallback.
guard event.isValidSignature() else { assertionFailure("inbound EVENT bypassed the verified pipeline")
SecureLogger.warning(
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
category: .session
)
return
}
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
return
}
if event.kind != 1059 {
// Per-event logging floods dev builds in busy geohashes; sample it.
inboundEventLogCount += 1
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
}
}
if let handler = self.messageHandlers[subId] {
handler(event)
} else {
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
}
case .eose(let subId): case .eose(let subId):
if var tracker = eoseTrackers[subId] { if var tracker = eoseTrackers[subId] {
tracker.pendingRelays.remove(relayUrl) tracker.pendingRelays.remove(relayUrl)
@@ -1106,6 +1232,7 @@ final class NostrRelayManager: ObservableObject {
private func handleDisconnection(relayUrl: String, error: Error) { private func handleDisconnection(relayUrl: String, error: Error) {
connections.removeValue(forKey: relayUrl) connections.removeValue(forKey: relayUrl)
teardownRelayInboundPipeline(for: relayUrl)
subscriptions.removeValue(forKey: relayUrl) subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error) updateRelayStatus(relayUrl, isConnected: false, error: error)
settleEOSETrackers(droppingRelay: relayUrl) settleEOSETrackers(droppingRelay: relayUrl)
@@ -1194,8 +1321,9 @@ final class NostrRelayManager: ObservableObject {
if let connection = connections[normalizedRelayUrl] { if let connection = connections[normalizedRelayUrl] {
connection.cancel(with: .goingAway, reason: nil) connection.cancel(with: .goingAway, reason: nil)
connections.removeValue(forKey: normalizedRelayUrl) connections.removeValue(forKey: normalizedRelayUrl)
teardownRelayInboundPipeline(for: normalizedRelayUrl)
} }
// Attempt immediate reconnection // Attempt immediate reconnection
connectToRelay(normalizedRelayUrl) connectToRelay(normalizedRelayUrl)
} }
@@ -1288,6 +1416,77 @@ final class NostrRelayManager: ObservableObject {
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated) // MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
/// A single raw socket frame awaiting off-main parse + Schnorr verification.
private struct InboundFrame: Sendable {
let message: URLSessionWebSocketTask.Message
}
/// Lock-guarded registry of per-relay inbound streams.
///
/// The raw WebSocket receive callback is not main-actor isolated, so it needs a
/// `Sendable` path to route a frame to the correct relay's stream without a
/// per-frame hop onto the main actor. Pipeline lifecycle (start/finish) is
/// driven from the main actor; frame delivery (`yield`) can come from any
/// thread. All access is serialized by a single lock contention is negligible
/// because the guarded critical section is only a dictionary lookup + yield.
private final class InboundFrameRouter: @unchecked Sendable {
private let lock = NSLock()
private var continuations: [String: AsyncStream<InboundFrame>.Continuation] = [:]
private var tasks: [String: Task<Void, Never>] = [:]
/// Start a relay's stream + consumer if one does not already exist.
/// Returns true when a new pipeline was created. The bounded
/// `.bufferingNewest` policy makes a single relay shed its OWN oldest
/// frames under a flood, never other relays' frames. Buffered memory per
/// relay is bounded (not eliminated) at the frame cap times the per-frame
/// byte cap (`maximumMessageSize`) see TransportConfig.
func startPipeline(
for relayUrl: String,
makeConsumer: (AsyncStream<InboundFrame>) -> Task<Void, Never>
) -> Bool {
lock.lock()
defer { lock.unlock() }
if continuations[relayUrl] != nil { return false }
let (stream, continuation) = AsyncStream<InboundFrame>.makeStream(
bufferingPolicy: .bufferingNewest(TransportConfig.nostrInboundPerRelayBufferCap)
)
continuations[relayUrl] = continuation
tasks[relayUrl] = makeConsumer(stream)
return true
}
/// Route a frame to a relay's stream. No-op if the relay has no live
/// pipeline (socket already torn down) the frame is simply dropped, which
/// is safe for best-effort Nostr inbound.
func yield(_ frame: InboundFrame, to relayUrl: String) {
lock.lock()
let continuation = continuations[relayUrl]
lock.unlock()
continuation?.yield(frame)
}
/// Finish a relay's stream. The consumer drains any already-buffered frames
/// before exiting, so in-flight verified events are still delivered.
func finishPipeline(for relayUrl: String) {
lock.lock()
let continuation = continuations.removeValue(forKey: relayUrl)
tasks.removeValue(forKey: relayUrl)
lock.unlock()
continuation?.finish()
}
func finishAll() {
lock.lock()
let allContinuations = continuations
continuations.removeAll()
tasks.removeAll()
lock.unlock()
for continuation in allContinuations.values {
continuation.finish()
}
}
}
private enum ParsedInbound { private enum ParsedInbound {
case event(subId: String, event: NostrEvent) case event(subId: String, event: NostrEvent)
case ok(eventId: String, success: Bool, reason: String) case ok(eventId: String, success: Bool, reason: String)
+20
View File
@@ -55,6 +55,26 @@ enum TransportConfig {
static let nostrDuplicateEventLogInterval: Int = 50 static let nostrDuplicateEventLogInterval: Int = 50
// Sample interval for per-event debug logs on the inbound hot path. // Sample interval for per-event debug logs on the inbound hot path.
static let nostrInboundEventLogInterval: Int = 100 static let nostrInboundEventLogInterval: Int = 100
// Bounded per-relay inbound frame buffer. Each relay connection owns its
// own serial verify pipeline; if a relay floods faster than its Schnorr
// verification drains, the oldest buffered frames for THAT relay are
// dropped (bufferingNewest) so one relay cannot stall other relays.
// Nostr inbound is already best-effort (relays are redundant and events
// replay), so dropping a flooding relay's backlog is safe. Together with
// nostrInboundMaxFrameBytes this caps buffered inbound bytes at
// cap × maxFrameBytes (128 MiB) per hostile relay bounded, not zero.
static let nostrInboundPerRelayBufferCap: Int = 256
// Hard per-frame byte bound, applied as URLSessionWebSocketTask
// .maximumMessageSize (oversized frames fail the receive instead of
// buffering). BitChat's legitimate Nostr traffic is small: geohash chat /
// presence events (kind 20000/20001), kind-1 notes, and NIP-17
// gift-wrapped DMs carrying text payloads or receipts are all a few KiB,
// and most public relays reject events beyond ~64256 KiB anyway. 512 KiB
// leaves an order-of-magnitude margin over anything we produce or expect
// while halving the URLSession default (1 MiB), so a hostile relay's
// worst-case buffered pile-up per connection is
// nostrInboundPerRelayBufferCap × 512 KiB = 128 MiB instead of 256 MiB.
static let nostrInboundMaxFrameBytes: Int = 512 * 1024
// Conversation store diagnostics (field observability) // Conversation store diagnostics (field observability)
// Sample interval for the periodic store-audit "OK" heartbeat line // Sample interval for the periodic store-audit "OK" heartbeat line
+5
View File
@@ -1177,6 +1177,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain // Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
// disconnect is not enough here. // disconnect is not enough here.
NostrRelayManager.shared.resetForPanicWipe() NostrRelayManager.shared.resetForPanicWipe()
// Clearing relay handlers stops NEW events, but a detached gift-wrap
// decrypt spawned just before the wipe still holds a pre-wipe key and
// ciphertext; bump the pipeline's wipe generation so its result is
// dropped at the main-actor delivery hop instead of landing here.
nostrCoordinator.inbound.invalidateInFlightDecrypts()
nostrRelayManager = nil nostrRelayManager = nil
// Clear Nostr identity associations // Clear Nostr identity associations
+2 -1
View File
@@ -103,7 +103,8 @@ final class GeoPresenceTracker {
else { else {
return return
} }
guard event.isValidSignature() else { return } // The signature was already verified (exactly once, off the main
// actor) by NostrRelayManager before delivery.
guard shouldProcessGeoSamplingEvent(event.id) else { return } guard shouldProcessGeoSamplingEvent(event.id) else { return }
let existingCount = context.geoParticipantCount(for: gh) let existingCount = context.geoParticipantCount(for: gh)
+135 -91
View File
@@ -75,20 +75,38 @@ extension ChatViewModel: NostrInboundPipelineContext {
} }
} }
/// The inbound Nostr hot path: raw relay events in, chat messages / Noise /// The inbound Nostr hot path: verified relay events in, chat messages /
/// payloads out. Pure transformation plus dedup no relay lifecycle. /// Noise payloads out. Pure transformation plus dedup no relay lifecycle.
/// ///
/// Ordering is deliberate and performance-critical: cheap rejects (kind, /// Every event arriving here already had its Schnorr signature verified
/// dedup lookup) run BEFORE Schnorr signature verification because duplicates /// exactly once, off the main actor, by `NostrRelayManager`'s serial inbound
/// dominate real relay traffic; events are recorded only AFTER verification so /// pipeline (which records events into its own dedup cache only AFTER
/// a forged-signature copy can never poison the dedup set; gift-wrap /// verification, so forged copies can't suppress genuine events). This
/// verification for the account mailbox runs off-main with an atomic /// pipeline therefore never re-verifies; it keeps its own event-ID dedup
/// main-actor check-and-record. /// (cheap main-actor lookups) and moves NIP-17 gift-wrap decryption two
/// ECDH+ChaCha layers off the main actor with an atomic main-actor
/// check-and-record.
final class NostrInboundPipeline { final class NostrInboundPipeline {
private weak var context: (any NostrInboundPipelineContext)? private weak var context: (any NostrInboundPipelineContext)?
private let presence: GeoPresenceTracker private let presence: GeoPresenceTracker
private var geoEventLogCount = 0 private var geoEventLogCount = 0
/// Monotonic panic-wipe generation for this pipeline. A panic wipe clears
/// relay handlers so no NEW events flow, but a detached decrypt task
/// spawned just BEFORE the wipe which strongly captures a pre-wipe Nostr
/// private key and ciphertext survives it. Spawn sites capture this
/// value; the task compares it at its main-actor hops and drops its result
/// (no delivery; the captured identity and plaintext die with the task)
/// if `invalidateInFlightDecrypts()` bumped it in between.
@MainActor private(set) var wipeGeneration: UInt64 = 0
/// Called from `ChatViewModel.panicClearAllData()` so plaintext decrypted
/// with pre-wipe keys can never land in post-wipe state.
@MainActor
func invalidateInFlightDecrypts() {
wipeGeneration &+= 1
}
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) { init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
self.context = context self.context = context
self.presence = presence self.presence = presence
@@ -97,17 +115,15 @@ final class NostrInboundPipeline {
@MainActor @MainActor
func subscribeNostrEvent(_ event: NostrEvent) { func subscribeNostrEvent(_ event: NostrEvent) {
guard let context else { return } guard let context else { return }
// Cheap rejects (kind, dedup lookup) before Schnorr verification // Cheap rejects (kind, dedup lookup) duplicates dominate real
// duplicates dominate real traffic and must not pay for crypto. // traffic. The signature was already verified (exactly once, off the
// Only verified events are recorded, so a forged-signature copy can // main actor) by NostrRelayManager before delivery.
// never poison the dedup set and suppress the genuine event.
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue), || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
!context.hasProcessedNostrEvent(event.id) !context.hasProcessedNostrEvent(event.id)
else { else {
return return
} }
guard event.isValidSignature() else { return }
context.recordProcessedNostrEvent(event.id) context.recordProcessedNostrEvent(event.id)
@@ -176,15 +192,14 @@ final class NostrInboundPipeline {
@MainActor @MainActor
func handleNostrEvent(_ event: NostrEvent) { func handleNostrEvent(_ event: NostrEvent) {
guard let context else { return } guard let context else { return }
// Cheap rejects (kind, dedup lookup) before Schnorr verification // Cheap rejects (kind, dedup lookup) the signature was already
// duplicates dominate real traffic and must not pay for crypto. // verified (exactly once, off the main actor) by NostrRelayManager.
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
else { else {
return return
} }
if context.hasProcessedNostrEvent(event.id) { return } if context.hasProcessedNostrEvent(event.id) { return }
guard event.isValidSignature() else { return }
context.recordProcessedNostrEvent(event.id) context.recordProcessedNostrEvent(event.id)
// Sampled: fires for every geo event and floods dev logs in busy geohashes. // Sampled: fires for every geo event and floods dev logs in busy geohashes.
@@ -264,104 +279,126 @@ final class NostrInboundPipeline {
@MainActor @MainActor
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard let context else { return } guard let context else { return }
// Dedup lookup before Schnorr verification; record only after it passes. // Cheap dedup pre-check only; processGeohashGiftWrap does the
// authoritative main-actor check-and-record before the off-main
// NIP-17 unwrap. The outer signature was already verified (exactly
// once, off the main actor) by NostrRelayManager.
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return } guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
guard giftWrap.isValidSignature() else { return }
context.recordProcessedNostrEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( // Capture the wipe generation at spawn, alongside the per-geohash
giftWrap: giftWrap, // identity (private key) the detached task strongly captures. A panic
recipientIdentity: id // wipe between spawn and delivery bumps the generation, and the task
), // drops its result instead of delivering plaintext post-wipe.
let packet = Self.decodeEmbeddedBitChatPacket(from: content), let wipeGeneration = self.wipeGeneration
packet.type == MessageType.noiseEncrypted.rawValue, Task.detached(priority: .userInitiated) { [weak self] in
let noisePayload = NoisePayload.decode(packet.payload) await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: false, wipeGeneration: wipeGeneration)
else {
return
}
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
let convKey = PeerID(nostr_: senderPubkey)
context.registerNostrKeyMapping(senderPubkey, for: convKey)
switch noisePayload.type {
case .privateMessage:
context.handlePrivateMessage(
noisePayload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
)
case .delivered:
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
break
} }
} }
@MainActor @MainActor
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard let context else { return } guard let context else { return }
// Dedup lookup before Schnorr verification; record only after it passes. // Cheap dedup pre-check only; see subscribeGiftWrap.
if context.hasProcessedNostrEvent(giftWrap.id) { if context.hasProcessedNostrEvent(giftWrap.id) {
return return
} }
guard giftWrap.isValidSignature() else { return }
context.recordProcessedNostrEvent(giftWrap.id) // Spawn-time wipe-generation capture; see subscribeGiftWrap.
let wipeGeneration = self.wipeGeneration
Task.detached(priority: .userInitiated) { [weak self] in
await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: true, wipeGeneration: wipeGeneration)
}
}
/// Geohash-DM gift wrap ingest. The NIP-17 unwrap (two ECDH+ChaCha
/// layers) runs off the main actor; results hop back for state updates.
/// `verbose` keeps `handleGiftWrap`'s decrypt logging without adding it
/// to the sampling path.
///
/// `wipeGeneration` is this pipeline's generation captured at spawn (the
/// moment the pre-wipe `id` was captured); a mismatch at either main-actor
/// hop means a panic wipe happened in between, so the task bails without
/// decrypting (first hop) or without delivering the plaintext (second
/// hop) the captured identity and any decrypted material are simply
/// dropped with the task.
private func processGeohashGiftWrap(
_ giftWrap: NostrEvent,
id: NostrIdentity,
verbose: Bool,
wipeGeneration: UInt64
) async {
guard let context else { return }
// Authoritative check-and-record, atomic on the main actor so two
// concurrent detached tasks can't both process the same event.
let alreadyProcessed: Bool = await MainActor.run {
guard self.wipeGeneration == wipeGeneration else { return true }
if context.hasProcessedNostrEvent(giftWrap.id) { return true }
context.recordProcessedNostrEvent(giftWrap.id)
return false
}
if alreadyProcessed { return }
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap, giftWrap: giftWrap,
recipientIdentity: id recipientIdentity: id
) else { ) else {
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", category: .session) if verbose {
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", category: .session)
}
return return
} }
SecureLogger.debug( if verbose {
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", SecureLogger.debug(
category: .session "GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
) category: .session
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload)
else {
return
}
let convKey = PeerID(nostr_: senderPubkey)
context.registerNostrKeyMapping(senderPubkey, for: convKey)
switch payload.type {
case .privateMessage:
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
context.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
) )
case .delivered: }
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt: await MainActor.run {
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) // A panic wipe during the off-main decrypt must not let the
case .verifyChallenge, .verifyResponse: // pre-wipe plaintext reach post-wipe state; drop it here, atomic
break // with the wipe on the main actor.
guard self.wipeGeneration == wipeGeneration else { return }
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload)
else {
return
}
let convKey = PeerID(nostr_: senderPubkey)
context.registerNostrKeyMapping(senderPubkey, for: convKey)
switch payload.type {
case .privateMessage:
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
context.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
)
case .delivered:
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
break
}
} }
} }
@MainActor @MainActor
func handleNostrMessage(_ giftWrap: NostrEvent) { func handleNostrMessage(_ giftWrap: NostrEvent) {
guard let context else { return } guard let context else { return }
// Cheap dedup pre-check only; Schnorr verification runs off-main in // Cheap dedup pre-check only; processNostrMessage does the
// processNostrMessage, which then does the authoritative // authoritative check-and-record before the off-main NIP-17 unwrap.
// check-and-record. Recording stays after verification so a // The outer signature was already verified (exactly once, off the
// forged-signature copy can never poison the dedup set and suppress // main actor) by NostrRelayManager, and only verified events are
// the genuine event. // recorded, so a forged-signature copy can never poison the dedup
// set and suppress the genuine event.
if context.hasProcessedNostrEvent(giftWrap.id) { return } if context.hasProcessedNostrEvent(giftWrap.id) { return }
Task.detached(priority: .userInitiated) { [weak self] in Task.detached(priority: .userInitiated) { [weak self] in
@@ -370,7 +407,6 @@ final class NostrInboundPipeline {
} }
func processNostrMessage(_ giftWrap: NostrEvent) async { func processNostrMessage(_ giftWrap: NostrEvent) async {
guard giftWrap.isValidSignature() else { return }
guard let context else { return } guard let context else { return }
// Authoritative check-and-record, atomic on the main actor so two // Authoritative check-and-record, atomic on the main actor so two
// concurrent detached tasks can't both process the same event. // concurrent detached tasks can't both process the same event.
@@ -380,8 +416,13 @@ final class NostrInboundPipeline {
return false return false
} }
if alreadyProcessed { return } if alreadyProcessed { return }
let currentIdentity: NostrIdentity? = await MainActor.run { // Fetch the identity and the wipe generation in ONE main-actor hop:
context.currentNostrIdentity() // the generation then vouches for exactly this identity. A wipe after
// this point bumps the generation and the delivery hop below drops
// the decrypted result (same guard as processGeohashGiftWrap; this
// account-mailbox path had the identical hazard).
let (currentIdentity, wipeGeneration): (NostrIdentity?, UInt64) = await MainActor.run {
(context.currentNostrIdentity(), self.wipeGeneration)
} }
guard let currentIdentity else { return } guard let currentIdentity else { return }
@@ -413,6 +454,9 @@ final class NostrInboundPipeline {
let payload = NoisePayload.decode(packet.payload) { let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
await MainActor.run { await MainActor.run {
// Drop pre-wipe plaintext if a panic wipe landed
// during the off-main decrypt (see above).
guard self.wipeGeneration == wipeGeneration else { return }
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID) context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
switch payload.type { switch payload.type {
@@ -382,10 +382,12 @@ struct ChatNostrCoordinatorContextTests {
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient) 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 convKey = PeerID(nostr_: sender.publicKeyHex)
let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
#expect(routed)
#expect(context.recordedNostrEventIDs == [giftWrap.id]) #expect(context.recordedNostrEventIDs == [giftWrap.id])
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex) #expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
#expect(context.handledPrivateMessages.count == 1)
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex) #expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
#expect(context.handledPrivateMessages.first?.convKey == convKey) #expect(context.handledPrivateMessages.first?.convKey == convKey)
@@ -398,30 +400,76 @@ struct ChatNostrCoordinatorContextTests {
// The same gift wrap is dropped on replay. // The same gift wrap is dropped on replay.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient) coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
await drainMainQueue()
#expect(context.recordedNostrEventIDs == [giftWrap.id]) #expect(context.recordedNostrEventIDs == [giftWrap.id])
#expect(context.handledPrivateMessages.count == 1) #expect(context.handledPrivateMessages.count == 1)
} }
@Test @MainActor @Test @MainActor
func processNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws { func handleGiftWrap_panicWipeAfterSpawnDropsDecryptedResult() async throws {
let context = MockChatNostrContext() let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context) let coordinator = ChatNostrCoordinator(context: context)
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let embedded = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content: "pre-wipe secret",
messageID: "gm-wipe-1",
senderPeerID: PeerID(str: "aabbccddeeff0011")
))
let giftWrap = try NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
// Spawn the detached decrypt (it strongly captures the pre-wipe
// identity), then panic-wipe in the SAME main-actor turn guaranteed
// to land before the task's first main-actor hop.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
coordinator.inbound.invalidateInFlightDecrypts()
// Give the detached task ample time to have delivered if the wipe
// guard were broken.
try? await Task.sleep(nanoseconds: 200_000_000)
await drainMainQueue()
#expect(context.handledPrivateMessages.isEmpty)
#expect(context.recordedNostrEventIDs.isEmpty)
// The pipeline itself stays usable: a gift wrap spawned AFTER the
// wipe (new generation) still decrypts and delivers.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
#expect(delivered)
}
// 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_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( let giftWrap = try NostrProtocol.createPrivateMessage(
content: "verify:noop", content: "verify:noop",
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
) )
var invalidGiftWrap = giftWrap
invalidGiftWrap.sig = String(repeating: "0", count: 128)
// A forged-signature copy is rejected WITHOUT entering the dedup set... // Fan-in of the same (already verified) gift wrap from several relays
await coordinator.inbound.processNostrMessage(invalidGiftWrap) // records and processes exactly once.
#expect(context.recordedNostrEventIDs.isEmpty) 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) await coordinator.inbound.processNostrMessage(giftWrap)
#expect(context.recordedNostrEventIDs == [giftWrap.id]) #expect(context.recordedNostrEventIDs == [giftWrap.id])
} }
+12 -27
View File
@@ -352,31 +352,11 @@ struct ChatViewModelNostrExtensionTests {
#expect(!viewModel.messages.contains { $0.content == "Blocked" }) #expect(!viewModel.messages.contains { $0.content == "Blocked" })
} }
@Test @MainActor // NOTE: Tampered-signature rejection is enforced once, off the main
func handleNostrEvent_rejectsInvalidSignature() async throws { // actor, at the relay boundary (events only reach the inbound pipeline
let (viewModel, _) = makeTestableViewModel() // after verification) see NostrRelayManagerTests
let geohash = "u4pruydq" // `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and
let identity = try NostrIdentity.generate() // `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`.
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" })
}
@Test @MainActor @Test @MainActor
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws { func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
@@ -565,9 +545,14 @@ struct ChatViewModelNostrExtensionTests {
viewModel.handleGiftWrap(giftWrap, id: recipient) 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.privateChats[convKey] == nil)
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
} }
@Test @MainActor @Test @MainActor
+5 -20
View File
@@ -326,26 +326,11 @@ struct ChatViewModelPresenceHandlingTests {
#expect(viewModel.geohashParticipantCount(for: activeGeohash) >= 1) #expect(viewModel.geohashParticipantCount(for: activeGeohash) >= 1)
} }
@Test func subscribeNostrEvent_samplingInvalidSignatureDoesNotPoisonDedup() async throws { // NOTE: Tampered-signature rejection (and the forged-copy dedup-poisoning
let (viewModel, _) = makeTestableViewModel() // invariant) is enforced once, off the main actor, at the relay boundary
let sampleGeohash = "u4pru" // the sampling path only ever sees verified events. See
let identity = try NostrIdentity.generate() // NostrRelayManagerTests
let event = NostrEvent( // `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache`.
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)
}
// MARK: - Test Helper // MARK: - Test Helper
@@ -77,8 +77,10 @@ final class PerformanceBaselineTests: XCTestCase {
// MARK: - 1a. Nostr inbound event handling (fresh events) // MARK: - 1a. Nostr inbound event handling (fresh events)
/// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events /// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events
/// (kind 20000): signature verification, dedup record, presence/nickname /// (kind 20000): dedup record, presence/nickname bookkeeping, and
/// bookkeeping, and public-message ingest scheduling. /// 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 { func testNostrInboundEventHandling_freshEvents() throws {
let events = try Self.makeSignedGeohashEvents(count: 500) let events = try Self.makeSignedGeohashEvents(count: 500)
// A fresh context per measure pass so every event takes the // 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 /// The dedup-hit path: identical events replayed. Duplicates dominate
/// real relay traffic (the same event arrives from several relays), so /// real relay traffic (the same event arrives from several relays), so
/// this path runs hundreds of times a minute in busy geohashes. Note it /// this path runs hundreds of times a minute in busy geohashes. It is a
/// still pays full Schnorr signature verification before the dedup check. /// pure dedup lookup: no crypto (verification happens upstream in
/// `NostrRelayManager`, and only for the first-seen copy).
func testNostrInboundEventHandling_duplicateEvents() throws { func testNostrInboundEventHandling_duplicateEvents() throws {
let events = try Self.makeSignedGeohashEvents(count: 500) let events = try Self.makeSignedGeohashEvents(count: 500)
let context = PerfNostrContext() let context = PerfNostrContext()
@@ -639,11 +639,15 @@ final class NostrRelayManagerTests: XCTestCase {
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: event) try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
try context.sessionFactory.latestConnection(for: secondRelayURL)?.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 == 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(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 1) XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 1)
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 1) XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 1)
@@ -676,12 +680,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 relayURLs.allSatisfy { relayURL in
context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1 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(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, relayURLs.count - 1) XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, relayURLs.count - 1)
XCTAssertEqual( XCTAssertEqual(
@@ -714,16 +723,153 @@ final class NostrRelayManagerTests: XCTestCase {
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: invalidEvent) try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: invalidEvent)
try context.sessionFactory.latestConnection(for: secondRelayURL)?.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 (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 == 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(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0) XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0)
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 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 { func test_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws {
let relayURL = "wss://missing-handler.example" let relayURL = "wss://missing-handler.example"
let context = makeContext(permission: .denied) let context = makeContext(permission: .denied)
@@ -1695,7 +1841,11 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
} }
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) { 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) { func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
@@ -1728,15 +1878,24 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
} }
func emitRawString(_ string: String) throws { func emitRawString(_ string: String) throws {
let handler = receiveHandler deliver(.success(.string(string)))
receiveHandler = nil
handler?(.success(.string(string)))
} }
private func emit(jsonObject: Any) throws { private func emit(jsonObject: Any) throws {
let data = try JSONSerialization.data(withJSONObject: jsonObject) let data = try JSONSerialization.data(withJSONObject: jsonObject)
let handler = receiveHandler deliver(.success(.data(data)))
receiveHandler = nil }
handler?(.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)
}
} }
} }