Gateway mode: opt-in mesh↔Nostr uplink for geohash channels (#1384)

* Add capability bits to announce TLV

Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".

PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Gateway mode: opt-in mesh↔Nostr uplink for geohash channels

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 <noreply@anthropic.com>

* Gateway: harden downlink freshness, uplink verify ordering, and drain

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 <noreply@anthropic.com>

* Gateway: stop self-echo of uplinked events onto the mesh

Every event a gateway uplinks to the relays comes back through its own
geohash subscription. `rebroadcastRelayEvent` deduped against
`meshBroadcastEventIDs`, `rebroadcastEventIDs`, and `pendingDownlinks`,
but not `publishedEventIDs` — so an event this gateway just published
was downlink-rebroadcast onto the same mesh it originated from, doubling
BLE airtime per uplinked message and able to starve the 30/min downlink
budget on a busy channel (device-confirmed, filed on #1384).

Fix: also skip the downlink rebroadcast when the event id is in
`publishedEventIDs`. That set is already the bounded (drop-oldest,
capacity maxTrackedEventIDs) loop-rule-2 uplink cache, populated only by
`publish()`, so genuine inbound-from-internet events (never published
here) still rebroadcast normally. Reconciles cleanly with the existing
loop-prevention sets — no new state.

Adds a GatewayServiceTests case asserting an uplinked event that echoes
back via the subscription is not rebroadcast, while a genuine inbound
event still is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-07 14:55:17 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent 2360140760
commit d4f0c49787
16 changed files with 1644 additions and 7 deletions
+15 -1
View File
@@ -12,20 +12,26 @@ final class LocationChannelsModel: ObservableObject {
@Published private(set) var bookmarkNames: [String: String] @Published private(set) var bookmarkNames: [String: String]
@Published private(set) var locationNames: [GeohashChannelLevel: String] @Published private(set) var locationNames: [GeohashChannelLevel: String]
@Published private(set) var userTorEnabled: Bool @Published private(set) var userTorEnabled: Bool
@Published private(set) var gatewayEnabled: Bool
private let manager: LocationChannelManager private let manager: LocationChannelManager
private let network: NetworkActivationService private let network: NetworkActivationService
private let gateway: GatewayService
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init( init(
manager: LocationChannelManager? = nil, manager: LocationChannelManager? = nil,
network: NetworkActivationService? = nil network: NetworkActivationService? = nil,
gateway: GatewayService? = nil
) { ) {
let manager = manager ?? .shared let manager = manager ?? .shared
let network = network ?? .shared let network = network ?? .shared
let gateway = gateway ?? .shared
self.manager = manager self.manager = manager
self.network = network self.network = network
self.gateway = gateway
self.gatewayEnabled = gateway.isEnabled
self.permissionState = manager.permissionState self.permissionState = manager.permissionState
self.availableChannels = manager.availableChannels self.availableChannels = manager.availableChannels
self.selectedChannel = manager.selectedChannel self.selectedChannel = manager.selectedChannel
@@ -96,6 +102,10 @@ final class LocationChannelsModel: ObservableObject {
network.setUserTorEnabled(enabled) network.setUserTorEnabled(enabled)
} }
func setGatewayEnabled(_ enabled: Bool) {
gateway.setEnabled(enabled)
}
func refreshMeshChannelsIfNeeded() { func refreshMeshChannelsIfNeeded() {
guard case .mesh = selectedChannel, guard case .mesh = selectedChannel,
permissionState == .authorized, permissionState == .authorized,
@@ -160,6 +170,10 @@ final class LocationChannelsModel: ObservableObject {
network.$userTorEnabled network.$userTorEnabled
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.assign(to: &$userTorEnabled) .assign(to: &$userTorEnabled)
gateway.$isEnabled
.receive(on: DispatchQueue.main)
.assign(to: &$gatewayEnabled)
} }
private func level(forLength length: Int) -> GeohashChannelLevel { private func level(forLength length: Int) -> GeohashChannelLevel {
+60
View File
@@ -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" : { "content.accessibility.jump_to_latest" : {
"comment" : "Accessibility label for the jump to latest messages button", "comment" : "Accessibility label for the jump to latest messages button",
"extractionState" : "manual", "extractionState" : "manual",
@@ -17884,6 +17896,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" : { "content.header.people" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
@@ -25819,6 +25843,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" : { "location_channels.loading_nearby" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
@@ -33665,6 +33713,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" : { "system.geohash.blocked" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
+136
View File
@@ -0,0 +1,136 @@
//
// NostrCarrierPacket.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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..<offset + length)
offset += length
switch TLVType(rawValue: typeRaw) {
case .direction:
guard value.count == 1, let parsed = Direction(rawValue: value[0]) else { return nil }
direction = parsed
case .geohash:
guard let parsed = String(data: value, encoding: .utf8) else { return nil }
geohash = parsed
case .eventJSON:
eventJSON = value
case nil:
// Unknown TLV; skip (tolerant decoder for forward compatibility).
continue
}
}
guard offset == data.count,
let direction,
let geohash,
let eventJSON else {
return nil
}
return NostrCarrierPacket(direction: direction, geohash: geohash, eventJSON: eventJSON)
}
}
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
switch MessageType(rawValue: packetType) { switch MessageType(rawValue: packetType) {
case .noiseEncrypted, .noiseHandshake: case .noiseEncrypted, .noiseHandshake:
return true return true
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost: case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .nostrCarrier:
return false return false
} }
} }
@@ -112,6 +112,11 @@ struct BLEPeerRegistry {
peers[peerID.toShort()]?.capabilities ?? [] peers[peerID.toShort()]?.capabilities ?? []
} }
/// Peers whose last verified announce advertised the given capability.
func peers(advertising capability: PeerCapabilities) -> [PeerID] {
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
}
func displayNicknames(selfNickname: String) -> [PeerID: String] { func displayNicknames(selfNickname: String) -> [PeerID: String] {
let connected = peers.filter { $0.value.isConnected } let connected = peers.filter { $0.value.isConnected }
let tuples = connected.map { ($0.key, $0.value.nickname, true) } let tuples = connected.map { ($0.key, $0.value.nickname, true) }
@@ -51,8 +51,11 @@ struct BLEReceivePipeline {
// Courier envelopes are directed opaque ciphertext like DMs; a // Courier envelopes are directed opaque ciphertext like DMs; a
// remote handover toward a relayed announce rides this same // remote handover toward a relayed announce rides this same
// deterministic relay treatment instead of the broadcast clamp. // 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 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, isFragment: packet.type == MessageType.fragment.rawValue,
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
isHandshake: packet.type == MessageType.noiseHandshake.rawValue, isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
+115 -4
View File
@@ -62,6 +62,14 @@ final class BLEService: NSObject {
// Local-only store-and-forward counters; nil in unit tests. // Local-only store-and-forward counters; nil in unit tests.
var sfMetrics: StoreAndForwardMetrics? 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 #if DEBUG
// Test-only tap on the outbound pipeline so multi-node tests can ferry // Test-only tap on the outbound pipeline so multi-node tests can ferry
// packets between in-process service instances. // packets between in-process service instances.
@@ -638,6 +646,32 @@ final class BLEService: NSObject {
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) } 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] { func getPeerNicknames() -> [PeerID: String] {
return collectionsQueue.sync { return collectionsQueue.sync {
peerRegistry.displayNicknames(selfNickname: myNickname) peerRegistry.displayNicknames(selfNickname: myNickname)
@@ -1272,16 +1306,16 @@ final class BLEService: NSObject {
let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification
let signingPub = noiseService.getSigningPublicKeyData() // For signature verification let signingPub = noiseService.getSigningPublicKeyData() // For signature verification
let connectedPeerIDs: [Data] = collectionsQueue.sync { let (connectedPeerIDs, advertisedCapabilities): ([Data], PeerCapabilities) = collectionsQueue.sync {
peerRegistry.connectedRoutingData (peerRegistry.connectedRoutingData, PeerCapabilities.localSupported.union(runtimeCapabilities))
} }
let announcement = AnnouncementPacket( let announcement = AnnouncementPacket(
nickname: myNickname, nickname: myNickname,
noisePublicKey: noisePub, noisePublicKey: noisePub,
signingPublicKey: signingPub, signingPublicKey: signingPub,
directNeighbors: connectedPeerIDs, directNeighbors: connectedPeerIDs,
capabilities: PeerCapabilities.localSupported capabilities: advertisedCapabilities
) )
guard let payload = announcement.encode() else { guard let payload = announcement.encode() else {
@@ -2762,6 +2796,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) // MARK: Link capability snapshots (thread-safe via bleQueue)
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T { private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
@@ -3269,6 +3378,8 @@ extension BLEService {
case .boardPost: case .boardPost:
// Invalid or deleted posts must not spread; skip the relay step. // Invalid or deleted posts must not spread; skip the relay step.
guard handleBoardPost(packet, from: senderID) else { return } guard handleBoardPost(packet, from: senderID) else { return }
case .nostrCarrier:
handleNostrCarrier(packet, from: peerID)
case .leave: case .leave:
handleLeave(packet, from: senderID) handleLeave(packet, from: senderID)
@@ -0,0 +1,492 @@
//
// GatewayService.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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:
/// 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. An event this gateway
/// itself uplinked (`publishedEventIDs`) is additionally never
/// downlink-rebroadcast: it originated on this mesh, so echoing it back
/// when our own relay subscription redelivers it would double BLE airtime
/// (the device-confirmed self-echo bug).
/// 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. 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
/// `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 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
/// 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)?
/// 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
/// 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)] = []
/// 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
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 }
// 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
}
// 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
}
// 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
}
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)
}
}
/// 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)
}
/// 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 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 {
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 }
// 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 (self-echo): never rebroadcast an event this
// gateway itself uplinked (`publishedEventIDs`) it originated on this
// very mesh, so our own relay subscription echoing it back must not
// double the BLE airtime by pushing it out again. Loop rule 2
// (downlink): rebroadcast each genuine inbound 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),
!publishedEventIDs.contains(event.id),
!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 }
pendingDownlinks.append((event, geohash))
if pendingDownlinks.count > Limits.maxPendingDownlinks {
// 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()
}
private func drainPendingDownlinks() {
let cutoff = now().addingTimeInterval(-60)
downlinkSendTimes.removeAll { $0 < cutoff }
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)
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 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 }),
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)
&& 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<String> = []
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
}
}
+4
View File
@@ -40,6 +40,10 @@ struct SyncTypeFlags: OptionSet {
// Courier envelopes are directed deposits between trusted peers and // Courier envelopes are directed deposits between trusted peers and
// must never spread via gossip sync. // must never spread via gossip sync.
case .courierEnvelope: return nil 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
} }
} }
@@ -72,6 +72,7 @@ final class ChatViewModelBootstrapper {
configureNoiseCallbacks() configureNoiseCallbacks()
bindTransferProgress() bindTransferProgress()
configureGeoChannels() configureGeoChannels()
configureGateway()
bindTeleportState() bindTeleportState()
requestNotifications() requestNotifications()
registerObservers() registerObservers()
@@ -244,6 +245,72 @@ 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
)
// 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)
}
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() { func bindTeleportState() {
viewModel.locationManager.$teleported viewModel.locationManager.$teleported
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
@@ -115,6 +115,9 @@ final class GeohashSubscriptionManager {
private weak var context: (any GeohashSubscriptionContext)? private weak var context: (any GeohashSubscriptionContext)?
private let inbound: NostrInboundPipeline private let inbound: NostrInboundPipeline
private let presence: GeoPresenceTracker 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<String>()
init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) { init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) {
self.context = context self.context = context
@@ -145,6 +148,9 @@ final class GeohashSubscriptionManager {
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
self?.inbound.subscribeNostrEvent(event) 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 NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
self?.inbound.handleNostrEvent(event) 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) 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.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex)) context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex))
SecureLogger.debug( SecureLogger.debug(
+13
View File
@@ -94,6 +94,19 @@ struct ContentHeaderView: View {
}() }()
HStack(spacing: 2) { 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 { if carriedMailCount > 0 {
Image(systemName: "figure.walk") Image(systemName: "figure.walk")
.font(.bitchatSystem(size: 12)) .font(.bitchatSystem(size: 12))
+30
View File
@@ -27,6 +27,8 @@ struct LocationChannelsSheet: View {
static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access" static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access"
static let torTitle: LocalizedStringKey = "location_channels.tor.title" static let torTitle: LocalizedStringKey = "location_channels.tor.title"
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle" 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 toggleOn: LocalizedStringKey = "common.toggle.on"
static let toggleOff: LocalizedStringKey = "common.toggle.off" static let toggleOff: LocalizedStringKey = "common.toggle.off"
@@ -244,6 +246,8 @@ struct LocationChannelsSheet: View {
sectionDivider sectionDivider
torToggleSection torToggleSection
.padding(.top, 12) .padding(.top, 12)
gatewayToggleSection
.padding(.top, 8)
Button(action: SystemSettings.location.open) { Button(action: SystemSettings.location.open) {
Text(Strings.removeAccess) Text(Strings.removeAccess)
.bitchatFont(size: 12) .bitchatFont(size: 12)
@@ -508,6 +512,32 @@ extension LocationChannelsSheet {
.cornerRadius(8) .cornerRadius(8)
} }
private var gatewayToggleBinding: Binding<Bool> {
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 standardGreen: Color { palette.primary }
private var standardBlue: Color { palette.accentBlue } private var standardBlue: Color { palette.accentBlue }
} }
@@ -0,0 +1,96 @@
//
// NostrCarrierPacketTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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)
}
}
@@ -0,0 +1,575 @@
//
// GatewayServiceTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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(set) var scheduledDrains: [(delay: TimeInterval, work: @MainActor () -> Void)] = []
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) }
// 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)
}
}
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
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..<GatewayService.Limits.uplinkEventsPerMinutePerDepositor {
try deposit(try makeEvent(), into: fixture, from: depositor)
}
#expect(fixture.published.count == GatewayService.Limits.uplinkEventsPerMinutePerDepositor)
// One over budget inside the window: dropped.
try deposit(try makeEvent(), into: fixture, from: depositor)
#expect(fixture.published.count == GatewayService.Limits.uplinkEventsPerMinutePerDepositor)
// Another depositor is unaffected.
try deposit(try makeEvent(), into: fixture, from: PeerID(str: "0011223344556677"))
#expect(fixture.published.count == GatewayService.Limits.uplinkEventsPerMinutePerDepositor + 1)
// The window slides.
fixture.advance(61)
try deposit(try makeEvent(), into: fixture, from: depositor)
#expect(fixture.published.count == GatewayService.Limits.uplinkEventsPerMinutePerDepositor + 2)
}
@Test("bounds the offline uplink queue per depositor and in total, evicting oldest")
func uplinkQueueQuotas() throws {
let fixture = Fixture()
fixture.relaysConnected = false
let depositorA = PeerID(str: "1111111111111111")
var firstFromA: NostrEvent?
for i in 0..<(GatewayService.Limits.maxQueuedUplinksPerDepositor + 1) {
let event = try makeEvent()
if i == 0 { firstFromA = event }
try deposit(event, into: fixture, from: depositorA)
}
// The sixth deposit from the same depositor is rejected.
#expect(fixture.service.queuedUplinks.count == GatewayService.Limits.maxQueuedUplinksPerDepositor)
// Fill the global queue with other depositors.
var filler = GatewayService.Limits.maxQueuedUplinksPerDepositor
var suffix = 2
while filler < GatewayService.Limits.maxQueuedUplinks {
let depositor = PeerID(str: String(repeating: "\(suffix)", count: 16))
for _ in 0..<GatewayService.Limits.maxQueuedUplinksPerDepositor {
try deposit(try makeEvent(), into: fixture, from: depositor)
}
filler += GatewayService.Limits.maxQueuedUplinksPerDepositor
suffix += 1
}
#expect(fixture.service.queuedUplinks.count == GatewayService.Limits.maxQueuedUplinks)
// One more from a fresh depositor evicts the oldest queued deposit.
let newest = try makeEvent()
try deposit(newest, into: fixture, from: PeerID(str: "9999999999999999"))
#expect(fixture.service.queuedUplinks.count == GatewayService.Limits.maxQueuedUplinks)
#expect(fixture.service.queuedUplinks.contains { $0.event.id == newest.id })
#expect(!fixture.service.queuedUplinks.contains { $0.event.id == firstFromA?.id })
// Relay connectivity returning flushes the queue.
fixture.relaysConnected = true
fixture.service.flushQueuedUplinks()
#expect(fixture.published.count == GatewayService.Limits.maxQueuedUplinks)
#expect(fixture.service.queuedUplinks.isEmpty)
}
@Test("absorbs repeat deposits of the same event")
func repeatDepositAbsorbed() throws {
let fixture = Fixture()
let event = try makeEvent()
try deposit(event, into: fixture)
try deposit(event, into: fixture, from: PeerID(str: "8877665544332211"))
#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..<GatewayService.Limits.maxQueuedUplinksPerDepositor {
try deposit(try makeEvent(), into: fixture, from: depositor)
}
#expect(fixture.service.queuedUplinks.count == GatewayService.Limits.maxQueuedUplinksPerDepositor)
#expect(fixture.injected.count == GatewayService.Limits.maxQueuedUplinksPerDepositor)
// One past the cap: dropped by the queue quota, so it is neither
// queued nor shown no phantom local message.
try deposit(try makeEvent(), into: fixture, from: depositor)
#expect(fixture.service.queuedUplinks.count == GatewayService.Limits.maxQueuedUplinksPerDepositor)
#expect(fixture.injected.count == GatewayService.Limits.maxQueuedUplinksPerDepositor)
}
// MARK: - Loop prevention
@Test("never re-publishes or re-carries an event learned from a fromGateway broadcast")
func meshCarriedNeverRepublished() throws {
let fixture = Fixture()
let event = try makeEvent()
let broadcast = try carrierPayload(event, direction: .fromGateway)
// Another gateway rebroadcast this event onto the mesh; we saw it.
fixture.service.handleMeshCarrier(broadcast, from: PeerID(str: "8877665544332211"), directedToUs: false)
#expect(fixture.injected.map(\.id) == [event.id])
// Rule: a deposit of the same event must never be published
try deposit(event, into: fixture)
#expect(fixture.published.isEmpty)
// a relay echo of it must never be rebroadcast (we'd loop it back)
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
#expect(fixture.broadcasts.isEmpty)
// and it must never be re-uplinked from this device.
fixture.relaysConnected = false
fixture.gatewayPeers = [PeerID(str: "8877665544332211")]
#expect(!fixture.service.uplinkViaMesh(event: event, geohash: Self.geohash))
#expect(fixture.uplinkSends.isEmpty)
}
@Test("rebroadcasts a relay event at most once, absorbing echoes")
func relayEventRebroadcastOnce() throws {
let fixture = Fixture()
let event = try makeEvent()
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
#expect(fixture.broadcasts.count == 1)
// The rebroadcast payload decodes as a fromGateway carrier for the
// same signed event.
let payload = try #require(fixture.broadcasts.first)
let decoded = try #require(NostrCarrierPacket.decode(payload))
#expect(decoded.direction == .fromGateway)
#expect(decoded.event()?.id == event.id)
}
@Test("never downlink-rebroadcasts an event it uplinked and saw echo back")
func uplinkedEventNotSelfEchoed() throws {
let fixture = Fixture()
let event = try makeEvent()
// A mesh-only peer deposits an event; the gateway uplinks (publishes)
// it to the relays (loop rule 2 records it in publishedEventIDs).
try deposit(event, into: fixture)
#expect(fixture.published.map(\.event.id) == [event.id])
// The gateway's own geohash subscription now delivers that same event
// right back (~0.15s later on device). It originated on this mesh, so
// rebroadcasting it would double BLE airtime the device-confirmed
// self-echo. It must be suppressed.
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
#expect(fixture.broadcasts.isEmpty)
// Sanity: a genuine inbound-from-internet event (never uplinked here)
// still downlink-rebroadcasts normally.
let inbound = try makeEvent()
fixture.service.rebroadcastRelayEvent(inbound, geohash: Self.geohash)
#expect(fixture.broadcasts.count == 1)
let payload = try #require(fixture.broadcasts.first)
let decoded = try #require(NostrCarrierPacket.decode(payload))
#expect(decoded.event()?.id == inbound.id)
}
@Test("a forged broadcast cannot poison the loop-prevention set")
func forgedBroadcastDoesNotPoison() throws {
let fixture = Fixture()
let event = try makeEvent()
let forgedPayload = try carrierPayload(try forge(event), direction: .fromGateway)
fixture.service.handleMeshCarrier(forgedPayload, from: PeerID(str: "8877665544332211"), directedToUs: false)
#expect(fixture.injected.isEmpty)
// The genuine event still uplinks: the forged copy (sharing its ID)
// was rejected before the mesh-carried marking.
try deposit(event, into: fixture)
#expect(fixture.published.map(\.event.id) == [event.id])
}
// MARK: - Downlink bandwidth guard
@Test("caps mesh rebroadcasts per minute, queueing with drop-oldest")
func downlinkBudget() throws {
let fixture = Fixture()
let overBudget = GatewayService.Limits.downlinkEventsPerMinute + 5
var events: [NostrEvent] = []
for _ in 0..<overBudget {
let event = try makeEvent()
events.append(event)
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
}
#expect(fixture.broadcasts.count == GatewayService.Limits.downlinkEventsPerMinute)
// Once the window slides, the next relay event drains the backlog too.
fixture.advance(61)
fixture.service.rebroadcastRelayEvent(try makeEvent(), geohash: Self.geohash)
#expect(fixture.broadcasts.count == overBudget + 1)
}
@Test("does not spend downlink budget on stale backfill or geohash mismatch")
func downlinkDropsStaleAndMismatched() throws {
let fixture = Fixture()
// A fresh, matching event rebroadcasts.
let fresh = try makeEvent()
// An event whose #g tag disagrees with the carrier geohash.
let mismatched = try makeEvent(geohash: "9q8yyk")
// An event that will age past the receiver window before we offer it.
let willBeStale = try makeEvent()
fixture.service.rebroadcastRelayEvent(fresh, geohash: Self.geohash)
#expect(fixture.broadcasts.count == 1)
fixture.service.rebroadcastRelayEvent(mismatched, geohash: Self.geohash)
#expect(fixture.broadcasts.count == 1) // geohash mismatch: dropped pre-budget
fixture.advance(GatewayService.Limits.maxEventAgeSeconds + 60)
fixture.service.rebroadcastRelayEvent(willBeStale, geohash: Self.geohash)
#expect(fixture.broadcasts.count == 1) // stale backfill: dropped pre-budget
}
@Test("drains a downlink backlog on the scheduled timer when the channel goes quiet")
func downlinkDrainsOnTimer() throws {
let fixture = Fixture()
let overBudget = GatewayService.Limits.downlinkEventsPerMinute + 5
for _ in 0..<overBudget {
fixture.service.rebroadcastRelayEvent(try makeEvent(), geohash: Self.geohash)
}
// Budget spent; the tail is queued and a drain timer was armed.
#expect(fixture.broadcasts.count == GatewayService.Limits.downlinkEventsPerMinute)
#expect(!fixture.scheduledDrains.isEmpty)
// The channel goes quiet no new inbound event. The window frees and
// the timer fires on its own, draining the remaining backlog.
fixture.advance(61)
fixture.fireScheduledDrains()
#expect(fixture.broadcasts.count == overBudget)
}
// MARK: - Receiver downlink handling
@Test("injects a carried event once, even when broadcast twice")
func downlinkInjectedOnce() throws {
let fixture = Fixture(enabled: false) // receivers need no toggle
let event = try makeEvent()
let payload = try carrierPayload(event, direction: .fromGateway)
fixture.service.handleMeshCarrier(payload, from: PeerID(str: "8877665544332211"), directedToUs: false)
fixture.service.handleMeshCarrier(payload, from: PeerID(str: "7766554433221100"), directedToUs: false)
#expect(fixture.injected.map(\.id) == [event.id])
}
@Test("only injects events for the geohash channel being viewed")
func downlinkChannelScoped() throws {
let fixture = Fixture(enabled: false)
fixture.currentGeohash = "9q8yyk"
let event = try makeEvent()
let payload = try carrierPayload(event, direction: .fromGateway)
fixture.service.handleMeshCarrier(payload, from: PeerID(str: "8877665544332211"), directedToUs: false)
#expect(fixture.injected.isEmpty)
}
@Test("a directed fromGateway or broadcast toGateway carrier is malformed and dropped")
func directionMisuseDropped() throws {
let fixture = Fixture()
let event = try makeEvent()
let downlink = try carrierPayload(event, direction: .fromGateway)
fixture.service.handleMeshCarrier(downlink, from: PeerID(str: "8877665544332211"), directedToUs: true)
#expect(fixture.injected.isEmpty)
let uplink = try carrierPayload(event, direction: .toGateway)
fixture.service.handleMeshCarrier(uplink, from: PeerID(str: "8877665544332211"), directedToUs: false)
#expect(fixture.published.isEmpty)
}
// MARK: - Mesh-only sender uplink
@Test("uplinks via a mesh gateway only when relays are down and a gateway exists")
func uplinkViaMeshConditions() throws {
let fixture = Fixture(enabled: false)
let gatewayPeer = PeerID(str: "8877665544332211")
let event = try makeEvent()
// Relays working: no mesh uplink.
fixture.relaysConnected = true
fixture.gatewayPeers = [gatewayPeer]
#expect(!fixture.service.uplinkViaMesh(event: event, geohash: Self.geohash))
// Relays down, no gateway around: no mesh uplink.
fixture.relaysConnected = false
fixture.gatewayPeers = []
#expect(!fixture.service.uplinkViaMesh(event: event, geohash: Self.geohash))
// Relays down and a gateway peer advertised: uplink goes out.
fixture.gatewayPeers = [gatewayPeer]
#expect(fixture.service.uplinkViaMesh(event: event, geohash: Self.geohash))
#expect(fixture.uplinkSends.count == 1)
#expect(fixture.uplinkSends.first?.peer == gatewayPeer)
let sentPayload = try #require(fixture.uplinkSends.first?.payload)
let sent = try #require(NostrCarrierPacket.decode(sentPayload))
#expect(sent.direction == .toGateway)
#expect(sent.geohash == Self.geohash)
#expect(sent.event()?.id == event.id)
// A failed transport hand-off reports false.
fixture.sendToGatewaySucceeds = false
#expect(!fixture.service.uplinkViaMesh(event: try makeEvent(), geohash: Self.geohash))
}
@Test("uplinkViaMesh backstop refuses an event this gateway already published")
func uplinkViaMeshBackstopsPublished() throws {
let fixture = Fixture()
let event = try makeEvent()
// This gateway publishes the event to relays (loop rule 2 records it
// in publishedEventIDs).
try deposit(event, into: fixture)
#expect(fixture.published.map(\.event.id) == [event.id])
// Relays then drop and a gateway peer appears. The same event must not
// be re-uplinked from this device the publishedEventIDs backstop in
// uplinkViaMesh catches it even though it was never a mesh broadcast.
fixture.relaysConnected = false
fixture.gatewayPeers = [PeerID(str: "8877665544332211")]
#expect(!fixture.service.uplinkViaMesh(event: event, geohash: Self.geohash))
#expect(fixture.uplinkSends.isEmpty)
}
// MARK: - Toggle
@Test("persists the toggle and reports changes")
func togglePersistsAndNotifies() throws {
let suite = "GatewayServiceTests-\(UUID().uuidString)"
let fixture = Fixture(enabled: false, suite: suite)
#expect(!fixture.service.isEnabled)
fixture.service.setEnabled(true)
#expect(fixture.enabledChanges == [true])
// Setting the same value again is a no-op.
fixture.service.setEnabled(true)
#expect(fixture.enabledChanges == [true])
// A fresh service over the same defaults restores the toggle.
let revived = GatewayService(defaults: UserDefaults(suiteName: suite)!)
#expect(revived.isEnabled)
fixture.service.setEnabled(false)
#expect(fixture.enabledChanges == [true, false])
fixture.defaults.removePersistentDomain(forName: suite)
}
@Test("disabling the toggle drops queued work")
func disableClearsQueues() throws {
let fixture = Fixture()
fixture.relaysConnected = false
try deposit(try makeEvent(), into: fixture)
#expect(fixture.service.queuedUplinks.count == 1)
fixture.service.setEnabled(false)
#expect(fixture.service.queuedUplinks.isEmpty)
// Nothing to flush after re-enabling.
fixture.service.setEnabled(true)
fixture.relaysConnected = true
fixture.service.flushQueuedUplinks()
#expect(fixture.published.isEmpty)
}
}
@@ -26,6 +26,10 @@ public enum MessageType: UInt8 {
case fileTransfer = 0x22 // Binary file/audio/image payloads case fileTransfer = 0x22 // Binary file/audio/image payloads
case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone
// Gateway mode: signed Nostr event ferried between a mesh-only peer and
// an internet gateway peer.
case nostrCarrier = 0x28
public var description: String { public var description: String {
switch self { switch self {
case .announce: return "announce" case .announce: return "announce"
@@ -38,6 +42,7 @@ public enum MessageType: UInt8 {
case .fragment: return "fragment" case .fragment: return "fragment"
case .fileTransfer: return "fileTransfer" case .fileTransfer: return "fileTransfer"
case .boardPost: return "boardPost" case .boardPost: return "boardPost"
case .nostrCarrier: return "nostrCarrier"
} }
} }
} }