Compare commits

..
Author SHA1 Message Date
jackandGitHub 41486fd1c9 Merge branch 'main' into chore/perf-privacy-polish 2026-07-01 23:58:27 +02:00
jackandClaude Fable 5 d8527b98fd Add a pragmatic SwiftLint configuration
Baseline .swiftlint.yml for local use (not wired into CI yet):

- file_length: warning at 600, error at 4000 — above the current
  maximum (BLEService.swift, ~3,500 lines), so the codebase passes
  as-is and only future growth trips the error.
- function_body_length: warning 100, error 200.
- Disables the high-noise style rules (line_length, identifier_name,
  cyclomatic_complexity, force_cast/force_try for tests, etc.) so the
  remaining defaults stay actionable; SwiftLint is not installed in
  this environment, so the disabled list is a best-effort starting
  point to be tuned on first real run.
- Excludes vendored Arti and SwiftPM .build output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:32:19 +02:00
jackandClaude Fable 5 5be8b5b37f Track CLAUDE.md and update it to match the current architecture
The previous (untracked) CLAUDE.md described ChatViewModel as a ~170KB
class split into +Nostr/+PrivateChat/+Tor extension files. Reality:
ChatViewModel.swift is ~1,600 lines and delegates to ~25 coordinator/
pipeline types in bitchat/ViewModels/; the Extensions/ files are thin
delegation shims. Document the actual structure: AppRuntime as the
composition root, ConversationStore as the single-writer message
store, the BLE handler/policy collaborators around BLEService, and
BinaryProtocol's move into the BitFoundation local package.

Also start tracking CLAUDE.md (removing its .gitignore entry) so the
guidance stays versioned alongside the code it describes; build/test
command sections are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:31:59 +02:00
jackandClaude Fable 5 7200b3de2a Add NIP-44 style padding support to Nostr DM encryption
The bespoke "v2" DM envelope (XChaCha20-Poly1305 over the raw UTF-8
rumor JSON) leaks exact plaintext length to relays. Add NIP-44 v2
style payload framing: a 2-byte big-endian length prefix followed by
zero padding to NIP-44's calc_padded_len buckets (32-byte minimum,
power-of-two-derived chunks), carried in a new "v3:" envelope that
reuses the existing key derivation and AEAD.

Compatibility: deployed clients hard-reject any envelope that does not
start with "v2:", and the v2 payload is raw JSON, so padded payloads
cannot be introduced inside v2 either. This lands phase 1 of a
two-phase rollout: decrypt accepts both v2 (unpadded) and v3 (padded),
while encrypt keeps emitting v2, gated by
NostrProtocol.sendPaddedEnvelope (defaults to false with the rollout
plan documented inline). Flip the flag once v3-capable clients are
widely deployed.

Unpad validates that the claimed length's bucket exactly matches the
authenticated payload size, so a tampered or malformed length prefix
fails closed. Tests cover pad/unpad round-trips, the NIP-44 bucket
vectors, v3 round-trip, legacy v2 decrypt, default-envelope pinning,
and tampered length prefixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:30:00 +02:00
jackandClaude Fable 5 3f6dc7dd36 Speed up hex encode/decode with lookup tables
Data.hexEncodedString() built each byte via String(format: "%02x", _),
paying Foundation format-string parsing per byte on the hot BLE receive
path (PeerID(hexData:) is called several times per received packet).
Replace it with an ASCII lookup table writing into a preallocated
buffer; output is byte-for-byte identical (lowercase hex).

Data(hexString:) similarly allocated a two-character substring and went
through integer radix parsing per byte; replace with direct nibble
lookups over the UTF-8 bytes. Semantics are unchanged except that
sign-prefixed pairs like "+f", which the old radix parser incorrectly
accepted, are now rejected.

