From fb80403cd4f4b978c85bbed0713613d5681c813f Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 6 Jul 2026 20:31:09 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Gateway=20mode:=20opt-in=20mesh=E2=86=94Nos?= =?UTF-8?q?tr=20uplink=20for=20geohash=20channels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An opt-in "internet gateway" toggle lets one connected phone bridge the local geohash channel for mesh-only peers: signed kind-20000 events ride a new nostrCarrier (0x28) packet — directed to the gateway for uplink, broadcast with TTL for downlink — with Schnorr verification at every hop, CourierStore-style quotas, and explicit loop-prevention rules. - BitFoundation: MessageType.nostrCarrier = 0x28 - NostrCarrierPacket: 2-byte-length TLV codec (direction, geohash, signed event JSON), 16 KiB cap, tolerant decoder - GatewayService: closure-injected policy layer — verify gates (sig, kind, #g tag, age, size), uplink quotas (10/min/depositor rate limit, offline queue of 20 total / 5 per depositor, drop-oldest, flush on reconnect), downlink budget (30/min, bounded drop-oldest backlog), bounded loop-prevention ID sets - BLEService: runtime capability bits (advertise .gateway only while the toggle is on, re-announce on change), signed directed uplink sends, carrier ingress with depositor signature verification - Mesh-only senders uplink automatically from sendGeohash when no relay is connected and a reachable peer advertises .gateway; once-per- channel "sent via mesh gateway" notice - UI: gateway toggle beside the Tor toggle, globe header indicator, VoiceOver labels, xcstrings entries Co-Authored-By: Claude Fable 5 --- bitchat/App/LocationChannelsModel.swift | 16 +- bitchat/Localizable.xcstrings | 60 +++ bitchat/Protocols/NostrCarrierPacket.swift | 136 ++++++ .../BLE/BLEOutboundPacketPolicy.swift | 2 +- bitchat/Services/BLE/BLEPeerRegistry.swift | 5 + bitchat/Services/BLE/BLEReceivePipeline.swift | 5 +- bitchat/Services/BLE/BLEService.swift | 120 ++++- bitchat/Services/Gateway/GatewayService.swift | 388 +++++++++++++++ bitchat/Sync/SyncTypeFlags.swift | 4 + .../ChatViewModelBootstrapper.swift | 63 +++ .../GeohashSubscriptionManager.swift | 26 + bitchat/Views/ContentHeaderView.swift | 13 + bitchat/Views/LocationChannelsSheet.swift | 30 ++ .../Protocols/NostrCarrierPacketTests.swift | 96 ++++ .../Services/GatewayServiceTests.swift | 453 ++++++++++++++++++ .../Sources/BitFoundation/MessageType.swift | 7 +- 16 files changed, 1416 insertions(+), 8 deletions(-) create mode 100644 bitchat/Protocols/NostrCarrierPacket.swift create mode 100644 bitchat/Services/Gateway/GatewayService.swift create mode 100644 bitchatTests/Protocols/NostrCarrierPacketTests.swift create mode 100644 bitchatTests/Services/GatewayServiceTests.swift diff --git a/bitchat/App/LocationChannelsModel.swift b/bitchat/App/LocationChannelsModel.swift index c820455a..d7431d6d 100644 --- a/bitchat/App/LocationChannelsModel.swift +++ b/bitchat/App/LocationChannelsModel.swift @@ -12,20 +12,26 @@ final class LocationChannelsModel: ObservableObject { @Published private(set) var bookmarkNames: [String: String] @Published private(set) var locationNames: [GeohashChannelLevel: String] @Published private(set) var userTorEnabled: Bool + @Published private(set) var gatewayEnabled: Bool private let manager: LocationChannelManager private let network: NetworkActivationService + private let gateway: GatewayService private var cancellables = Set() init( manager: LocationChannelManager? = nil, - network: NetworkActivationService? = nil + network: NetworkActivationService? = nil, + gateway: GatewayService? = nil ) { let manager = manager ?? .shared let network = network ?? .shared + let gateway = gateway ?? .shared self.manager = manager self.network = network + self.gateway = gateway + self.gatewayEnabled = gateway.isEnabled self.permissionState = manager.permissionState self.availableChannels = manager.availableChannels self.selectedChannel = manager.selectedChannel @@ -96,6 +102,10 @@ final class LocationChannelsModel: ObservableObject { network.setUserTorEnabled(enabled) } + func setGatewayEnabled(_ enabled: Bool) { + gateway.setEnabled(enabled) + } + func refreshMeshChannelsIfNeeded() { guard case .mesh = selectedChannel, permissionState == .authorized, @@ -160,6 +170,10 @@ final class LocationChannelsModel: ObservableObject { network.$userTorEnabled .receive(on: DispatchQueue.main) .assign(to: &$userTorEnabled) + + gateway.$isEnabled + .receive(on: DispatchQueue.main) + .assign(to: &$gatewayEnabled) } private func level(forLength length: Int) -> GeohashChannelLevel { diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 7301e613..c8f4517e 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -9348,6 +9348,18 @@ } } }, + "content.accessibility.gateway_active" : { + "comment" : "Accessibility label for the internet gateway indicator", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Internet gateway active, sharing your connection with the mesh" + } + } + } + }, "content.accessibility.jump_to_latest" : { "comment" : "Accessibility label for the jump to latest messages button", "extractionState" : "manual", @@ -17872,6 +17884,18 @@ } } }, + "content.header.gateway_active" : { + "comment" : "Tooltip for the internet gateway indicator", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sharing your internet connection with nearby mesh peers" + } + } + } + }, "content.header.people" : { "extractionState" : "manual", "localizations" : { @@ -25713,6 +25737,30 @@ } } }, + "location_channels.gateway.subtitle" : { + "comment" : "Explanation under the internet gateway toggle", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "share your internet with nearby mesh peers so their geohash messages reach the network" + } + } + } + }, + "location_channels.gateway.title" : { + "comment" : "Title for the internet gateway toggle in the location channels sheet", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "internet gateway" + } + } + } + }, "location_channels.loading_nearby" : { "extractionState" : "manual", "localizations" : { @@ -33535,6 +33583,18 @@ } } }, + "system.gateway.sent_via_mesh" : { + "comment" : "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sent via mesh gateway" + } + } + } + }, "system.geohash.blocked" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/Protocols/NostrCarrierPacket.swift b/bitchat/Protocols/NostrCarrierPacket.swift new file mode 100644 index 00000000..e65f2b66 --- /dev/null +++ b/bitchat/Protocols/NostrCarrierPacket.swift @@ -0,0 +1,136 @@ +// +// NostrCarrierPacket.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation + +/// Wire payload for `MessageType.nostrCarrier` (0x28): a complete, signed +/// Nostr event ferried over the mesh between a mesh-only peer and an +/// internet gateway peer. +/// +/// - `toGateway` rides a DIRECTED packet (recipientID = the gateway peer): +/// a mesh-only sender asks the gateway to publish its locally signed +/// geohash event to Nostr relays. +/// - `fromGateway` rides a BROADCAST packet (default TTL): the gateway +/// rebroadcasts inbound relay events so mesh-only peers see the channel. +/// +/// The carried event is public geohash chat — already plaintext on Nostr — +/// so the carrier adds no encryption. It IS signed by the originator's +/// per-geohash identity, so neither the gateway nor any mesh relay can forge +/// or alter it undetected: gateways and receivers verify the Schnorr +/// signature before acting on it. +/// +/// TLV encoding with 2-byte big-endian lengths (the event JSON exceeds the +/// 1-byte TLV range used by smaller packets). Unknown TLV types are skipped +/// for forward compatibility. +struct NostrCarrierPacket: Equatable { + enum Direction: UInt8 { + case toGateway = 0x01 + case fromGateway = 0x02 + } + + let direction: Direction + let geohash: String + /// Complete signed Nostr event JSON (id, pubkey, created_at, kind, tags, + /// content, sig). + let eventJSON: Data + + /// BLE airtime cap for a carried event. + static let maxEventJSONBytes = 16 * 1024 + static let maxGeohashLength = 12 + + private enum TLVType: UInt8 { + case direction = 0x01 + case geohash = 0x02 + case eventJSON = 0x03 + } + + init?(direction: Direction, geohash: String, eventJSON: Data) { + let geohashBytes = Data(geohash.utf8) + guard !geohashBytes.isEmpty, + geohashBytes.count <= Self.maxGeohashLength, + !eventJSON.isEmpty, + eventJSON.count <= Self.maxEventJSONBytes else { + return nil + } + self.direction = direction + self.geohash = geohash + self.eventJSON = eventJSON + } + + init?(direction: Direction, geohash: String, event: NostrEvent) { + guard let json = try? event.jsonString(), !json.isEmpty else { return nil } + self.init(direction: direction, geohash: geohash, eventJSON: Data(json.utf8)) + } + + /// Decodes the carried event. Callers MUST still verify + /// `event.isValidSignature()` before publishing or displaying it. + func event() -> NostrEvent? { + guard let dict = try? JSONSerialization.jsonObject(with: eventJSON) as? [String: Any] else { + return nil + } + return try? NostrEvent(from: dict) + } + + func encode() -> Data? { + var data = Data() + data.reserveCapacity(eventJSON.count + geohash.utf8.count + 12) + + func appendTLV(_ type: TLVType, _ value: Data) { + data.append(type.rawValue) + data.append(UInt8((value.count >> 8) & 0xFF)) + data.append(UInt8(value.count & 0xFF)) + data.append(value) + } + + appendTLV(.direction, Data([direction.rawValue])) + appendTLV(.geohash, Data(geohash.utf8)) + appendTLV(.eventJSON, eventJSON) + return data + } + + static func decode(_ data: Data) -> NostrCarrierPacket? { + // Defensive slice re-base (Data slices keep parent indices). + let data = Data(data) + var offset = 0 + var direction: Direction? + var geohash: String? + var eventJSON: Data? + + while offset + 3 <= data.count { + let typeRaw = data[offset] + let length = (Int(data[offset + 1]) << 8) | Int(data[offset + 2]) + offset += 3 + guard offset + length <= data.count else { return nil } + let value = data.subdata(in: offset.. [PeerID] { + peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID) + } + func displayNicknames(selfNickname: String) -> [PeerID: String] { let connected = peers.filter { $0.value.isConnected } let tuples = connected.map { ($0.key, $0.value.nickname, true) } diff --git a/bitchat/Services/BLE/BLEReceivePipeline.swift b/bitchat/Services/BLE/BLEReceivePipeline.swift index 71aacbaf..9bc30011 100644 --- a/bitchat/Services/BLE/BLEReceivePipeline.swift +++ b/bitchat/Services/BLE/BLEReceivePipeline.swift @@ -51,8 +51,11 @@ struct BLEReceivePipeline { // Courier envelopes are directed opaque ciphertext like DMs; a // remote handover toward a relayed announce rides this same // deterministic relay treatment instead of the broadcast clamp. + // Directed nostrCarrier uplinks (mesh-only peer -> gateway) need + // the same multi-hop treatment to reach a non-adjacent gateway. isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue - || packet.type == MessageType.courierEnvelope.rawValue) && packet.recipientID != nil, + || packet.type == MessageType.courierEnvelope.rawValue + || packet.type == MessageType.nostrCarrier.rawValue) && packet.recipientID != nil, isFragment: packet.type == MessageType.fragment.rawValue, isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, isHandshake: packet.type == MessageType.noiseHandshake.rawValue, diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index d1ed84f2..111eb780 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -60,6 +60,14 @@ final class BLEService: NSObject { // Local-only store-and-forward counters; nil in unit tests. var sfMetrics: StoreAndForwardMetrics? + // Gateway mode: sink for received nostrCarrier packets (set by app + // wiring, called on the main actor after transport-level checks) and the + // runtime-toggled capability bits ORed into `PeerCapabilities.localSupported` + // for every announce. `directedToUs` distinguishes an uplink deposit + // addressed to this device from a downlink broadcast. + var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)? + private var runtimeCapabilities: PeerCapabilities = [] // collectionsQueue + #if DEBUG // Test-only tap on the outbound pipeline so multi-node tests can ferry // packets between in-process service instances. @@ -625,6 +633,32 @@ final class BLEService: NSObject { collectionsQueue.sync { peerRegistry.capabilities(for: peerID) } } + /// Enables or disables a runtime-advertised capability bit (e.g. the + /// internet-gateway toggle) and re-announces so peers learn promptly. + /// Build-time bits stay in `PeerCapabilities.localSupported`. + func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool) { + let changed: Bool = collectionsQueue.sync(flags: .barrier) { + let before = runtimeCapabilities + if enabled { + runtimeCapabilities.insert(capability) + } else { + runtimeCapabilities.remove(capability) + } + return runtimeCapabilities != before + } + guard changed else { return } + sendAnnounce(forceSend: true) + } + + /// Reachable peers currently advertising the `.gateway` capability. + func reachableGatewayPeers() -> [PeerID] { + let now = Date() + return collectionsQueue.sync { + peerRegistry.peers(advertising: .gateway) + .filter { peerRegistry.isReachable($0, now: now) } + } + } + func getPeerNicknames() -> [PeerID: String] { return collectionsQueue.sync { peerRegistry.displayNicknames(selfNickname: myNickname) @@ -1259,16 +1293,16 @@ final class BLEService: NSObject { let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification let signingPub = noiseService.getSigningPublicKeyData() // For signature verification - let connectedPeerIDs: [Data] = collectionsQueue.sync { - peerRegistry.connectedRoutingData + let (connectedPeerIDs, advertisedCapabilities): ([Data], PeerCapabilities) = collectionsQueue.sync { + (peerRegistry.connectedRoutingData, PeerCapabilities.localSupported.union(runtimeCapabilities)) } - + let announcement = AnnouncementPacket( nickname: myNickname, noisePublicKey: noisePub, signingPublicKey: signingPub, directNeighbors: connectedPeerIDs, - capabilities: PeerCapabilities.localSupported + capabilities: advertisedCapabilities ) guard let payload = announcement.encode() else { @@ -2716,6 +2750,81 @@ extension BLEService { } } + // MARK: Gateway carrier (nostrCarrier) + + /// Sign and send an encoded `toGateway` carrier payload directed at a + /// gateway peer. The packet is signed so the gateway can key its uplink + /// quotas to an authenticated depositor; the carried Nostr event has its + /// own Schnorr signature for content authenticity. Returns false when + /// the gateway is not reachable or signing fails. + func sendNostrCarrier(_ payload: Data, to gatewayPeer: PeerID) -> Bool { + guard isPeerReachable(gatewayPeer) else { return false } + let packet = BitchatPacket( + type: MessageType.nostrCarrier.rawValue, + senderID: myPeerIDData, + recipientID: Data(hexString: gatewayPeer.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: messageTTL + ) + guard let signed = noiseService.signPacket(packet) else { return false } + messageQueue.async { [weak self] in + // broadcastPacket applies a known route when one exists and + // otherwise floods the directed packet like a DM, so a gateway + // that is reachable but multi-hop still gets the deposit. + self?.broadcastPacket(signed) + } + return true + } + + /// Broadcast an encoded `fromGateway` carrier payload on the mesh with + /// the default TTL. Unsigned at the packet layer — receivers verify the + /// carried event's own Schnorr signature. + func broadcastNostrCarrier(_ payload: Data) { + let packet = BitchatPacket( + type: MessageType.nostrCarrier.rawValue, + senderID: myPeerIDData, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: messageTTL + ) + messageQueue.async { [weak self] in + self?.broadcastPacket(packet) + } + } + + /// Transport-level handling for a received nostrCarrier packet; policy + /// (verification of the carried event, quotas, loop prevention) lives in + /// `GatewayService` behind `onNostrCarrierPacket`. + private func handleNostrCarrier(_ packet: BitchatPacket, from peerID: PeerID) { + let senderID = PeerID(hexData: packet.senderID) + let directedToUs: Bool + if let recipientID = packet.recipientID { + // Carriers addressed elsewhere ride the generic relay path untouched. + guard recipientID == myPeerIDData else { return } + // Uplink deposit: quotas are keyed by the depositor, so the + // packet signature must verify against the sender's announced + // signing key. Unlike courier deposits the depositor may be + // multi-hop away, so ingress-link identity is not required. + let signingKey = collectionsQueue.sync { peerRegistry.info(for: senderID)?.signingPublicKey } + guard let signingKey, + noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { + SecureLogger.debug("🌐 nostrCarrier uplink from \(senderID.id.prefix(8))… rejected (missing/invalid packet signature)", category: .security) + return + } + directedToUs = true + } else { + directedToUs = false + } + let payload = packet.payload + notifyUI { [weak self] in + self?.onNostrCarrierPacket?(payload, senderID, directedToUs) + } + } + // MARK: Link capability snapshots (thread-safe via bleQueue) private func readLinkState(_ body: (BLELinkStateStore) -> T) -> T { @@ -3210,6 +3319,9 @@ extension BLEService { case .courierEnvelope: handleCourierEnvelope(packet, from: peerID) + case .nostrCarrier: + handleNostrCarrier(packet, from: peerID) + case .leave: handleLeave(packet, from: senderID) diff --git a/bitchat/Services/Gateway/GatewayService.swift b/bitchat/Services/Gateway/GatewayService.swift new file mode 100644 index 00000000..20924589 --- /dev/null +++ b/bitchat/Services/Gateway/GatewayService.swift @@ -0,0 +1,388 @@ +// +// GatewayService.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import Combine +import Foundation + +/// Policy engine for gateway mode: an opt-in "share my internet with the +/// mesh" bridge. While the toggle is on, this device advertises the +/// `.gateway` capability bit, publishes signed geohash events deposited by +/// mesh-only peers to Nostr relays (uplink), and rebroadcasts inbound relay +/// events onto the mesh (downlink) so mesh-only peers can take part in the +/// local geohash channel. Mesh-only peers need no toggle: their uplink +/// engages automatically when relays are unreachable and a gateway peer +/// exists. +/// +/// Threat model: +/// - Keys never leave the originating device. Mesh-only senders sign events +/// locally with their per-geohash ephemeral identity; the gateway carries +/// only the finished, signed event. +/// - The gateway cannot forge or alter events: every carried event is +/// Schnorr-verified here before it is published or rebroadcast, and again +/// independently by relays and receivers. +/// - Carried contents are public geohash chat, already plaintext on Nostr, +/// so the mesh carrier adds no confidentiality loss. +/// +/// Loop-prevention rules (each enforced here and unit-tested): +/// 1. An event learned from a `fromGateway` mesh broadcast is never +/// re-published to relays, never re-uplinked, and never rebroadcast +/// (`meshBroadcastEventIDs`), so a second gateway on the same mesh cannot +/// echo mesh-carried traffic back out. Mesh-level propagation of the +/// original broadcast packet is the TTL relay's job, not ours. +/// 2. An uplink deposit is published at most once (`publishedEventIDs`) and +/// a relay event is rebroadcast at most once (`rebroadcastEventIDs`), so +/// repeat deposits and relay echoes are absorbed. +/// 3. Uplink is only attempted for locally composed events at the send site +/// (`GeohashSubscriptionManager.sendGeohash`); events received over the +/// carrier never re-enter the uplink path. +/// Rebroadcast storms at the mesh layer are additionally bounded by the BLE +/// `MessageDeduplicator` and packet TTL, and receivers dedup carried events +/// against their own relay subscriptions via the Nostr event-ID cache in +/// `NostrInboundPipeline`. +/// +/// All dependencies are closure-injected (repo convention) so the policy +/// layer is unit-testable without relays or radios. +@MainActor +final class GatewayService: ObservableObject { + enum Limits { + /// Uplink deposits held while relays are unreachable (CourierStore-style + /// bounded mailbag: bounded total, bounded per depositor). + static let maxQueuedUplinks = 20 + static let maxQueuedUplinksPerDepositor = 5 + /// Uplink deposits accepted per depositor per minute. + static let uplinkEventsPerMinutePerDepositor = 10 + /// Downlink mesh rebroadcasts per minute — BLE airtime is precious. + /// Beyond the budget events queue (bounded, drop-oldest) and drain as + /// the window frees; a busy channel triggers frequent drains, a quiet + /// one never queues. + static let downlinkEventsPerMinute = 30 + static let maxPendingDownlinks = 30 + /// Accepted clock skew for a carried ephemeral event; anything older + /// is stale replay the relays would drop anyway. + static let maxEventAgeSeconds: TimeInterval = 15 * 60 + /// Bounded loop-prevention ID caches (oldest evicted). + static let maxTrackedEventIDs = 512 + } + + struct QueuedUplink { + let depositor: PeerID + let geohash: String + let event: NostrEvent + let queuedAt: Date + } + + static let shared = GatewayService() + + /// The user toggle. While true this device advertises `.gateway` and + /// bridges mesh <-> Nostr for geohash channels. + @Published private(set) var isEnabled: Bool + + // MARK: Wiring (set once by the bootstrapper; fakes in tests) + + /// Publishes a verified event to the geo relays for a geohash. + var publishToRelays: (@MainActor (NostrEvent, String) -> Void)? + /// Broadcasts an encoded `fromGateway` carrier payload on the mesh. + var broadcastToMesh: (@MainActor (Data) -> Void)? + /// Sends an encoded `toGateway` carrier payload directed to a gateway + /// peer. Returns false when the transport could not accept it. + var sendToGatewayPeer: (@MainActor (Data, PeerID) -> Bool)? + /// Reachable mesh peers currently advertising the `.gateway` capability. + var availableGatewayPeers: (@MainActor () -> [PeerID])? + /// Whether any Nostr relay connection is currently working. + var relaysConnected: (@MainActor () -> Bool)? + /// The geohash channel the local user is viewing, if any. + var currentGeohash: (@MainActor () -> String?)? + /// Injects a verified carried event into the same inbound pipeline as + /// relay-received events (blocking, rate limits, dedup, rendering). + var injectInbound: (@MainActor (NostrEvent) -> Void)? + /// Fired on toggle changes (advertise/withdraw the capability bit and + /// force a re-announce). + var onEnabledChanged: (@MainActor (Bool) -> Void)? + + // MARK: State + + /// Loop rule 1: event IDs seen in `fromGateway` mesh broadcasts. + private var meshBroadcastEventIDs: BoundedIDSet + /// Loop rule 2 (uplink): event IDs this gateway already published. + private var publishedEventIDs: BoundedIDSet + /// Loop rule 2 (downlink): event IDs this gateway already rebroadcast. + private var rebroadcastEventIDs: BoundedIDSet + + private(set) var queuedUplinks: [QueuedUplink] = [] + private var uplinkDepositTimes: [PeerID: [Date]] = [:] + private var downlinkSendTimes: [Date] = [] + private var pendingDownlinks: [(event: NostrEvent, geohash: String)] = [] + + private let defaults: UserDefaults + private let now: () -> Date + private static let enabledKey = "gateway.userEnabled" + + init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) { + self.defaults = defaults + self.now = now + self.isEnabled = defaults.bool(forKey: Self.enabledKey) + self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + } + + // MARK: - Toggle + + func setEnabled(_ enabled: Bool) { + guard enabled != isEnabled else { return } + isEnabled = enabled + defaults.set(enabled, forKey: Self.enabledKey) + if !enabled { + queuedUplinks.removeAll() + pendingDownlinks.removeAll() + uplinkDepositTimes.removeAll() + } + SecureLogger.info("🌐 Gateway mode \(enabled ? "enabled" : "disabled")", category: .session) + onEnabledChanged?(enabled) + } + + // MARK: - Mesh carrier ingress (both roles) + + /// Entry point for received `nostrCarrier` packets. `directedToUs` is + /// true for packets addressed to this device (uplink deposits); false + /// for broadcasts (downlink rebroadcasts from a gateway). + func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) { + guard let carrier = NostrCarrierPacket.decode(payload) else { + SecureLogger.debug("🌐 Gateway: dropping undecodable carrier from \(peerID.id.prefix(8))…", category: .session) + return + } + switch carrier.direction { + case .toGateway: + // Uplink deposits are directed; a broadcast toGateway is malformed. + guard directedToUs else { return } + handleUplinkDeposit(carrier, from: peerID) + case .fromGateway: + // Downlink rides broadcast only; a directed fromGateway is malformed. + guard !directedToUs else { return } + handleDownlinkBroadcast(carrier) + } + } + + // MARK: - Uplink (gateway role: mesh peer -> internet) + + private func handleUplinkDeposit(_ carrier: NostrCarrierPacket, from depositor: PeerID) { + guard isEnabled else { return } + guard let event = validatedEvent(from: carrier) else { + SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security) + return + } + // Loop rule 1: events learned from a fromGateway broadcast are + // mesh-carried; a gateway must never re-publish them. + guard !meshBroadcastEventIDs.contains(event.id) else { return } + // Loop rule 2: repeat deposits of an already handled event are absorbed. + guard !publishedEventIDs.contains(event.id), + !queuedUplinks.contains(where: { $0.event.id == event.id }) else { + return + } + guard allowUplinkDeposit(from: depositor) else { + SecureLogger.debug("🌐 Gateway: rate-limited uplink deposit from \(depositor.id.prefix(8))…", category: .session) + return + } + + if relaysConnected?() ?? false { + publish(event, geohash: carrier.geohash) + } else { + enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now())) + } + + // Show the carried message on our own timeline when we're viewing + // that geohash; the relay echo (if any) dedups in the pipeline. + if currentGeohash?() == carrier.geohash { + injectInbound?(event) + } + } + + /// Publish everything queued while relays were unreachable. Called when + /// relay connectivity comes back. + func flushQueuedUplinks() { + guard isEnabled, relaysConnected?() ?? false, !queuedUplinks.isEmpty else { return } + let queued = queuedUplinks + queuedUplinks.removeAll() + for item in queued where !publishedEventIDs.contains(item.event.id) { + publish(item.event, geohash: item.geohash) + } + } + + private func publish(_ event: NostrEvent, geohash: String) { + publishedEventIDs.insert(event.id) + publishToRelays?(event, geohash) + SecureLogger.info("🌐 Gateway: published carried event \(event.id.prefix(8))… to relays for #\(geohash)", category: .session) + } + + private func enqueueUplink(_ item: QueuedUplink) { + let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count + guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else { + SecureLogger.debug("🌐 Gateway: uplink queue quota reached for \(item.depositor.id.prefix(8))…", category: .session) + return + } + if queuedUplinks.count >= Limits.maxQueuedUplinks { + queuedUplinks.removeFirst(queuedUplinks.count - Limits.maxQueuedUplinks + 1) + } + queuedUplinks.append(item) + } + + private func allowUplinkDeposit(from depositor: PeerID) -> Bool { + let cutoff = now().addingTimeInterval(-60) + var times = uplinkDepositTimes[depositor, default: []] + times.removeAll { $0 < cutoff } + guard times.count < Limits.uplinkEventsPerMinutePerDepositor else { + uplinkDepositTimes[depositor] = times + return false + } + times.append(now()) + uplinkDepositTimes[depositor] = times + // Bound the tracker itself against a churn of spoofed depositors. + if uplinkDepositTimes.count > Limits.maxTrackedEventIDs { + uplinkDepositTimes = uplinkDepositTimes.filter { !$0.value.isEmpty && $0.value.contains { $0 >= cutoff } } + } + return true + } + + // MARK: - Downlink (gateway role: internet -> mesh) + + /// Called for every event the gateway's own geohash-channel subscription + /// delivers. Wraps it in a `fromGateway` carrier and broadcasts it on + /// the mesh, within the airtime budget. + func rebroadcastRelayEvent(_ event: NostrEvent, geohash: String) { + guard isEnabled, broadcastToMesh != nil else { return } + guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } + // Loop rule 1: never rebroadcast mesh-carried events back onto the + // mesh. Loop rule 2: rebroadcast each relay event at most once. + guard !meshBroadcastEventIDs.contains(event.id), + !rebroadcastEventIDs.contains(event.id) else { + return + } + // Verify before spending BLE airtime; receivers verify again. + guard event.isValidSignature() else { return } + rebroadcastEventIDs.insert(event.id) + + pendingDownlinks.append((event, geohash)) + if pendingDownlinks.count > Limits.maxPendingDownlinks { + // Bandwidth guard: drop-oldest — fresher chat is worth more. + pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks) + } + drainPendingDownlinks() + } + + private func drainPendingDownlinks() { + let cutoff = now().addingTimeInterval(-60) + downlinkSendTimes.removeAll { $0 < cutoff } + while !pendingDownlinks.isEmpty, + downlinkSendTimes.count < Limits.downlinkEventsPerMinute { + let (event, geohash) = pendingDownlinks.removeFirst() + guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event), + let payload = carrier.encode() else { continue } + broadcastToMesh?(payload) + downlinkSendTimes.append(now()) + } + } + + // MARK: - Downlink (receiver role: carried event arrives over mesh) + + private func handleDownlinkBroadcast(_ carrier: NostrCarrierPacket) { + guard let event = validatedEvent(from: carrier) else { return } + // Mark only AFTER signature verification, so a forged copy carrying a + // real event's ID cannot poison the never-republish set, and use the + // marking as dedup: the same broadcast relayed along several mesh + // paths injects once (the pipeline's Nostr event-ID cache additionally + // dedups against our own relay subscription). + guard meshBroadcastEventIDs.insert(event.id) else { return } + // Only inject events for the channel we're viewing; the inbound + // pipeline files public messages under the current geohash. + guard currentGeohash?() == carrier.geohash else { return } + injectInbound?(event) + } + + // MARK: - Uplink (sender role: mesh-only peer with no relays) + + /// Hands a locally signed event to a mesh gateway peer when we have no + /// working relay connection. Returns true when the event was sent. + /// + /// v1 is deliberately fire-and-forget: no gateway ack. The event also + /// stays in `NostrRelayManager`'s own pending queue, so if our internet + /// comes back the relays dedup the duplicate publish by event ID. + /// + /// Loop rule 3: call sites only pass freshly composed events (see + /// `GeohashSubscriptionManager.sendGeohash`); received carrier events + /// never reach this path, and the mesh-carried guard below backstops it. + func uplinkViaMesh(event: NostrEvent, geohash: String) -> Bool { + if relaysConnected?() ?? true { return false } + guard !meshBroadcastEventIDs.contains(event.id), + !publishedEventIDs.contains(event.id) else { + return false + } + // A single gateway is enough — relays fan out from there, and BLE + // airtime is precious. + guard let gateway = availableGatewayPeers?().first else { return false } + guard let carrier = NostrCarrierPacket(direction: .toGateway, geohash: geohash, event: event), + let payload = carrier.encode() else { + return false + } + guard sendToGatewayPeer?(payload, gateway) ?? false else { return false } + SecureLogger.info("🌐 Gateway: uplinked event \(event.id.prefix(8))… for #\(geohash) via mesh gateway \(gateway.id.prefix(8))…", category: .session) + return true + } + + // MARK: - Validation + + /// Structural and cryptographic checks every carried event must pass + /// before a gateway publishes it or a receiver displays it. Ordered + /// cheap-first; Schnorr verification runs last. + private func validatedEvent(from carrier: NostrCarrierPacket) -> NostrEvent? { + guard carrier.eventJSON.count <= NostrCarrierPacket.maxEventJSONBytes, + Self.isValidGeohash(carrier.geohash), + let event = carrier.event(), + event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue, + event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == carrier.geohash }), + abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds, + event.isValidSignature() else { + return nil + } + return event + } + + static func isValidGeohash(_ geohash: String) -> Bool { + let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") + return (1...NostrCarrierPacket.maxGeohashLength).contains(geohash.count) + && geohash.allSatisfy { allowed.contains($0) } + } +} + +/// Insertion-ordered string set with a fixed capacity; the oldest entry is +/// evicted when full. +private struct BoundedIDSet { + private var members: Set = [] + private var order: [String] = [] + let capacity: Int + + init(capacity: Int) { + self.capacity = capacity + } + + func contains(_ id: String) -> Bool { + members.contains(id) + } + + /// Returns false when the ID was already present. + @discardableResult + mutating func insert(_ id: String) -> Bool { + guard members.insert(id).inserted else { return false } + order.append(id) + if order.count > capacity { + members.remove(order.removeFirst()) + } + return true + } +} diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index 430485a0..d4a51a6c 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -23,6 +23,10 @@ struct SyncTypeFlags: OptionSet { // Courier envelopes are directed deposits between trusted peers and // must never spread via gossip sync. case .courierEnvelope: return nil + // Gateway carriers are ephemeral live traffic (uplinks are directed, + // downlinks are rate-budgeted rebroadcasts); replaying them via sync + // would waste airtime and extend their lifetime. + case .nostrCarrier: return nil } } diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 35bc0ef4..d1b73f0e 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -72,6 +72,7 @@ final class ChatViewModelBootstrapper { configureNoiseCallbacks() bindTransferProgress() configureGeoChannels() + configureGateway() bindTeleportState() requestNotifications() registerObservers() @@ -244,6 +245,68 @@ private extension ChatViewModelBootstrapper { ) } + /// Wires the gateway-mode policy layer (`GatewayService`) to the mesh + /// transport, the relay manager, and the inbound Nostr pipeline. All + /// dependencies are closures so the service stays unit-testable with + /// fakes. + func configureGateway() { + // Gateway mode bridges BLE mesh <-> Nostr; a mock transport (tests) + // has no carrier packets to bridge. + guard let bleService = viewModel.meshService as? BLEService else { return } + let gateway = GatewayService.shared + + gateway.publishToRelays = { event, geohash in + let relays = GeoRelayDirectory.shared.closestRelays( + toGeohash: geohash, + count: TransportConfig.nostrGeoRelayCount + ) + if relays.isEmpty { + NostrRelayManager.shared.sendEvent(event) + } else { + NostrRelayManager.shared.sendEvent(event, to: relays) + } + } + gateway.broadcastToMesh = { [weak bleService] payload in + bleService?.broadcastNostrCarrier(payload) + } + gateway.sendToGatewayPeer = { [weak bleService] payload, peer in + bleService?.sendNostrCarrier(payload, to: peer) ?? false + } + gateway.availableGatewayPeers = { [weak bleService] in + bleService?.reachableGatewayPeers() ?? [] + } + gateway.relaysConnected = { NostrRelayManager.shared.isConnected } + gateway.currentGeohash = { [weak viewModel] in viewModel?.currentGeohash } + // Carried events enter the same pipeline as relay-received events so + // blocking, rate limits, dedup, and rendering behave identically. + gateway.injectInbound = { [weak viewModel] event in + viewModel?.handleNostrEvent(event) + } + // The capability bit is advertised ONLY while the toggle is on; a + // change forces a re-announce so peers learn promptly. + gateway.onEnabledChanged = { [weak bleService] enabled in + bleService?.setLocalCapability(.gateway, enabled: enabled) + } + bleService.onNostrCarrierPacket = { payload, from, directedToUs in + GatewayService.shared.handleMeshCarrier(payload, from: from, directedToUs: directedToUs) + } + + // Uplinks deposited while relays were unreachable flush on reconnect. + NostrRelayManager.shared.$isConnected + .receive(on: DispatchQueue.main) + .sink { connected in + if connected { + GatewayService.shared.flushQueuedUplinks() + } + } + .store(in: &viewModel.cancellables) + + // Apply the persisted toggle at launch. + if gateway.isEnabled { + bleService.setLocalCapability(.gateway, enabled: true) + } + } + func bindTeleportState() { viewModel.locationManager.$teleported .receive(on: DispatchQueue.main) diff --git a/bitchat/ViewModels/GeohashSubscriptionManager.swift b/bitchat/ViewModels/GeohashSubscriptionManager.swift index 4e267c59..ce4c9448 100644 --- a/bitchat/ViewModels/GeohashSubscriptionManager.swift +++ b/bitchat/ViewModels/GeohashSubscriptionManager.swift @@ -115,6 +115,9 @@ final class GeohashSubscriptionManager { private weak var context: (any GeohashSubscriptionContext)? private let inbound: NostrInboundPipeline private let presence: GeoPresenceTracker + /// Geohashes already told "sent via mesh gateway" this session, so the + /// notice appears once per channel instead of once per message. + private var gatewayNoticeGeohashes = Set() init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) { self.context = context @@ -145,6 +148,9 @@ final class GeohashSubscriptionManager { NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in Task { @MainActor [weak self] in self?.inbound.subscribeNostrEvent(event) + // Gateway downlink: rebroadcast relay events for the viewed + // channel onto the mesh (no-op unless gateway mode is on). + GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash) } } @@ -235,6 +241,9 @@ final class GeohashSubscriptionManager { NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in Task { @MainActor [weak self] in self?.inbound.handleNostrEvent(event) + // Gateway downlink: rebroadcast relay events for the viewed + // channel onto the mesh (no-op unless gateway mode is on). + GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash) } } @@ -280,6 +289,23 @@ final class GeohashSubscriptionManager { NostrRelayManager.shared.sendEvent(event, to: targetRelays) } + // Mesh gateway uplink: with no working relay connection, hand the + // locally signed event to a mesh peer advertising the gateway + // capability (keys never leave this device — only the finished, + // signed event travels). Uplink is only ever attempted here, for a + // freshly composed event, never for received carrier events (loop + // rule 3 in GatewayService). + if GatewayService.shared.uplinkViaMesh(event: event, geohash: channel.geohash), + gatewayNoticeGeohashes.insert(channel.geohash).inserted { + context.addPublicSystemMessage( + String( + localized: "system.gateway.sent_via_mesh", + defaultValue: "sent via mesh gateway", + comment: "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable" + ) + ) + } + context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex)) SecureLogger.debug( diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index eb188ed7..08403030 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -91,6 +91,19 @@ struct ContentHeaderView: View { }() HStack(spacing: 2) { + if locationChannelsModel.gatewayEnabled { + Image(systemName: "globe") + .font(.bitchatSystem(size: 12)) + .foregroundColor(palette.secondary.opacity(0.8)) + .headerTapTarget() + .accessibilityLabel( + String(localized: "content.accessibility.gateway_active", defaultValue: "Internet gateway active, sharing your connection with the mesh", comment: "Accessibility label for the internet gateway indicator") + ) + .help( + String(localized: "content.header.gateway_active", defaultValue: "Sharing your internet connection with nearby mesh peers", comment: "Tooltip for the internet gateway indicator") + ) + } + if carriedMailCount > 0 { Image(systemName: "figure.walk") .font(.bitchatSystem(size: 12)) diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 77b45a21..0a6e077a 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -27,6 +27,8 @@ struct LocationChannelsSheet: View { static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access" static let torTitle: LocalizedStringKey = "location_channels.tor.title" static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle" + static let gatewayTitle: LocalizedStringKey = "location_channels.gateway.title" + static let gatewaySubtitle: LocalizedStringKey = "location_channels.gateway.subtitle" static let toggleOn: LocalizedStringKey = "common.toggle.on" static let toggleOff: LocalizedStringKey = "common.toggle.off" @@ -244,6 +246,8 @@ struct LocationChannelsSheet: View { sectionDivider torToggleSection .padding(.top, 12) + gatewayToggleSection + .padding(.top, 8) Button(action: SystemSettings.location.open) { Text(Strings.removeAccess) .bitchatFont(size: 12) @@ -508,6 +512,32 @@ extension LocationChannelsSheet { .cornerRadius(8) } + private var gatewayToggleBinding: Binding { + Binding( + get: { locationChannelsModel.gatewayEnabled }, + set: { locationChannelsModel.setGatewayEnabled($0) } + ) + } + + private var gatewayToggleSection: some View { + VStack(alignment: .leading, spacing: 8) { + Toggle(isOn: gatewayToggleBinding) { + VStack(alignment: .leading, spacing: 2) { + Text(Strings.gatewayTitle) + .bitchatFont(size: 12, weight: .semibold) + .foregroundColor(palette.primary) + Text(Strings.gatewaySubtitle) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary) + } + } + .toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff)) + } + .padding(12) + .background(palette.secondary.opacity(0.12)) + .cornerRadius(8) + } + private var standardGreen: Color { palette.primary } private var standardBlue: Color { palette.accentBlue } } diff --git a/bitchatTests/Protocols/NostrCarrierPacketTests.swift b/bitchatTests/Protocols/NostrCarrierPacketTests.swift new file mode 100644 index 00000000..50ffb287 --- /dev/null +++ b/bitchatTests/Protocols/NostrCarrierPacketTests.swift @@ -0,0 +1,96 @@ +// +// NostrCarrierPacketTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat + +@Suite("Nostr carrier packet TLV") +struct NostrCarrierPacketTests { + private func makeEvent(geohash: String = "u4pruy", content: String = "hello mesh") throws -> NostrEvent { + let identity = try NostrIdentity.generate() + return try NostrProtocol.createEphemeralGeohashEvent( + content: content, + geohash: geohash, + senderIdentity: identity, + nickname: "tester" + ) + } + + @Test("round-trips both directions with the signed event intact") + func roundTrip() throws { + let event = try makeEvent() + for direction in [NostrCarrierPacket.Direction.toGateway, .fromGateway] { + let packet = try #require(NostrCarrierPacket(direction: direction, geohash: "u4pruy", event: event)) + let encoded = try #require(packet.encode()) + let decoded = try #require(NostrCarrierPacket.decode(encoded)) + + #expect(decoded == packet) + #expect(decoded.direction == direction) + #expect(decoded.geohash == "u4pruy") + + // The carried event survives byte-exact: same ID, and the + // signature still verifies after the mesh hop. + let carried = try #require(decoded.event()) + #expect(carried.id == event.id) + #expect(carried.sig == event.sig) + #expect(carried.isValidSignature()) + } + } + + @Test("rejects an oversized event at construction and at decode") + func oversizedRejected() throws { + let oversized = Data(repeating: 0x7B, count: NostrCarrierPacket.maxEventJSONBytes + 1) + #expect(NostrCarrierPacket(direction: .toGateway, geohash: "u4pruy", eventJSON: oversized) == nil) + + // Hand-build the TLV bytes to bypass the initializer's cap. + var data = Data([0x01, 0x00, 0x01, NostrCarrierPacket.Direction.toGateway.rawValue]) + let geohash = Data("u4pruy".utf8) + data.append(contentsOf: [0x02, 0x00, UInt8(geohash.count)]) + data.append(geohash) + data.append(contentsOf: [0x03, UInt8((oversized.count >> 8) & 0xFF), UInt8(oversized.count & 0xFF)]) + data.append(oversized) + #expect(NostrCarrierPacket.decode(data) == nil) + } + + @Test("rejects an over-length or empty geohash") + func geohashBoundsEnforced() throws { + let event = try makeEvent() + #expect(NostrCarrierPacket(direction: .toGateway, geohash: "", event: event) == nil) + #expect(NostrCarrierPacket(direction: .toGateway, geohash: String(repeating: "u", count: 13), event: event) == nil) + #expect(NostrCarrierPacket(direction: .toGateway, geohash: String(repeating: "u", count: 12), event: event) != nil) + } + + @Test("skips unknown TLVs for forward compatibility") + func unknownTLVSkipped() throws { + let event = try makeEvent() + let packet = try #require(NostrCarrierPacket(direction: .fromGateway, geohash: "u4pruy", event: event)) + var encoded = try #require(packet.encode()) + // Append an unknown TLV (type 0x7F, 2-byte value). + encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD]) + let decoded = try #require(NostrCarrierPacket.decode(encoded)) + #expect(decoded == packet) + } + + @Test("rejects truncated and missing-field payloads") + func malformedRejected() throws { + let event = try makeEvent() + let packet = try #require(NostrCarrierPacket(direction: .toGateway, geohash: "u4pruy", event: event)) + let encoded = try #require(packet.encode()) + + // Truncation anywhere inside the last TLV fails cleanly. + #expect(NostrCarrierPacket.decode(encoded.dropLast(1)) == nil) + #expect(NostrCarrierPacket.decode(encoded.prefix(4)) == nil) + #expect(NostrCarrierPacket.decode(Data()) == nil) + + // Direction TLV alone (missing geohash and event) fails. + #expect(NostrCarrierPacket.decode(Data([0x01, 0x00, 0x01, 0x01])) == nil) + // Unknown direction value fails. + #expect(NostrCarrierPacket.decode(Data([0x01, 0x00, 0x01, 0x77])) == nil) + } +} diff --git a/bitchatTests/Services/GatewayServiceTests.swift b/bitchatTests/Services/GatewayServiceTests.swift new file mode 100644 index 00000000..0372c410 --- /dev/null +++ b/bitchatTests/Services/GatewayServiceTests.swift @@ -0,0 +1,453 @@ +// +// GatewayServiceTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("Gateway mode policy") +@MainActor +struct GatewayServiceTests { + private static let geohash = "u4pruy" + + /// Closure-injected harness around `GatewayService` recording every + /// side effect, with a controllable clock and relay connectivity. + @MainActor + private final class Fixture { + private final class ClockBox { + var now = Date() + } + + var relaysConnected = true + var currentGeohash: String? = GatewayServiceTests.geohash + var gatewayPeers: [PeerID] = [] + var sendToGatewaySucceeds = true + + private(set) var published: [(event: NostrEvent, geohash: String)] = [] + private(set) var broadcasts: [Data] = [] + private(set) var injected: [NostrEvent] = [] + private(set) var uplinkSends: [(payload: Data, peer: PeerID)] = [] + private(set) var enabledChanges: [Bool] = [] + + private let clock = ClockBox() + let defaults: UserDefaults + let service: GatewayService + + init(enabled: Bool = true, suite: String = "GatewayServiceTests-\(UUID().uuidString)") { + defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let clock = clock + service = GatewayService(defaults: defaults) { clock.now } + service.publishToRelays = { [weak self] event, geohash in + self?.published.append((event, geohash)) + } + service.broadcastToMesh = { [weak self] payload in + self?.broadcasts.append(payload) + } + service.sendToGatewayPeer = { [weak self] payload, peer in + guard let self, self.sendToGatewaySucceeds else { return false } + self.uplinkSends.append((payload, peer)) + return true + } + service.availableGatewayPeers = { [weak self] in self?.gatewayPeers ?? [] } + service.relaysConnected = { [weak self] in self?.relaysConnected ?? false } + service.currentGeohash = { [weak self] in self?.currentGeohash } + service.injectInbound = { [weak self] event in self?.injected.append(event) } + service.onEnabledChanged = { [weak self] enabled in self?.enabledChanges.append(enabled) } + if enabled { + service.setEnabled(true) + } + } + + func advance(_ seconds: TimeInterval) { + clock.now = clock.now.addingTimeInterval(seconds) + } + } + + // MARK: Event helpers + + private func makeEvent( + geohash: String = GatewayServiceTests.geohash, + content: String = "hello \(UUID().uuidString.prefix(8))" + ) throws -> NostrEvent { + let identity = try NostrIdentity.generate() + return try NostrProtocol.createEphemeralGeohashEvent( + content: content, + geohash: geohash, + senderIdentity: identity, + nickname: "tester" + ) + } + + /// A copy of `event` with tampered content but the original ID and + /// signature — what a forging gateway or mesh peer would produce. + private func forge(_ event: NostrEvent) throws -> NostrEvent { + let dict: [String: Any] = [ + "id": event.id, + "pubkey": event.pubkey, + "created_at": event.created_at, + "kind": event.kind, + "tags": event.tags, + "content": event.content + " (tampered)", + "sig": event.sig ?? "" + ] + return try NostrEvent(from: dict) + } + + private func carrierPayload( + _ event: NostrEvent, + direction: NostrCarrierPacket.Direction = .toGateway, + geohash: String = GatewayServiceTests.geohash + ) throws -> Data { + let packet = try #require(NostrCarrierPacket(direction: direction, geohash: geohash, event: event)) + return try #require(packet.encode()) + } + + private func deposit( + _ event: NostrEvent, + into fixture: Fixture, + from depositor: PeerID = PeerID(str: "1122334455667788"), + geohash: String = GatewayServiceTests.geohash + ) throws { + let payload = try carrierPayload(event, direction: .toGateway, geohash: geohash) + fixture.service.handleMeshCarrier(payload, from: depositor, directedToUs: true) + } + + // MARK: - Uplink verification gates + + @Test("publishes a verified deposit to the geo relays") + func verifiedDepositPublished() throws { + let fixture = Fixture() + let event = try makeEvent() + try deposit(event, into: fixture) + + #expect(fixture.published.count == 1) + #expect(fixture.published.first?.event.id == event.id) + #expect(fixture.published.first?.geohash == Self.geohash) + // Viewing the same geohash: the carried message shows on our own timeline. + #expect(fixture.injected.map(\.id) == [event.id]) + } + + @Test("rejects a forged signature") + func forgedSignatureRejected() throws { + let fixture = Fixture() + let forged = try forge(try makeEvent()) + try deposit(forged, into: fixture) + + #expect(fixture.published.isEmpty) + #expect(fixture.injected.isEmpty) + } + + @Test("rejects wrong kind, geohash mismatch, and stale events") + func structuralGates() throws { + let fixture = Fixture() + + // Wrong kind (kind-1 text note instead of kind-20000 ephemeral). + let identity = try NostrIdentity.generate() + let note = try NostrProtocol.createGeohashTextNote( + content: "note", + geohash: Self.geohash, + senderIdentity: identity + ) + try deposit(note, into: fixture) + #expect(fixture.published.isEmpty) + + // Carrier geohash disagreeing with the event's #g tag. + let mismatched = try makeEvent(geohash: "9q8yyk") + try deposit(mismatched, into: fixture, geohash: Self.geohash) + #expect(fixture.published.isEmpty) + + // Stale event (beyond accepted clock skew). + let stale = try makeEvent() + fixture.advance(GatewayService.Limits.maxEventAgeSeconds + 60) + try deposit(stale, into: fixture) + #expect(fixture.published.isEmpty) + } + + @Test("does nothing while the toggle is off") + func disabledGatewayIgnoresDeposits() throws { + let fixture = Fixture(enabled: false) + try deposit(try makeEvent(), into: fixture) + #expect(fixture.published.isEmpty) + #expect(fixture.service.queuedUplinks.isEmpty) + + fixture.service.rebroadcastRelayEvent(try makeEvent(), geohash: Self.geohash) + #expect(fixture.broadcasts.isEmpty) + } + + // MARK: - Uplink quotas and rate limit + + @Test("rate-limits deposits per depositor per minute") + func uplinkRateLimit() throws { + let fixture = Fixture() + let depositor = PeerID(str: "aabbccddeeff0011") + + for _ in 0.. Date: Mon, 6 Jul 2026 21:13:10 +0200 Subject: [PATCH 2/2] Gateway: harden downlink freshness, uplink verify ordering, and drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the confirmed downlink/uplink defects from the PR #1384 review + Codex findings: - Downlink age + #g gate (Codex P2 / review #1): rebroadcastRelayEvent now drops events outside the same freshness window receivers enforce and whose #g tag mismatches the carrier geohash, BEFORE spending any budget — so a 1h/200-event channel-resubscribe backfill no longer burns the 30/min BLE budget on events every receiver drops. - Rate-limit + dedup before Schnorr (review #2): handleUplinkDeposit now runs cheap structural checks + carried-ID dedup + rate-token consume before isValidSignature(), so a replay flood is bounded by cheap work instead of unbounded main-actor verifies. - Quota-dropped deposits not rendered (review #3): enqueueUplink reports acceptance and injectInbound only fires for events actually published/queued, ending the local-timeline divergence. - Drain timer + mark-after-send (Codex P2 / review #4): a burst beyond budget now arms a timer to drain when the window frees; rebroadcast IDs are marked only after an event is actually sent, so overflow- dropped events stay retryable. - Symmetric publish path (review #5): the gateway publish closure now refuses when no geo relay is known, matching the local send path instead of publishing dead traffic to default relays. - Loop-rule doc (review #7): softened to reflect that rule 3 is a call-site convention with unit-tested backstops; added tests for the publishedEventIDs backstop, downlink freshness/mismatch, drain timer, and quota-drop non-injection. Co-Authored-By: Claude Fable 5 --- bitchat/Services/Gateway/GatewayService.swift | 149 ++++++++++++++---- .../ChatViewModelBootstrapper.swift | 12 +- .../Services/GatewayServiceTests.swift | 95 +++++++++++ 3 files changed, 225 insertions(+), 31 deletions(-) diff --git a/bitchat/Services/Gateway/GatewayService.swift b/bitchat/Services/Gateway/GatewayService.swift index 20924589..e325cb39 100644 --- a/bitchat/Services/Gateway/GatewayService.swift +++ b/bitchat/Services/Gateway/GatewayService.swift @@ -30,7 +30,7 @@ import Foundation /// - Carried contents are public geohash chat, already plaintext on Nostr, /// so the mesh carrier adds no confidentiality loss. /// -/// Loop-prevention rules (each enforced here and unit-tested): +/// Loop-prevention rules: /// 1. An event learned from a `fromGateway` mesh broadcast is never /// re-published to relays, never re-uplinked, and never rebroadcast /// (`meshBroadcastEventIDs`), so a second gateway on the same mesh cannot @@ -41,7 +41,10 @@ import Foundation /// repeat deposits and relay echoes are absorbed. /// 3. Uplink is only attempted for locally composed events at the send site /// (`GeohashSubscriptionManager.sendGeohash`); events received over the -/// carrier never re-enter the uplink path. +/// carrier never re-enter the uplink path. This is a call-site convention; +/// the `meshBroadcastEventIDs`/`publishedEventIDs` backstops in +/// `uplinkViaMesh` enforce it defensively and are unit-tested. +/// Rules 1 and 2 are enforced here and unit-tested. /// Rebroadcast storms at the mesh layer are additionally bounded by the BLE /// `MessageDeduplicator` and packet TTL, and receivers dedup carried events /// against their own relay subscriptions via the Nostr event-ID cache in @@ -59,9 +62,9 @@ final class GatewayService: ObservableObject { /// Uplink deposits accepted per depositor per minute. static let uplinkEventsPerMinutePerDepositor = 10 /// Downlink mesh rebroadcasts per minute — BLE airtime is precious. - /// Beyond the budget events queue (bounded, drop-oldest) and drain as - /// the window frees; a busy channel triggers frequent drains, a quiet - /// one never queues. + /// Beyond the budget events queue (bounded, drop-oldest) and drain on + /// a scheduled timer once the window frees (also re-driven by the next + /// inbound relay event); a quiet channel does not strand its backlog. static let downlinkEventsPerMinute = 30 static let maxPendingDownlinks = 30 /// Accepted clock skew for a carried ephemeral event; anything older @@ -105,6 +108,9 @@ final class GatewayService: ObservableObject { /// Fired on toggle changes (advertise/withdraw the capability bit and /// force a re-announce). var onEnabledChanged: (@MainActor (Bool) -> Void)? + /// Schedules a downlink-drain closure to run after a delay. Injected so + /// the drain timer is deterministic in tests; nil arms a real `Task`. + var scheduleDrainTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)? // MARK: State @@ -119,6 +125,8 @@ final class GatewayService: ObservableObject { private var uplinkDepositTimes: [PeerID: [Date]] = [:] private var downlinkSendTimes: [Date] = [] private var pendingDownlinks: [(event: NostrEvent, geohash: String)] = [] + /// True while a drain timer is armed, so a burst schedules at most one. + private var downlinkDrainScheduled = false private let defaults: UserDefaults private let now: () -> Date @@ -174,32 +182,49 @@ final class GatewayService: ObservableObject { private func handleUplinkDeposit(_ carrier: NostrCarrierPacket, from depositor: PeerID) { guard isEnabled else { return } - guard let event = validatedEvent(from: carrier) else { + // Cheap structural checks first (parse, size, geohash, kind, #g tag, + // age) — no crypto — so junk and stale replays are dropped before we + // ever pay for a MainActor Schnorr verify. + guard let event = structurallyValidEvent(from: carrier) else { SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security) return } - // Loop rule 1: events learned from a fromGateway broadcast are - // mesh-carried; a gateway must never re-publish them. - guard !meshBroadcastEventIDs.contains(event.id) else { return } - // Loop rule 2: repeat deposits of an already handled event are absorbed. - guard !publishedEventIDs.contains(event.id), + // Dedup by the carried event ID BEFORE verification. Loop rule 1: a + // fromGateway-learned event is mesh-carried and must never be + // re-published. Loop rule 2: repeat deposits of an already handled + // event are absorbed. A replay of one valid deposit is short-circuited + // here without a per-packet signature verify. + guard !meshBroadcastEventIDs.contains(event.id), + !publishedEventIDs.contains(event.id), !queuedUplinks.contains(where: { $0.event.id == event.id }) else { return } + // Consume the per-depositor rate token BEFORE the expensive verify so + // a flood of distinct forged/junk deposits is bounded by cheap work, + // not by main-actor Schnorr verifications. guard allowUplinkDeposit(from: depositor) else { SecureLogger.debug("🌐 Gateway: rate-limited uplink deposit from \(depositor.id.prefix(8))…", category: .session) return } - - if relaysConnected?() ?? false { - publish(event, geohash: carrier.geohash) - } else { - enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now())) + // Only now pay for cryptographic verification; receivers verify again. + guard event.isValidSignature() else { + SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security) + return } - // Show the carried message on our own timeline when we're viewing - // that geohash; the relay echo (if any) dedups in the pipeline. - if currentGeohash?() == carrier.geohash { + let accepted: Bool + if relaysConnected?() ?? false { + publish(event, geohash: carrier.geohash) + accepted = true + } else { + accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now())) + } + + // Only render on our own timeline what we actually accepted for + // publish or queue: a quota-dropped deposit is never published and, + // being directed, no other peer will ever see it, so showing it would + // diverge our timeline permanently from what reached the channel. + if accepted, currentGeohash?() == carrier.geohash { injectInbound?(event) } } @@ -221,16 +246,19 @@ final class GatewayService: ObservableObject { SecureLogger.info("🌐 Gateway: published carried event \(event.id.prefix(8))… to relays for #\(geohash)", category: .session) } - private func enqueueUplink(_ item: QueuedUplink) { + /// Returns true when the item was actually stored for later publish. + @discardableResult + private func enqueueUplink(_ item: QueuedUplink) -> Bool { let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else { SecureLogger.debug("🌐 Gateway: uplink queue quota reached for \(item.depositor.id.prefix(8))…", category: .session) - return + return false } if queuedUplinks.count >= Limits.maxQueuedUplinks { queuedUplinks.removeFirst(queuedUplinks.count - Limits.maxQueuedUplinks + 1) } queuedUplinks.append(item) + return true } private func allowUplinkDeposit(from depositor: PeerID) -> Bool { @@ -258,19 +286,35 @@ final class GatewayService: ObservableObject { func rebroadcastRelayEvent(_ event: NostrEvent, geohash: String) { guard isEnabled, broadcastToMesh != nil else { return } guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } + // Freshness + geohash gate BEFORE spending any budget. A channel + // (re)subscribe backfills up to an hour of history (limit 200), but + // every receiver's `validatedEvent` drops anything older than the + // same window — so rebroadcasting backfill would burn the whole + // per-minute budget on events no mesh peer accepts. Also require the + // event's own `#g` tag to match the carrier geohash. + guard isFresh(event), + event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == geohash }) else { + return + } // Loop rule 1: never rebroadcast mesh-carried events back onto the - // mesh. Loop rule 2: rebroadcast each relay event at most once. + // mesh. Loop rule 2: rebroadcast each relay event at most once — but + // mark only AFTER it is actually sent (in `drainPendingDownlinks`), so + // an event dropped by the queue overflow stays retryable on relay + // redelivery. Guard against a redelivery re-queueing an event that is + // still waiting to be sent. guard !meshBroadcastEventIDs.contains(event.id), - !rebroadcastEventIDs.contains(event.id) else { + !rebroadcastEventIDs.contains(event.id), + !pendingDownlinks.contains(where: { $0.event.id == event.id }) else { return } // Verify before spending BLE airtime; receivers verify again. guard event.isValidSignature() else { return } - rebroadcastEventIDs.insert(event.id) pendingDownlinks.append((event, geohash)) if pendingDownlinks.count > Limits.maxPendingDownlinks { - // Bandwidth guard: drop-oldest — fresher chat is worth more. + // Bandwidth guard: drop-oldest — fresher chat is worth more. The + // dropped event is not yet in `rebroadcastEventIDs`, so a later + // relay redelivery can still carry it. pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks) } drainPendingDownlinks() @@ -282,11 +326,44 @@ final class GatewayService: ObservableObject { while !pendingDownlinks.isEmpty, downlinkSendTimes.count < Limits.downlinkEventsPerMinute { let (event, geohash) = pendingDownlinks.removeFirst() + // A queued event may have aged past the window while it waited; + // don't burn airtime on what receivers would now drop. + guard isFresh(event) else { continue } guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event), let payload = carrier.encode() else { continue } broadcastToMesh?(payload) + // Mark-after-send: only now is the relay event definitively + // rebroadcast (loop rule 2). + rebroadcastEventIDs.insert(event.id) downlinkSendTimes.append(now()) } + // Budget exhausted with events still queued: arm a timer to drain when + // the window frees, instead of stranding them until the next inbound + // relay event (which may never come on a channel that went quiet). + scheduleDownlinkDrainIfNeeded() + } + + /// Arms a single timer to drain the backlog once the per-minute window + /// frees. No-op when nothing is pending or a drain is already scheduled. + private func scheduleDownlinkDrainIfNeeded() { + guard !pendingDownlinks.isEmpty, !downlinkDrainScheduled else { return } + // The window frees when the oldest recorded send ages out of 60s. + let oldest = downlinkSendTimes.min() ?? now() + let delay = max(0.05, 60 - now().timeIntervalSince(oldest)) + downlinkDrainScheduled = true + let fire: @MainActor () -> Void = { [weak self] in + guard let self else { return } + self.downlinkDrainScheduled = false + self.drainPendingDownlinks() + } + if let scheduleDrainTimer { + scheduleDrainTimer(delay, fire) + } else { + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + fire() + } + } } // MARK: - Downlink (receiver role: carried event arrives over mesh) @@ -341,18 +418,36 @@ final class GatewayService: ObservableObject { /// before a gateway publishes it or a receiver displays it. Ordered /// cheap-first; Schnorr verification runs last. private func validatedEvent(from carrier: NostrCarrierPacket) -> NostrEvent? { + guard let event = structurallyValidEvent(from: carrier), + event.isValidSignature() else { + return nil + } + return event + } + + /// The cheap half of `validatedEvent`: parse + size + geohash + kind + + /// `#g` tag + freshness, with NO signature verification. Callers that can + /// dedup or rate-limit on the carried ID run this first so the expensive + /// Schnorr verify is reached only for events that survive the cheap gates. + private func structurallyValidEvent(from carrier: NostrCarrierPacket) -> NostrEvent? { guard carrier.eventJSON.count <= NostrCarrierPacket.maxEventJSONBytes, Self.isValidGeohash(carrier.geohash), let event = carrier.event(), event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue, event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == carrier.geohash }), - abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds, - event.isValidSignature() else { + isFresh(event) else { return nil } return event } + /// True when `event.created_at` is within the accepted clock skew — the + /// SAME freshness window receivers enforce, so a gateway never spends + /// airtime on events every receiver would drop as stale. + private func isFresh(_ event: NostrEvent) -> Bool { + abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds + } + static func isValidGeohash(_ geohash: String) -> Bool { let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") return (1...NostrCarrierPacket.maxGeohashLength).contains(geohash.count) diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index d1b73f0e..caca90ec 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -260,11 +260,15 @@ private extension ChatViewModelBootstrapper { toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount ) - if relays.isEmpty { - NostrRelayManager.shared.sendEvent(event) - } else { - NostrRelayManager.shared.sendEvent(event, to: relays) + // Symmetric with the local send path (GeohashSubscriptionManager + // .sendGeohash): with no known geo relay, refuse rather than + // publish to default relays no geo subscriber reads — that would + // be silent dead traffic, not delivery. + guard !relays.isEmpty else { + SecureLogger.warning("🌐 Gateway: no geo relays for #\(geohash); not publishing carried event", category: .session) + return } + NostrRelayManager.shared.sendEvent(event, to: relays) } gateway.broadcastToMesh = { [weak bleService] payload in bleService?.broadcastNostrCarrier(payload) diff --git a/bitchatTests/Services/GatewayServiceTests.swift b/bitchatTests/Services/GatewayServiceTests.swift index 0372c410..8cecc6ec 100644 --- a/bitchatTests/Services/GatewayServiceTests.swift +++ b/bitchatTests/Services/GatewayServiceTests.swift @@ -34,6 +34,7 @@ struct GatewayServiceTests { private(set) var injected: [NostrEvent] = [] private(set) var uplinkSends: [(payload: Data, peer: PeerID)] = [] private(set) var enabledChanges: [Bool] = [] + private(set) var scheduledDrains: [(delay: TimeInterval, work: @MainActor () -> Void)] = [] private let clock = ClockBox() let defaults: UserDefaults @@ -60,6 +61,11 @@ struct GatewayServiceTests { service.currentGeohash = { [weak self] in self?.currentGeohash } service.injectInbound = { [weak self] event in self?.injected.append(event) } service.onEnabledChanged = { [weak self] enabled in self?.enabledChanges.append(enabled) } + // Capture drain timers instead of arming a real Task so the drain + // is deterministic under the fake clock. + service.scheduleDrainTimer = { [weak self] delay, work in + self?.scheduledDrains.append((delay, work)) + } if enabled { service.setEnabled(true) } @@ -68,6 +74,14 @@ struct GatewayServiceTests { func advance(_ seconds: TimeInterval) { clock.now = clock.now.addingTimeInterval(seconds) } + + /// Fires every currently-scheduled drain timer (simulating the window + /// freeing), as the real Task would after its delay. + func fireScheduledDrains() { + let due = scheduledDrains + scheduledDrains.removeAll() + for item in due { item.work() } + } } // MARK: Event helpers @@ -258,6 +272,27 @@ struct GatewayServiceTests { #expect(fixture.published.count == 1) } + @Test("a quota-dropped deposit is not rendered on the local timeline") + func quotaDroppedDepositNotInjected() throws { + let fixture = Fixture() + fixture.relaysConnected = false + let depositor = PeerID(str: "1111111111111111") + + // Fill this depositor's offline queue to its per-depositor cap; each + // accepted deposit is rendered locally (we view that geohash). + for _ in 0..