mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:45:20 +00:00
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>
137 lines
4.8 KiB
Swift
137 lines
4.8 KiB
Swift
//
|
|
// 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)
|
|
}
|
|
}
|