Add round-trip, known-vector, and invalid-input tests to
BitFoundationTests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:26:38 +02:00
17 changed files with 757 additions and 695 deletions
-1
View File
@@ -6,7 +6,6 @@
plans/
## AI
CLAUDE.md
AGENTS.md
.claude/
+51
View File
@@ -0,0 +1,51 @@
# SwiftLint configuration for BitChat.
#
# Intentionally pragmatic: rules that would flood the existing codebase are
# disabled so the lint signal stays actionable; re-enable them individually as
# files get cleaned up. Not wired into CI yet — run locally with `swiftlint`
# from the repo root.
included:
- bitchat
- bitchatTests
- localPackages/BitFoundation/Sources
- localPackages/BitFoundation/Tests
- localPackages/BitLogger/Sources
excluded:
- .build
- localPackages/Arti
- localPackages/BitFoundation/.build
- localPackages/BitLogger/.build
disabled_rules:
# Style/volume rules that currently produce noise across the codebase.
- line_length
- identifier_name
- type_name
- type_body_length
- cyclomatic_complexity
- function_parameter_count
- large_tuple
- nesting
- todo
- trailing_comma
- opening_brace
- statement_position
- for_where
- redundant_string_enum_value
- non_optional_string_data_conversion
# Common in tests (force casts/tries on fixtures).
- force_cast
- force_try
file_length:
warning: 600
# BLEService.swift is currently ~3,500 lines; the error threshold sits above
# today's maximum so the codebase passes as-is and only future growth of the
# largest files trips it.
error: 4000
function_body_length:
warning: 100
error: 200
+106
View File
@@ -0,0 +1,106 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build & Test Commands
```bash
# Build macOS (no signing)
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build
# Build iOS for simulator
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' build
# Run all tests (iOS simulator)
xcodebuild -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' test
# Run tests via Swift Package Manager (used in CI)
swift build && swift test --parallel
# Clean build
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" clean
# Quick dev build and run (macOS only, requires `just`)
just run
```
### Running Specific Tests
```bash
# Run a single test class
xcodebuild test -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:bitchatTests/IntegrationTests
# Run a single test method
xcodebuild test -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:bitchatTests/NoiseProtocolTests/testHandshake
```
## Architecture Overview
BitChat is a **dual-transport P2P messaging app**: Bluetooth mesh for offline local communication, Nostr protocol for internet-based global messaging.
### Local Packages (`localPackages/`)
- **BitFoundation**: Shared foundation types — `BinaryProtocol` (compact binary packet format for BLE), `BitchatPacket`, `BitchatMessage`, `PeerID`, hex/SHA256/compression utilities. Has its own test suite under `localPackages/BitFoundation/Tests` (run `swift test` inside the package).
- **BitLogger**: `SecureLogger` logging.
- **Arti**: Tor integration (exposes the `Tor` product).
### Transport Layer
- **BLEService** (`bitchat/Services/BLE/BLEService.swift`): Core Bluetooth LE mesh networking - peer discovery, connection management, multi-hop relay (max 7 hops), packet fragmentation. The BLE directory contains ~40 focused collaborators (handlers, policies, buffers): `BLEReceivePipeline` dispatches inbound packets to `BLEAnnounceHandler` / `BLENoisePacketHandler` / `BLEPublicMessageHandler` / `BLEFragmentHandler` etc.; outbound planning lives in the `BLEOutbound*` types; peer state in `BLEPeerRegistry`.
- **NostrTransport** (`bitchat/Services/NostrTransport.swift`): Internet transport via Nostr relays with NIP-17 encryption
- **Transport protocol** (`bitchat/Services/Transport.swift`): Common interface both transports implement
### Encryption Layer
- **NoiseProtocol** (`bitchat/Noise/`): Noise XX pattern for end-to-end encryption with forward secrecy
- `NoiseEncryptionService`: Main encryption/decryption API
- `NoiseSessionManager`: Thread-safe per-peer session management
- `NoiseSession`: Individual peer session state (handshake, send/receive ciphers)
- **NostrProtocol** (`bitchat/Nostr/NostrProtocol.swift`): NIP-17 gift-wrapped encryption for Nostr messages
### Protocol Layer
- **BinaryProtocol** (`localPackages/BitFoundation/Sources/BitFoundation/BinaryProtocol.swift`): Compact binary packet format for BLE (lives in BitFoundation, not `bitchat/Protocols/`)
- **BitchatProtocol** (`bitchat/Protocols/BitchatProtocol.swift`): Message types and packet structures
- **LocationChannel/Geohash** (`bitchat/Protocols/`): Geographic channel routing
### Application Layer
- **AppRuntime** (`bitchat/App/AppRuntime.swift`): Composition root. Owns `ChatViewModel`, `ConversationStore`, the feature models (`PublicChatModel`, `PeerListModel`, `LocationPresenceStore`, `PeerIdentityStore`, ...), and the app event stream.
- **ConversationStore** (`bitchat/App/ConversationStore.swift`): Single-writer, single source of truth for conversation message state and selection (see `docs/CONVERSATION-STORE-DESIGN.md`). Feature models and `ChatViewModel` observe it and mutate it through its intent API.
- **ChatViewModel** (`bitchat/ViewModels/ChatViewModel.swift`, ~1,600 lines): Central coordinator that wires and delegates to ~25 focused coordinator/pipeline types in `bitchat/ViewModels/`, e.g.:
- `ChatPrivateConversationCoordinator` / `ChatPublicConversationCoordinator`: DM and public chat flows
- `ChatNostrCoordinator``GeohashSubscriptionManager`, `NostrInboundPipeline`, `GeoPresenceTracker`, `GeoChannelCoordinator`: Nostr/geohash channel logic
- `ChatOutgoingCoordinator`, `ChatDeliveryCoordinator`, `PublicMessagePipeline`: send paths and delivery tracking
- `ChatMediaTransferCoordinator`, `ChatMediaPreparation`: media transfers
- `ChatLifecycleCoordinator`, `ChatTransportEventCoordinator`, `ChatPeerListCoordinator`, `ChatPeerIdentityCoordinator`, `ChatVerificationCoordinator`: lifecycle, transport events, peer state
- `bitchat/ViewModels/Extensions/` (`ChatViewModel+Nostr/+PrivateChat/+Tor`): thin delegation shims kept for call-site stability; the real logic lives in the coordinators
- **MessageRouter** (`bitchat/Services/MessageRouter.swift`): Intelligent transport selection (BLE → Nostr fallback)
- **PrivateChatManager** (`bitchat/Services/PrivateChatManager.swift`): DM session management
## Test Infrastructure
Tests use an **in-memory networking harness** for deterministic, race-free testing:
- **MockBLEService** (`bitchatTests/Mocks/MockBLEService.swift`): Simulated BLE mesh with configurable topology
- `MockBLEService.resetTestBus()` - Clear state in setUp()
- `simulateConnectedPeer(_:)` / `simulateDisconnectedPeer(_:)` - Configure topology
- `autoFloodEnabled` - Enable broadcast flooding for Integration tests only
- **Test categories**:
- `bitchatTests/EndToEnd/`: Full message flow tests with explicit routing
- `bitchatTests/Integration/`: Multi-node topology tests with auto-flooding
- Unit tests: Individual component tests
## Key Patterns
- **Threading**: Use `@MainActor` for UI, `Task { @MainActor in ... }` for main thread dispatch
- **Delegation**: `BitchatDelegate`, `TransportPeerEventsDelegate` for event propagation
- **Session recovery**: On decrypt failure, clear local session and re-initiate Noise handshake (no NACK)
## Device Setup
To run on physical devices:
1. Copy `Configs/Local.xcconfig.example` to `Configs/Local.xcconfig`
2. Add your Developer Team ID to `Local.xcconfig`
3. Replace `group.chat.bitchat` with `group.<your_bundle_id>` in entitlements
+117 -25
View File
@@ -328,52 +328,85 @@ struct NostrProtocol {
return try NostrEvent(from: rumorDict)
}
// MARK: - Encryption (NIP-44 v2)
private static func encrypt(
// MARK: - Encryption (NIP-44 v2/v3)
/// Whether outgoing DMs use the padded "v3:" envelope.
///
/// TWO-PHASE ROLLOUT DO NOT FLIP YET. Deployed clients hard-reject any
/// ciphertext that does not start with "v2:" (`decrypt` below, as shipped,
/// throws `invalidCiphertext` on unknown version prefixes), and the "v2:"
/// payload is the raw UTF-8 rumor JSON, so a padded payload cannot be
/// smuggled inside "v2:" without breaking old receivers either. Enabling
/// this today would strand every client in the field.
///
/// Phase 1 (this change): ship decrypt-side support for "v3:" everywhere.
/// Phase 2 (future release, once phase-1 clients are widely deployed):
/// set this to true so outgoing DMs stop leaking plaintext length to
/// relays.
static let sendPaddedEnvelope = false
/// Internal (rather than private) so tests can exercise both envelope
/// versions directly.
static func encrypt(
plaintext: String,
recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey
senderKey: P256K.Schnorr.PrivateKey,
padded: Bool = NostrProtocol.sendPaddedEnvelope
) throws -> String {
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
throw NostrError.invalidPublicKey
}
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
// Encrypting message (XChaCha20-Poly1305, versioned envelope)
// Derive shared secret
let sharedSecret = try deriveSharedSecret(
privateKey: senderKey,
publicKey: recipientPubkeyData
)
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info).
// The v3 envelope deliberately reuses the same key derivation; it only
// changes the payload framing (length prefix + padding).
let key = try deriveNIP44V2Key(from: sharedSecret)
// 24-byte random nonce for XChaCha20-Poly1305
var nonce24 = Data(count: 24)
_ = nonce24.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
}
// v2 payload: raw UTF-8 plaintext (length leaks to relays)
// v3 payload: NIP-44 style [2-byte BE length][plaintext][zero padding]
let pt = Data(plaintext.utf8)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
// v2: base64url(nonce24 || ciphertext || tag)
let payload = padded ? try NIP44Padding.pad(pt) : pt
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: payload, key: key, nonce24: nonce24)
// version prefix + base64url(nonce24 || ciphertext || tag)
var combined = Data()
combined.append(nonce24)
combined.append(sealed.ciphertext)
combined.append(sealed.tag)
return "v2:" + Base64URLCoding.encode(combined)
return (padded ? "v3:" : "v2:") + Base64URLCoding.encode(combined)
}
private static func decrypt(
/// Internal (rather than private) so tests can exercise both envelope
/// versions directly.
static func decrypt(
ciphertext: String,
senderPubkey: String,
recipientKey: P256K.Schnorr.PrivateKey
) throws -> String {
// Expect NIP-44 v2 format
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
// Accept both the legacy unpadded "v2:" envelope and the padded "v3:"
// envelope (see `sendPaddedEnvelope` for the rollout plan).
let isPadded: Bool
if ciphertext.hasPrefix("v2:") {
isPadded = false
} else if ciphertext.hasPrefix("v3:") {
isPadded = true
} else {
throw NostrError.invalidCiphertext
}
let encoded = String(ciphertext.dropFirst(3))
guard let data = Base64URLCoding.decode(encoded),
data.count > (24 + 16),
@@ -399,18 +432,23 @@ struct NostrProtocol {
}
// If 32 bytes (x-only) try both parities, otherwise single try
let payload: Data
if senderPubkeyData.count == 32 {
let even = Data([0x02]) + senderPubkeyData
if let pt = try? attemptDecrypt(using: even) {
return String(data: pt, encoding: .utf8) ?? ""
payload = pt
} else {
let odd = Data([0x03]) + senderPubkeyData
payload = try attemptDecrypt(using: odd)
}
let odd = Data([0x03]) + senderPubkeyData
let pt = try attemptDecrypt(using: odd)
return String(data: pt, encoding: .utf8) ?? ""
} else {
let pt = try attemptDecrypt(using: senderPubkeyData)
return String(data: pt, encoding: .utf8) ?? ""
payload = try attemptDecrypt(using: senderPubkeyData)
}
// The AEAD tag has already authenticated the payload; unpadding
// failures here mean a malformed sender, not a wrong key.
let plaintextData = isPadded ? try NIP44Padding.unpad(payload) : payload
return String(data: plaintextData, encoding: .utf8) ?? ""
}
private static func deriveSharedSecret(
@@ -641,6 +679,60 @@ enum NostrError: Error {
case encryptionFailed
}
// MARK: - NIP-44 style padding (v3 envelope payload framing)
/// Payload framing for the padded "v3:" envelope, modeled on NIP-44 v2:
/// `[2-byte big-endian plaintext length][plaintext][zero padding]`, where the
/// total is padded to `paddedLength(for:)` power-of-two-derived buckets with
/// a 32-byte minimum so ciphertext length no longer reveals exact plaintext
/// length to relays.
enum NIP44Padding {
static let minPaddedLength = 32
static let maxPlaintextLength = 65535
/// NIP-44's calc_padded_len: pad to 32 bytes minimum, then to a chunk
/// granularity of max(32, nextPowerOfTwo/8).
static func paddedLength(for unpaddedLength: Int) -> Int {
guard unpaddedLength > minPaddedLength else { return minPaddedLength }
// Smallest power of two strictly greater than (unpaddedLength - 1).
let nextPower = 1 << (Int.bitWidth - (unpaddedLength - 1).leadingZeroBitCount)
let chunk = nextPower <= 256 ? 32 : nextPower / 8
return chunk * ((unpaddedLength - 1) / chunk + 1)
}
/// Prefix plaintext with its 2-byte big-endian length and zero-pad to the
/// bucketed length. Rejects empty plaintexts and plaintexts that do not
/// fit the 16-bit length prefix.
static func pad(_ plaintext: Data) throws -> Data {
let length = plaintext.count
guard length >= 1, length <= maxPlaintextLength else {
throw NostrError.encryptionFailed
}
let padded = paddedLength(for: length)
var result = Data(capacity: 2 + padded)
result.append(UInt8(length >> 8))
result.append(UInt8(length & 0xFF))
result.append(plaintext)
result.append(Data(count: padded - length))
return result
}
/// Read the 2-byte length prefix, validate the total padded size matches
/// it exactly, and return the plaintext. Throws on any inconsistency so a
/// malformed (already-authenticated) payload can never over- or
/// under-read.
static func unpad(_ padded: Data) throws -> Data {
guard padded.count >= 2 else { throw NostrError.invalidCiphertext }
let start = padded.startIndex
let length = Int(padded[start]) << 8 | Int(padded[start + 1])
guard length >= 1,
padded.count == 2 + paddedLength(for: length) else {
throw NostrError.invalidCiphertext
}
return padded.subdata(in: (start + 2)..<(start + 2 + length))
}
}
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
private extension NostrProtocol {
+41 -240
View File
@@ -48,14 +48,7 @@ private struct URLSessionAdapter: NostrRelaySessionProtocol {
let base: URLSession
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
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)
URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url))
}
}
@@ -113,10 +106,7 @@ private extension NostrRelayManagerDependencies {
@MainActor
final class NostrRelayManager: ObservableObject {
static let shared = NostrRelayManager()
// 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.
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
private(set) static var pendingGiftWrapIDs = Set<String>()
static func registerPendingGiftWrap(id: String) {
pendingGiftWrapIDs.insert(id)
@@ -222,33 +212,7 @@ final class NostrRelayManager: ObservableObject {
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
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() {
self.dependencies = .live()
hasMutualFavorites = dependencies.hasMutualFavorites()
@@ -302,70 +266,7 @@ final class NostrRelayManager: ObservableObject {
}
.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
func connect() {
// Global network policy gate
@@ -380,8 +281,6 @@ final class NostrRelayManager: ObservableObject {
task.cancel(with: .goingAway, reason: nil)
}
connections.removeAll()
// Sockets are gone; drop every relay's inbound verify pipeline.
teardownAllRelayInboundPipelines()
markRelaySocketsClosed(resetState: false)
// Sockets are gone, so per-relay subscription state is cleared but
// durable intent (subscriptionRequestState, messageHandlers, parked
@@ -411,7 +310,6 @@ final class NostrRelayManager: ObservableObject {
task.cancel(with: .goingAway, reason: nil)
}
connections.removeAll()
teardownAllRelayInboundPipelines()
markRelaySocketsClosed(resetState: true)
subscriptions.removeAll()
pendingSubscriptions.removeAll()
@@ -662,7 +560,6 @@ final class NostrRelayManager: ObservableObject {
connection.cancel(with: .goingAway, reason: nil)
}
connections.removeValue(forKey: url)
teardownRelayInboundPipeline(for: url)
subscriptions.removeValue(forKey: url)
pendingSubscriptions.removeValue(forKey: url)
}
@@ -1002,11 +899,7 @@ final class NostrRelayManager: ObservableObject {
connections[urlString] = task
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
receiveMessage(from: task, relayUrl: urlString)
@@ -1065,13 +958,14 @@ final class NostrRelayManager: ObservableObject {
switch result {
case .success(let message):
// Hand the raw frame to this relay's serial inbound pipeline:
// parsing and signature verification run off-main, in arrival
// order, independently of every other relay's pipeline. Routing
// through the lock-guarded router keeps this off the main actor
// (no per-frame main hop).
self.inboundRouter.yield(InboundFrame(message: message), to: relayUrl)
// Parse off-main to reduce UI jank, then hop back for state updates
Task.detached(priority: .utility) {
guard let parsed = ParsedInbound(message) else { return }
await MainActor.run {
self.handleParsedMessage(parsed, from: relayUrl)
}
}
// Continue receiving
Task { @MainActor in
self.receiveMessage(from: task, relayUrl: relayUrl)
@@ -1089,55 +983,35 @@ final class NostrRelayManager: ObservableObject {
// Note: declared at file scope below to avoid MainActor isolation inside this class
// and keep parsing off the main actor.
/// 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)
// Handle parsed message on MainActor (state updates and handlers)
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
switch parsed {
case .event:
// Events flow through the serial inbound pipeline (precheck
// off-main signature verification deliverVerifiedInboundEvent)
// and never reach this fallback.
assertionFailure("inbound EVENT bypassed the verified pipeline")
case .event(let subId, let event):
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
self.relays[index].messagesReceived += 1
}
guard event.isValidSignature() else {
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):
if var tracker = eoseTrackers[subId] {
tracker.pendingRelays.remove(relayUrl)
@@ -1232,7 +1106,6 @@ final class NostrRelayManager: ObservableObject {
private func handleDisconnection(relayUrl: String, error: Error) {
connections.removeValue(forKey: relayUrl)
teardownRelayInboundPipeline(for: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error)
settleEOSETrackers(droppingRelay: relayUrl)
@@ -1321,9 +1194,8 @@ final class NostrRelayManager: ObservableObject {
if let connection = connections[normalizedRelayUrl] {
connection.cancel(with: .goingAway, reason: nil)
connections.removeValue(forKey: normalizedRelayUrl)
teardownRelayInboundPipeline(for: normalizedRelayUrl)
}
// Attempt immediate reconnection
connectToRelay(normalizedRelayUrl)
}
@@ -1416,77 +1288,6 @@ final class NostrRelayManager: ObservableObject {
// 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 {
case event(subId: String, event: NostrEvent)
case ok(eventId: String, success: Bool, reason: String)
-20
View File
@@ -55,26 +55,6 @@ enum TransportConfig {
static let nostrDuplicateEventLogInterval: Int = 50
// Sample interval for per-event debug logs on the inbound hot path.
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)
// Sample interval for the periodic store-audit "OK" heartbeat line
-5
View File
@@ -1177,11 +1177,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
// disconnect is not enough here.
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
// Clear Nostr identity associations
+1 -2
View File
@@ -103,8 +103,7 @@ final class GeoPresenceTracker {
else {
return
}
// The signature was already verified (exactly once, off the main
// actor) by NostrRelayManager before delivery.
guard event.isValidSignature() else { return }
guard shouldProcessGeoSamplingEvent(event.id) else { return }
let existingCount = context.geoParticipantCount(for: gh)
+89 -133
View File
@@ -75,38 +75,20 @@ extension ChatViewModel: NostrInboundPipelineContext {
}
}
/// The inbound Nostr hot path: verified relay events in, chat messages /
/// Noise payloads out. Pure transformation plus dedup no relay lifecycle.
/// The inbound Nostr hot path: raw relay events in, chat messages / Noise
/// payloads out. Pure transformation plus dedup no relay lifecycle.
///
/// Every event arriving here already had its Schnorr signature verified
/// exactly once, off the main actor, by `NostrRelayManager`'s serial inbound
/// pipeline (which records events into its own dedup cache only AFTER
/// verification, so forged copies can't suppress genuine events). This
/// pipeline therefore never re-verifies; it keeps its own event-ID dedup
/// (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.
/// Ordering is deliberate and performance-critical: cheap rejects (kind,
/// dedup lookup) run BEFORE Schnorr signature verification because duplicates
/// dominate real relay traffic; events are recorded only AFTER verification so
/// a forged-signature copy can never poison the dedup set; gift-wrap
/// verification for the account mailbox runs off-main with an atomic
/// main-actor check-and-record.
final class NostrInboundPipeline {
private weak var context: (any NostrInboundPipelineContext)?
private let presence: GeoPresenceTracker
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) {
self.context = context
self.presence = presence
@@ -115,15 +97,17 @@ final class NostrInboundPipeline {
@MainActor
func subscribeNostrEvent(_ event: NostrEvent) {
guard let context else { return }
// Cheap rejects (kind, dedup lookup) duplicates dominate real
// traffic. The signature was already verified (exactly once, off the
// main actor) by NostrRelayManager before delivery.
// Cheap rejects (kind, dedup lookup) before Schnorr verification
// duplicates dominate real traffic and must not pay for crypto.
// Only verified events are recorded, so a forged-signature copy can
// never poison the dedup set and suppress the genuine event.
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
!context.hasProcessedNostrEvent(event.id)
else {
return
}
guard event.isValidSignature() else { return }
context.recordProcessedNostrEvent(event.id)
@@ -192,14 +176,15 @@ final class NostrInboundPipeline {
@MainActor
func handleNostrEvent(_ event: NostrEvent) {
guard let context else { return }
// Cheap rejects (kind, dedup lookup) the signature was already
// verified (exactly once, off the main actor) by NostrRelayManager.
// Cheap rejects (kind, dedup lookup) before Schnorr verification
// duplicates dominate real traffic and must not pay for crypto.
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
else {
return
}
if context.hasProcessedNostrEvent(event.id) { return }
guard event.isValidSignature() else { return }
context.recordProcessedNostrEvent(event.id)
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
@@ -279,126 +264,104 @@ final class NostrInboundPipeline {
@MainActor
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard let context else { return }
// 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.
// Dedup lookup before Schnorr verification; record only after it passes.
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
guard giftWrap.isValidSignature() else { return }
context.recordProcessedNostrEvent(giftWrap.id)
// Capture the wipe generation at spawn, alongside the per-geohash
// identity (private key) the detached task strongly captures. A panic
// wipe between spawn and delivery bumps the generation, and the task
// drops its result instead of delivering plaintext post-wipe.
let wipeGeneration = self.wipeGeneration
Task.detached(priority: .userInitiated) { [weak self] in
await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: false, wipeGeneration: wipeGeneration)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: id
),
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let noisePayload = NoisePayload.decode(packet.payload)
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
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard let context else { return }
// Cheap dedup pre-check only; see subscribeGiftWrap.
// Dedup lookup before Schnorr verification; record only after it passes.
if context.hasProcessedNostrEvent(giftWrap.id) {
return
}
// 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 giftWrap.isValidSignature() else { return }
context.recordProcessedNostrEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: id
) else {
if verbose {
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", category: .session)
}
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", category: .session)
return
}
if verbose {
SecureLogger.debug(
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
category: .session
)
SecureLogger.debug(
"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
}
await MainActor.run {
// A panic wipe during the off-main decrypt must not let the
// pre-wipe plaintext reach post-wipe state; drop it here, atomic
// 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)
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
}
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
func handleNostrMessage(_ giftWrap: NostrEvent) {
guard let context else { return }
// Cheap dedup pre-check only; processNostrMessage does the
// authoritative check-and-record before the off-main NIP-17 unwrap.
// The outer signature was already verified (exactly once, off the
// main actor) by NostrRelayManager, and only verified events are
// recorded, so a forged-signature copy can never poison the dedup
// set and suppress the genuine event.
// Cheap dedup pre-check only; Schnorr verification runs off-main in
// processNostrMessage, which then does the authoritative
// check-and-record. Recording stays after verification so a
// forged-signature copy can never poison the dedup set and suppress
// the genuine event.
if context.hasProcessedNostrEvent(giftWrap.id) { return }
Task.detached(priority: .userInitiated) { [weak self] in
@@ -407,6 +370,7 @@ final class NostrInboundPipeline {
}
func processNostrMessage(_ giftWrap: NostrEvent) async {
guard giftWrap.isValidSignature() else { return }
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.
@@ -416,13 +380,8 @@ final class NostrInboundPipeline {
return false
}
if alreadyProcessed { return }
// Fetch the identity and the wipe generation in ONE main-actor hop:
// 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)
let currentIdentity: NostrIdentity? = await MainActor.run {
context.currentNostrIdentity()
}
guard let currentIdentity else { return }
@@ -454,9 +413,6 @@ final class NostrInboundPipeline {
let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
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)
switch payload.type {
@@ -382,12 +382,10 @@ 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)
@@ -400,76 +398,30 @@ 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)
}
@Test @MainActor
func handleGiftWrap_panicWipeAfterSpawnDropsDecryptedResult() async throws {
func processNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let recipient = 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(
content: "verify:noop",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
var invalidGiftWrap = giftWrap
invalidGiftWrap.sig = String(repeating: "0", count: 128)
// 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])
// A forged-signature copy is rejected WITHOUT entering the dedup set...
await coordinator.inbound.processNostrMessage(invalidGiftWrap)
#expect(context.recordedNostrEventIDs.isEmpty)
// ...so the genuine event with the same ID still processes and records.
await coordinator.inbound.processNostrMessage(giftWrap)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
}
+27 -12
View File
@@ -352,11 +352,31 @@ struct ChatViewModelNostrExtensionTests {
#expect(!viewModel.messages.contains { $0.content == "Blocked" })
}
// 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 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" })
}
@Test @MainActor
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
@@ -545,14 +565,9 @@ struct ChatViewModelNostrExtensionTests {
viewModel.handleGiftWrap(giftWrap, id: recipient)
// 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)
try? await Task.sleep(nanoseconds: 50_000_000)
#expect(viewModel.privateChats[convKey] == nil)
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
}
@Test @MainActor
+20 -5
View File
@@ -326,11 +326,26 @@ struct ChatViewModelPresenceHandlingTests {
#expect(viewModel.geohashParticipantCount(for: activeGeohash) >= 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`.
@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)
}
// MARK: - Test Helper
+153
View File
@@ -289,6 +289,159 @@ struct NostrProtocolTests {
#expect(object["limit"] as? Int == 42)
}
// MARK: - Padding (v3 envelope)
@Test func paddedLengthMatchesNIP44Buckets() {
// Vectors from the NIP-44 reference test suite (calc_padded_len).
let vectors: [(Int, Int)] = [
(1, 32), (16, 32), (32, 32), (33, 64), (37, 64), (45, 64), (49, 64),
(64, 64), (65, 96), (100, 128), (111, 128), (200, 224), (250, 256),
(320, 320), (383, 384), (384, 384), (400, 448), (500, 512),
(512, 512), (515, 640), (700, 768), (800, 896), (900, 1024),
(1020, 1024), (65535, 65536)
]
for (unpadded, expected) in vectors {
#expect(
NIP44Padding.paddedLength(for: unpadded) == expected,
"paddedLength(for: \(unpadded)) should be \(expected)"
)
}
}
@Test func padUnpadRoundTrip() throws {
for length in [1, 2, 31, 32, 33, 100, 320, 1020, 4096, 65535] {
let plaintext = Data((0..<length).map { _ in UInt8.random(in: .min ... .max) })
let padded = try NIP44Padding.pad(plaintext)
#expect(padded.count == 2 + NIP44Padding.paddedLength(for: length))
let unpadded = try NIP44Padding.unpad(padded)
#expect(unpadded == plaintext)
}
}
@Test func padHidesExactLengthWithinBucket() throws {
// Two plaintexts of different length in the same bucket must produce
// identically sized padded payloads (and thus ciphertexts).
let short = try NIP44Padding.pad(Data(repeating: 0x41, count: 65))
let long = try NIP44Padding.pad(Data(repeating: 0x42, count: 96))
#expect(short.count == long.count)
}
@Test func padRejectsOutOfRangePlaintexts() {
#expect(throws: (any Error).self) { try NIP44Padding.pad(Data()) }
#expect(throws: (any Error).self) { try NIP44Padding.pad(Data(count: 65536)) }
}
@Test func unpadRejectsTamperedLengthPrefix() throws {
var padded = try NIP44Padding.pad(Data(repeating: 0x41, count: 40))
// Claimed length larger than the actual payload
var tooLong = padded
tooLong[tooLong.startIndex] = 0xFF
tooLong[tooLong.startIndex + 1] = 0xFF
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(tooLong) }
// Claimed length of zero
var zero = padded
zero[zero.startIndex] = 0x00
zero[zero.startIndex + 1] = 0x00
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(zero) }
// Claimed length whose bucket does not match the payload size
// (payload is bucket 64; a claimed length of 20 expects bucket 32)
var wrongBucket = padded
wrongBucket[wrongBucket.startIndex] = 0x00
wrongBucket[wrongBucket.startIndex + 1] = 0x14
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(wrongBucket) }
// Truncated payloads
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(Data()) }
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(Data([0x00])) }
padded.removeLast()
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(padded) }
// Works on Data slices with non-zero startIndex
let sliced = try (Data([0xAB]) + NIP44Padding.pad(Data(repeating: 0x41, count: 40))).dropFirst()
#expect(try NIP44Padding.unpad(sliced) == Data(repeating: 0x41, count: 40))
}
@Test func paddedEnvelopeRoundTrip_v3() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let plaintext = "padded envelope test"
let ciphertext = try NostrProtocol.encrypt(
plaintext: plaintext,
recipientPubkey: recipient.publicKeyHex,
senderKey: sender.schnorrSigningKey(),
padded: true
)
#expect(ciphertext.hasPrefix("v3:"))
let decrypted = try NostrProtocol.decrypt(
ciphertext: ciphertext,
senderPubkey: sender.publicKeyHex,
recipientKey: recipient.schnorrSigningKey()
)
#expect(decrypted == plaintext)
}
@Test func legacyUnpaddedEnvelopeStillDecrypts_v2() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let plaintext = "legacy v2 envelope"
// What deployed clients send today.
let ciphertext = try NostrProtocol.encrypt(
plaintext: plaintext,
recipientPubkey: recipient.publicKeyHex,
senderKey: sender.schnorrSigningKey(),
padded: false
)
#expect(ciphertext.hasPrefix("v2:"))
let decrypted = try NostrProtocol.decrypt(
ciphertext: ciphertext,
senderPubkey: sender.publicKeyHex,
recipientKey: recipient.schnorrSigningKey()
)
#expect(decrypted == plaintext)
}
@Test func outgoingMessagesStillUseV2UntilRolloutFlagFlips() throws {
// Deployed clients reject anything that is not "v2:", so the padded
// envelope must stay off by default until decrypt-side support is
// widely shipped (see NostrProtocol.sendPaddedEnvelope).
#expect(NostrProtocol.sendPaddedEnvelope == false)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let giftWrap = try NostrProtocol.createPrivateMessage(
content: "default envelope",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
#expect(giftWrap.content.hasPrefix("v2:"))
}
@Test func decryptRejectsUnknownEnvelopeVersion() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let ciphertext = try NostrProtocol.encrypt(
plaintext: "test",
recipientPubkey: recipient.publicKeyHex,
senderKey: sender.schnorrSigningKey(),
padded: false
)
let mutated = "v9:" + ciphertext.dropFirst(3)
#expect(throws: NostrError.invalidCiphertext) {
_ = try NostrProtocol.decrypt(
ciphertext: mutated,
senderPubkey: sender.publicKeyHex,
recipientKey: recipient.schnorrSigningKey()
)
}
}
// MARK: - Helpers
private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
@@ -77,10 +77,8 @@ final class PerformanceBaselineTests: XCTestCase {
// MARK: - 1a. Nostr inbound event handling (fresh events)
/// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events
/// (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.
/// (kind 20000): signature verification, dedup record, presence/nickname
/// bookkeeping, and public-message ingest scheduling.
func testNostrInboundEventHandling_freshEvents() throws {
let events = try Self.makeSignedGeohashEvents(count: 500)
// A fresh context per measure pass so every event takes the
@@ -108,9 +106,8 @@ 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. It is a
/// pure dedup lookup: no crypto (verification happens upstream in
/// `NostrRelayManager`, and only for the first-seen copy).
/// this path runs hundreds of times a minute in busy geohashes. Note it
/// still pays full Schnorr signature verification before the dedup check.
func testNostrInboundEventHandling_duplicateEvents() throws {
let events = try Self.makeSignedGeohashEvents(count: 500)
let context = PerfNostrContext()
@@ -639,15 +639,11 @@ 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)
// 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 {
let countedOnBothRelays = await waitUntil {
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
receivedIDs == [event.id] &&
context.manager.debugDuplicateInboundEventDropCount == 1
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
}
XCTAssertTrue(settled)
XCTAssertTrue(countedOnBothRelays)
XCTAssertEqual(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 1)
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 1)
@@ -680,17 +676,12 @@ final class NostrRelayManagerTests: XCTestCase {
)
}
// 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 {
let countedOnEveryRelay = 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(settled)
XCTAssertTrue(countedOnEveryRelay)
XCTAssertEqual(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, relayURLs.count - 1)
XCTAssertEqual(
@@ -723,153 +714,16 @@ 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)
// 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 {
let countedOnBothRelays = await waitUntil {
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
receivedIDs == [event.id]
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
}
XCTAssertTrue(genuineDelivered)
XCTAssertTrue(countedOnBothRelays)
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)
@@ -1841,11 +1695,7 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
}
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
if !pendingResults.isEmpty {
completionHandler(pendingResults.removeFirst())
} else {
receiveHandler = completionHandler
}
receiveHandler = completionHandler
}
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
@@ -1878,24 +1728,15 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
}
func emitRawString(_ string: String) throws {
deliver(.success(.string(string)))
let handler = receiveHandler
receiveHandler = nil
handler?(.success(.string(string)))
}
private func emit(jsonObject: Any) throws {
let data = try JSONSerialization.data(withJSONObject: jsonObject)
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)
}
let handler = receiveHandler
receiveHandler = nil
handler?(.success(.data(data)))
}
}
@@ -8,12 +8,44 @@
import struct Foundation.Data
/// Lowercase hex digits used by `hexEncodedString()`.
private let hexDigits: [UInt8] = Array("0123456789abcdef".utf8)
/// Maps an ASCII byte to its hex nibble value, or nil for non-hex characters.
/// Accepts both lowercase and uppercase hex digits.
@inline(__always)
private func hexNibble(_ ascii: UInt8) -> UInt8? {
switch ascii {
case UInt8(ascii: "0")...UInt8(ascii: "9"):
return ascii - UInt8(ascii: "0")
case UInt8(ascii: "a")...UInt8(ascii: "f"):
return ascii - UInt8(ascii: "a") + 10
case UInt8(ascii: "A")...UInt8(ascii: "F"):
return ascii - UInt8(ascii: "A") + 10
default:
return nil
}
}
public extension Data {
/// Lowercase hex representation of the bytes.
///
/// Lookup-table based: this sits on the hot BLE receive path (it is called
/// several times per received packet via `PeerID(hexData:)`), where the
/// previous per-byte `String(format: "%02x", _)` implementation spent most
/// of its time re-parsing the format string through Foundation.
func hexEncodedString() -> String {
if self.isEmpty {
if isEmpty {
return ""
}
return self.map { String(format: "%02x", $0) }.joined()
var output = [UInt8](repeating: 0, count: count * 2)
var i = 0
for byte in self {
output[i] = hexDigits[Int(byte >> 4)]
output[i + 1] = hexDigits[Int(byte & 0x0F)]
i += 2
}
return String(decoding: output, as: UTF8.self)
}
/// Initialize Data from a hex string.
@@ -28,28 +60,28 @@ public extension Data {
hex = String(hex.dropFirst(2))
}
let ascii = Array(hex.utf8)
// Reject odd-length strings
guard hex.count % 2 == 0 else {
guard ascii.count % 2 == 0 else {
return nil
}
// Reject empty strings
guard !hex.isEmpty else {
// Accept empty strings
guard !ascii.isEmpty else {
self = Data()
return
}
let len = hex.count / 2
var data = Data(capacity: len)
var index = hex.startIndex
for _ in 0..<len {
let nextIndex = hex.index(index, offsetBy: 2)
guard let byte = UInt8(String(hex[index..<nextIndex]), radix: 16) else {
var data = Data(capacity: ascii.count / 2)
var index = 0
while index < ascii.count {
guard let high = hexNibble(ascii[index]),
let low = hexNibble(ascii[index + 1]) else {
return nil
}
data.append(byte)
index = nextIndex
data.append((high << 4) | low)
index += 2
}
self = data
@@ -0,0 +1,78 @@
//
// DataHexTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import BitFoundation
struct DataHexTests {
// MARK: - Encoding
@Test func encode_knownVectors() {
#expect(Data().hexEncodedString() == "")
#expect(Data([0x00]).hexEncodedString() == "00")
#expect(Data([0x0f]).hexEncodedString() == "0f")
#expect(Data([0xf0]).hexEncodedString() == "f0")
#expect(Data([0xff]).hexEncodedString() == "ff")
#expect(Data([0xde, 0xad, 0xbe, 0xef]).hexEncodedString() == "deadbeef")
#expect(Data([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]).hexEncodedString() == "0123456789abcdef")
}
@Test func encode_allByteValues_matchesFormatReference() {
let all = Data((0...255).map { UInt8($0) })
let reference = (0...255).map { String(format: "%02x", $0) }.joined()
#expect(all.hexEncodedString() == reference)
}
@Test func encode_worksOnDataSlices() {
let data = Data([0xaa, 0xde, 0xad, 0xbe, 0xef, 0xbb])
let slice = data.dropFirst().dropLast()
#expect(slice.hexEncodedString() == "deadbeef")
}
// MARK: - Decoding
@Test func decode_knownVectors() {
#expect(Data(hexString: "deadbeef") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: "DEADBEEF") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: "DeAdBeEf") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: "00") == Data([0x00]))
#expect(Data(hexString: "0123456789abcdef") == Data([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]))
}
@Test func decode_handlesPrefixAndWhitespace() {
#expect(Data(hexString: "0xdeadbeef") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: "0XDEADBEEF") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: " deadbeef\n") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: "") == Data())
#expect(Data(hexString: "0x") == Data())
}
@Test func decode_rejectsInvalidInput() {
#expect(Data(hexString: "abc") == nil) // odd length
#expect(Data(hexString: "zz") == nil) // non-hex characters
#expect(Data(hexString: "0xg1") == nil) // non-hex after prefix
#expect(Data(hexString: "+f") == nil) // sign characters are not hex
#expect(Data(hexString: "-0") == nil)
#expect(Data(hexString: "a\u{00e9}") == nil) // non-ASCII
#expect(Data(hexString: "de ad") == nil) // interior whitespace
}
// MARK: - Round trip
@Test func roundTrip_randomLengths() {
for length in [0, 1, 2, 3, 8, 16, 31, 32, 33, 64, 255, 1024] {
let data = Data((0..<length).map { _ in UInt8.random(in: .min ... .max) })
let hex = data.hexEncodedString()
#expect(hex.count == length * 2)
#expect(Data(hexString: hex) == data)
#expect(Data(hexString: hex.uppercased()) == data)
}
}
}