Files
bitchat/bitchat/Services/Transport.swift
T
jackandClaude Fable 5 5aaa209020 Friend-courier store-and-forward: mutual favorites carry sealed messages to offline peers
When a private message has no reachable transport, the router now seals it
to the recipient's Noise static key (new one-way Noise X pattern) and hands
the envelope to up to three connected mutual favorites. Couriers store the
opaque ciphertext under strict quotas (20 total, 5 per depositor, 16 KiB,
24 h) and hand it over when the recipient's announce matches a rotating
HMAC recipient tag; the recipient opens it and the message flows through
the normal private-message pipeline, so dedup and delivery acks just work.

- CourierEnvelope TLV + courierEnvelope (0x04) message type in BitFoundation
- Noise X one-way pattern reusing the existing handshake machinery,
  domain-separated by a courier prologue; sender identity authenticated
  via the ss DH (no forward secrecy - documented tradeoff)
- CourierStore with eviction, file persistence, and panic-wipe integration
- Rotating recipient tags (HMAC over epoch day) so carried envelopes don't
  correlate for observers who don't already know the recipient's key
- New "carried" delivery status with figure.walk glyph; header indicator
  while carrying mail for others
- Three-node end-to-end test ferrying packets through real BLEService
  instances, plus codec/crypto/store/router suites (986 tests green)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:13:16 +02:00

179 lines
7.8 KiB
Swift

import BitFoundation
import Foundation
import Combine
import CoreBluetooth
/// Abstract transport interface used by ChatViewModel and services.
/// BLEService implements this protocol; a future Nostr transport can too.
struct TransportPeerSnapshot: Equatable, Hashable {
let peerID: PeerID
let nickname: String
let isConnected: Bool
let noisePublicKey: Data?
let lastSeen: Date
}
enum TransportEvent: @unchecked Sendable {
case messageReceived(BitchatMessage)
case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
case noisePayloadReceived(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
case peerConnected(PeerID)
case peerDisconnected(PeerID)
case peerListUpdated([PeerID])
case peerSnapshotsUpdated([TransportPeerSnapshot])
case messageDeliveryStatusUpdated(messageID: String, status: DeliveryStatus)
case bluetoothStateUpdated(CBManagerState)
}
protocol TransportEventDelegate: AnyObject {
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
}
protocol Transport: AnyObject {
// Event sink
var delegate: BitchatDelegate? { get set }
// Typed event sink for transport-domain events. Prefer this over BitchatDelegate for new code.
var eventDelegate: TransportEventDelegate? { get set }
// Peer events (preferred over publishers for UI)
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
// Peer snapshots (for non-UI services)
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
func currentPeerSnapshots() -> [TransportPeerSnapshot]
// Identity
var myPeerID: PeerID { get }
var myNickname: String { get }
func setNickname(_ nickname: String)
// Lifecycle
func startServices()
func stopServices()
func emergencyDisconnectAll()
// Connectivity and peers
func isPeerConnected(_ peerID: PeerID) -> Bool
func isPeerReachable(_ peerID: PeerID) -> Bool
func peerNickname(peerID: PeerID) -> String?
func getPeerNicknames() -> [PeerID: String]
// Protocol utilities
func getFingerprint(for peerID: PeerID) -> String?
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState
func triggerHandshake(with peerID: PeerID)
// Noise identity/session access. Narrow, purpose-named wrappers so the
// underlying NoiseEncryptionService (and its peer-binding/session
// orchestration) is never exposed outside the transport.
/// The remote static public key of the Noise session with `peerID`, if established.
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data?
/// Fingerprint of our own Noise static identity key.
func noiseIdentityFingerprint() -> String
/// Our Noise static public key (Curve25519 key agreement).
func noiseStaticPublicKeyData() -> Data
/// Our Noise signing public key (Ed25519).
func noiseSigningPublicKeyData() -> Data
/// Signs `data` with our Noise signing key.
func noiseSignData(_ data: Data) -> Data?
/// Verifies an Ed25519 `signature` over `data` against `publicKey`.
func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool
/// Registers session-lifecycle callbacks (peer authenticated / handshake required).
func installNoiseSessionCallbacks(
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
onHandshakeRequired: @escaping (PeerID) -> Void
)
// Messaging
func sendMessage(_ content: String, mentions: [String])
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
func sendBroadcastAnnounce()
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func cancelTransfer(_ transferId: String)
// Courier store-and-forward (mesh transports only): seal a message to the
// recipient's static key and hand it to connected couriers for physical
// delivery while the recipient is offline. Returns false when the
// transport cannot courier (no connected courier, or unsupported).
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
// QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
// Pending file management (BCH-01-002: files held in memory until user accepts)
func acceptPendingFile(id: String) -> URL?
func declinePendingFile(id: String)
}
extension Transport {
// Noise identity hooks default to inert for transports that do not carry
// Noise sessions (e.g. NostrTransport).
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil }
func noiseIdentityFingerprint() -> String { "" }
func noiseStaticPublicKeyData() -> Data { Data() }
func noiseSigningPublicKeyData() -> Data { Data() }
func noiseSignData(_ data: Data) -> Data? { nil }
func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool { false }
func installNoiseSessionCallbacks(
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
onHandshakeRequired: @escaping (PeerID) -> Void
) {}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func cancelTransfer(_ transferId: String) {}
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
sendMessage(content, mentions: mentions)
}
func acceptPendingFile(id: String) -> URL? { nil }
func declinePendingFile(id: String) {}
}
protocol TransportPeerEventsDelegate: AnyObject {
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
}
extension BitchatDelegate {
@MainActor
func receiveTransportEvent(_ event: TransportEvent) {
switch event {
case .messageReceived(let message):
didReceiveMessage(message)
case let .publicMessageReceived(peerID, nickname, content, timestamp, messageID):
didReceivePublicMessage(
from: peerID,
nickname: nickname,
content: content,
timestamp: timestamp,
messageID: messageID
)
case let .noisePayloadReceived(peerID, type, payload, timestamp):
didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp)
case .peerConnected(let peerID):
didConnectToPeer(peerID)
case .peerDisconnected(let peerID):
didDisconnectFromPeer(peerID)
case .peerListUpdated(let peers):
didUpdatePeerList(peers)
case .peerSnapshotsUpdated:
break
case let .messageDeliveryStatusUpdated(messageID, status):
didUpdateMessageDeliveryStatus(messageID, status: status)
case .bluetoothStateUpdated(let state):
didUpdateBluetoothState(state)
}
}
}
extension BLEService: Transport {}