Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b8256bf72 | ||
|
|
2b7fd2002b | ||
|
|
e4b3cff5fa | ||
|
|
688b954fb8 |
@@ -1,30 +0,0 @@
|
||||
name: Dead Code
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
periphery:
|
||||
name: Periphery scan
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 30
|
||||
# Advisory, like SwiftLint (#1361): findings annotate the PR but don't
|
||||
# block merges. Drop continue-on-error once the baseline proves stable.
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install Periphery
|
||||
# homebrew-core formula; the peripheryapp tap lags years behind.
|
||||
run: brew install periphery
|
||||
|
||||
- name: Scan for dead code
|
||||
# Config comes from .periphery.yml; known findings (mostly iOS-only
|
||||
# code invisible to a macOS scan) are suppressed by the committed
|
||||
# baseline. --strict fails the step when NEW dead code appears.
|
||||
run: periphery scan --strict --disable-update-check
|
||||
@@ -1 +0,0 @@
|
||||
{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat17PrekeyBundleStoreC12StoredBundleV8noiseKey10Foundation4DataVvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
|
||||
@@ -1,14 +0,0 @@
|
||||
# Periphery dead-code scan configuration (https://github.com/peripheryapp/periphery)
|
||||
#
|
||||
# CI runs the macOS scheme only (an iOS scan needs a device destination and
|
||||
# doubles the build time). macOS-only scans falsely flag iOS-only code —
|
||||
# state restoration, screenshot handlers, background BLE sampling — so those
|
||||
# findings live in .periphery.baseline.json rather than being "fixed".
|
||||
# When auditing by hand, scan BOTH schemes and intersect:
|
||||
# periphery scan --schemes "bitchat (iOS)" -- -destination 'generic/platform=iOS' ARCHS=arm64
|
||||
project: bitchat.xcodeproj
|
||||
schemes:
|
||||
- bitchat (macOS)
|
||||
retain_swift_ui_previews: true
|
||||
relative_results: true
|
||||
baseline: .periphery.baseline.json
|
||||
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.7.0
|
||||
MARKETING_VERSION = 1.5.4
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
@@ -36,12 +36,34 @@ enum AppEvent: Sendable, Equatable {
|
||||
actor AppEventStream {
|
||||
private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:]
|
||||
|
||||
func stream() -> AsyncStream<AppEvent> {
|
||||
let id = UUID()
|
||||
return AsyncStream { continuation in
|
||||
continuations[id] = continuation
|
||||
continuation.onTermination = { [id] _ in
|
||||
Task {
|
||||
await self.removeContinuation(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func emit(_ event: AppEvent) {
|
||||
for continuation in continuations.values {
|
||||
continuation.yield(event)
|
||||
}
|
||||
}
|
||||
|
||||
func finish() {
|
||||
for continuation in continuations.values {
|
||||
continuation.finish()
|
||||
}
|
||||
continuations.removeAll()
|
||||
}
|
||||
|
||||
private func removeContinuation(_ id: UUID) {
|
||||
continuations.removeValue(forKey: id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Identity key for a direct conversation. Equality and hashing use the
|
||||
|
||||
@@ -10,10 +10,6 @@ final class AppChromeModel: ObservableObject {
|
||||
@Published var showingFingerprintFor: PeerID?
|
||||
@Published var isAppInfoPresented = false
|
||||
@Published var isLocationChannelsSheetPresented = false
|
||||
@Published var isNoticesSheetPresented = false
|
||||
/// When the sheet is opened for "notes left here" (empty mesh timeline),
|
||||
/// it should land on the geo tab instead of the channel-derived default.
|
||||
@Published var noticesSheetPrefersGeoTab = false
|
||||
@Published var showBluetoothAlert = false
|
||||
@Published var bluetoothAlertMessage = ""
|
||||
@Published var bluetoothState: CBManagerState = .unknown
|
||||
@@ -22,9 +18,6 @@ final class AppChromeModel: ObservableObject {
|
||||
private let chatViewModel: ChatViewModel
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
/// Bulletin-board coordinator, created on first use of the board sheet.
|
||||
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
||||
|
||||
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.nickname = chatViewModel.nickname
|
||||
@@ -66,33 +59,6 @@ final class AppChromeModel: ObservableObject {
|
||||
isAppInfoPresented = true
|
||||
}
|
||||
|
||||
func presentNotices(geoTab: Bool = false) {
|
||||
noticesSheetPrefersGeoTab = geoTab
|
||||
isNoticesSheetPresented = true
|
||||
}
|
||||
|
||||
/// Builds the mesh topology map model from the transport's gossiped
|
||||
/// graph plus the live nickname table. Unknown nodes (heard about via a
|
||||
/// neighbor claim but never announced to us) fall back to a short ID.
|
||||
func meshTopologyDisplayModel() -> MeshTopologyDisplayModel {
|
||||
let mesh = chatViewModel.meshService
|
||||
guard let snapshot = mesh.currentMeshTopology() else { return .empty }
|
||||
let nicknames = mesh.getPeerNicknames()
|
||||
|
||||
let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in
|
||||
let isSelf = peerID == snapshot.localPeerID
|
||||
let label: String
|
||||
if isSelf {
|
||||
label = chatViewModel.nickname
|
||||
} else {
|
||||
label = nicknames[peerID] ?? "\(peerID.id.prefix(8))…"
|
||||
}
|
||||
return MeshTopologyDisplayModel.Node(id: peerID.id, label: label, isSelf: isSelf)
|
||||
}
|
||||
let edges = snapshot.edges.map { ($0.a.id, $0.b.id) }
|
||||
return MeshTopologyDisplayModel(nodes: nodes, edges: edges)
|
||||
}
|
||||
|
||||
func triggerScreenshotPrivacyWarning() {
|
||||
showScreenshotPrivacyWarning = true
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ final class AppRuntime: ObservableObject {
|
||||
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models
|
||||
/// and `ChatViewModel` observe and mutate it through its intent API.
|
||||
let conversations: ConversationStore
|
||||
let peerIdentityStore: PeerIdentityStore
|
||||
let locationPresenceStore: LocationPresenceStore
|
||||
let publicChatModel: PublicChatModel
|
||||
let privateInboxModel: PrivateInboxModel
|
||||
let privateConversationModel: PrivateConversationModel
|
||||
@@ -26,7 +28,6 @@ final class AppRuntime: ObservableObject {
|
||||
let locationChannelsModel: LocationChannelsModel
|
||||
let peerListModel: PeerListModel
|
||||
let appChromeModel: AppChromeModel
|
||||
let boardAlertsModel: BoardAlertsModel
|
||||
|
||||
private let idBridge: NostrIdentityBridge
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
@@ -40,7 +41,7 @@ final class AppRuntime: ObservableObject {
|
||||
#endif
|
||||
|
||||
init(
|
||||
keychain: KeychainManagerProtocol = KeychainManager.makeDefault(),
|
||||
keychain: KeychainManagerProtocol = KeychainManager(),
|
||||
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
||||
) {
|
||||
self.idBridge = idBridge
|
||||
@@ -49,6 +50,8 @@ final class AppRuntime: ObservableObject {
|
||||
let locationPresenceStore = LocationPresenceStore()
|
||||
let locationManager = LocationChannelManager.shared
|
||||
self.conversations = conversations
|
||||
self.peerIdentityStore = peerIdentityStore
|
||||
self.locationPresenceStore = locationPresenceStore
|
||||
self.chatViewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
@@ -88,24 +91,6 @@ final class AppRuntime: ObservableObject {
|
||||
chatViewModel: self.chatViewModel,
|
||||
privateInboxModel: self.privateInboxModel
|
||||
)
|
||||
let chatViewModel = self.chatViewModel
|
||||
self.boardAlertsModel = BoardAlertsModel(
|
||||
arrivals: BoardStore.shared.postArrivals.eraseToAnyPublisher(),
|
||||
wipes: BoardStore.shared.didWipe.eraseToAnyPublisher(),
|
||||
dependencies: BoardAlertsModel.Dependencies(
|
||||
isOwnPost: { post in
|
||||
let key = chatViewModel.meshService.noiseSigningPublicKeyData()
|
||||
return !key.isEmpty && key == post.authorSigningKey
|
||||
},
|
||||
emitSystemLine: { content, geohash in
|
||||
if geohash.isEmpty {
|
||||
chatViewModel.addMeshOnlySystemMessage(content)
|
||||
} else {
|
||||
chatViewModel.addGeohashSystemMessage(content, geohash: geohash)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||
bindRuntimeObservers()
|
||||
@@ -217,16 +202,7 @@ final class AppRuntime: ObservableObject {
|
||||
chatViewModel.applicationWillTerminate()
|
||||
}
|
||||
|
||||
func handleNotificationResponse(
|
||||
identifier: String,
|
||||
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
|
||||
userInfo: [AnyHashable: Any]
|
||||
) {
|
||||
if actionIdentifier == NotificationService.waveActionID {
|
||||
chatViewModel.sendMeshWave()
|
||||
return
|
||||
}
|
||||
|
||||
func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) {
|
||||
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
||||
record(.notificationOpened(peerID: peerID))
|
||||
chatViewModel.startPrivateChat(with: peerID)
|
||||
|
||||
@@ -796,6 +796,16 @@ extension ConversationStore {
|
||||
return messageIDs
|
||||
}
|
||||
|
||||
/// Removes every direct conversation (panic clear).
|
||||
func removeAllDirectConversations() {
|
||||
let directIDs = conversationIDs.filter { id in
|
||||
if case .direct = id { return true }
|
||||
return false
|
||||
}
|
||||
for id in directIDs {
|
||||
removeConversation(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Diagnostics support
|
||||
|
||||
@@ -12,9 +12,6 @@ final class ConversationUIModel: ObservableObject {
|
||||
@Published private(set) var currentNickname: String
|
||||
@Published private(set) var isBatchingPublic = false
|
||||
@Published private(set) var canSendMediaInCurrentContext = true
|
||||
/// Who is talking live in the public mesh channel right now (floor
|
||||
/// courtesy: the composer mic tints "busy" while someone holds the floor).
|
||||
@Published private(set) var activeLiveVoiceTalker: String?
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let privateConversationModel: PrivateConversationModel
|
||||
@@ -153,17 +150,6 @@ final class ConversationUIModel: ObservableObject {
|
||||
chatViewModel.sendVoiceNote(at: url)
|
||||
}
|
||||
|
||||
/// Capture backend for the mic gesture: live PTT when the current DM
|
||||
/// peer can hear it now, classic voice note otherwise.
|
||||
func makeVoiceCaptureSession() -> VoiceCaptureSession {
|
||||
chatViewModel.makeVoiceCaptureSession()
|
||||
}
|
||||
|
||||
/// Whether this message is a live voice burst still streaming in.
|
||||
func isLiveVoiceMessage(_ message: BitchatMessage) -> Bool {
|
||||
chatViewModel.liveVoiceCoordinator.isLiveVoiceMessage(message)
|
||||
}
|
||||
|
||||
func cancelMediaSend(messageID: String) {
|
||||
chatViewModel.cancelMediaSend(messageID: messageID)
|
||||
}
|
||||
@@ -189,10 +175,6 @@ final class ConversationUIModel: ObservableObject {
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$isBatchingPublic)
|
||||
|
||||
chatViewModel.$activePublicVoiceTalker
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$activeLiveVoiceTalker)
|
||||
|
||||
conversations.$activeChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] channel in
|
||||
@@ -211,9 +193,7 @@ final class ConversationUIModel: ObservableObject {
|
||||
|
||||
private func refreshComputedState() {
|
||||
if let selectedPeerID = privateConversationModel.selectedPeerID {
|
||||
// Media transfer is not wired for groups in v1; keep it off so the
|
||||
// composer can't strand a media placeholder that never sends.
|
||||
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat || selectedPeerID.isGroup)
|
||||
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@@ -11,25 +12,20 @@ final class LocationChannelsModel: ObservableObject {
|
||||
@Published private(set) var bookmarkNames: [String: String]
|
||||
@Published private(set) var locationNames: [GeohashChannelLevel: String]
|
||||
@Published private(set) var userTorEnabled: Bool
|
||||
@Published private(set) var gatewayEnabled: Bool
|
||||
|
||||
private let manager: LocationChannelManager
|
||||
private let network: NetworkActivationService
|
||||
private let gateway: GatewayService
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
manager: LocationChannelManager? = nil,
|
||||
network: NetworkActivationService? = nil,
|
||||
gateway: GatewayService? = nil
|
||||
network: NetworkActivationService? = nil
|
||||
) {
|
||||
let manager = manager ?? .shared
|
||||
let network = network ?? .shared
|
||||
let gateway = gateway ?? .shared
|
||||
|
||||
self.manager = manager
|
||||
self.network = network
|
||||
self.gateway = gateway
|
||||
self.gatewayEnabled = gateway.isEnabled
|
||||
self.permissionState = manager.permissionState
|
||||
self.availableChannels = manager.availableChannels
|
||||
self.selectedChannel = manager.selectedChannel
|
||||
@@ -164,10 +160,6 @@ final class LocationChannelsModel: ObservableObject {
|
||||
network.$userTorEnabled
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$userTorEnabled)
|
||||
|
||||
gateway.$isEnabled
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$gatewayEnabled)
|
||||
}
|
||||
|
||||
private func level(forLength length: Int) -> GeohashChannelLevel {
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
//
|
||||
// NearbyNotesCounter.swift
|
||||
// bitchat
|
||||
//
|
||||
// Counts unexpired location notes left at the user's current building-level
|
||||
// geohash so the empty mesh timeline can say "📍 3 notes left here". Only
|
||||
// subscribes while a view holds it active, and only when location notes are
|
||||
// enabled and location permission is already granted (it never prompts).
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class NearbyNotesCounter: ObservableObject {
|
||||
static let shared = NearbyNotesCounter()
|
||||
|
||||
@Published private(set) var noteCount = 0
|
||||
|
||||
private var manager: LocationNotesManager?
|
||||
private var managerCancellable: AnyCancellable?
|
||||
private var channelsCancellable: AnyCancellable?
|
||||
private var settingCancellable: AnyCancellable?
|
||||
private var activeHolders = 0
|
||||
private let locationManager: LocationChannelManager
|
||||
|
||||
init(locationManager: LocationChannelManager = .shared) {
|
||||
self.locationManager = locationManager
|
||||
}
|
||||
|
||||
/// Begins (or keeps) the notes subscription for the current building
|
||||
/// geohash. Balanced by `deactivate()`; ref-counted so multiple views can
|
||||
/// hold it.
|
||||
func activate() {
|
||||
activeHolders += 1
|
||||
guard activeHolders == 1 else { return }
|
||||
channelsCancellable = locationManager.$availableChannels
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in self?.retarget() }
|
||||
// The app-info kill switch must take effect immediately, not on the
|
||||
// next location change or remount.
|
||||
settingCancellable = NotificationCenter.default
|
||||
.publisher(for: LocationNotesSettings.didChangeNotification)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in self?.retarget() }
|
||||
retarget()
|
||||
}
|
||||
|
||||
func deactivate() {
|
||||
activeHolders = max(0, activeHolders - 1)
|
||||
guard activeHolders == 0 else { return }
|
||||
channelsCancellable = nil
|
||||
settingCancellable = nil
|
||||
managerCancellable = nil
|
||||
manager?.cancel()
|
||||
manager = nil
|
||||
noteCount = 0
|
||||
}
|
||||
|
||||
private func retarget() {
|
||||
guard activeHolders > 0,
|
||||
LocationNotesSettings.enabled,
|
||||
locationManager.permissionState == .authorized,
|
||||
let geohash = locationManager.availableChannels
|
||||
.first(where: { $0.level == .building })?.geohash
|
||||
else {
|
||||
managerCancellable = nil
|
||||
manager?.cancel()
|
||||
manager = nil
|
||||
noteCount = 0
|
||||
return
|
||||
}
|
||||
|
||||
if let manager {
|
||||
manager.setGeohash(geohash)
|
||||
return
|
||||
}
|
||||
|
||||
let fresh = LocationNotesManager(geohash: geohash)
|
||||
manager = fresh
|
||||
managerCancellable = fresh.$notes
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] notes in
|
||||
let now = Date()
|
||||
self?.noteCount = notes.filter { $0.expiresAt.map { $0 > now } ?? true }.count
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,10 @@ final class PeerIdentityStore: ObservableObject {
|
||||
stablePeerIDsByShortID[peerID] = stablePeerID
|
||||
}
|
||||
|
||||
func replaceStablePeerIDs(_ mappings: [PeerID: PeerID]) {
|
||||
stablePeerIDsByShortID = mappings
|
||||
}
|
||||
|
||||
func fingerprint(for peerID: PeerID) -> String? {
|
||||
peerFingerprintsByPeerID[peerID]
|
||||
}
|
||||
@@ -90,6 +94,10 @@ final class PeerIdentityStore: ObservableObject {
|
||||
invalidateEncryptionCache(for: peerID)
|
||||
}
|
||||
|
||||
func replaceEncryptionStatuses(_ statuses: [PeerID: EncryptionStatus]) {
|
||||
encryptionStatuses = statuses
|
||||
}
|
||||
|
||||
func setVerifiedFingerprints(_ fingerprints: Set<String>) {
|
||||
verifiedFingerprints = fingerprints
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@ struct MeshPeerRow: Identifiable, Equatable {
|
||||
let isMutualFavorite: Bool
|
||||
let encryptionStatus: EncryptionStatus
|
||||
let showsVerifiedBadgeWhenOffline: Bool
|
||||
/// Vouched-for by someone I verified, without an explicit verification of
|
||||
/// mine — rendered as the unfilled seal (verified gets the filled one).
|
||||
let showsVouchedBadge: Bool
|
||||
|
||||
var id: String { peerID.id }
|
||||
}
|
||||
@@ -29,22 +26,11 @@ struct GeohashPersonRow: Identifiable, Equatable {
|
||||
let isBlocked: Bool
|
||||
}
|
||||
|
||||
struct GroupChatRow: Identifiable, Equatable {
|
||||
let peerID: PeerID
|
||||
let name: String
|
||||
let memberCount: Int
|
||||
let isCreator: Bool
|
||||
let hasUnread: Bool
|
||||
|
||||
var id: String { peerID.id }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PeerListModel: ObservableObject {
|
||||
@Published private(set) var allPeers: [BitchatPeer] = []
|
||||
@Published private(set) var meshRows: [MeshPeerRow] = []
|
||||
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
||||
@Published private(set) var groupRows: [GroupChatRow] = []
|
||||
@Published private(set) var reachableMeshPeerCount = 0
|
||||
@Published private(set) var connectedMeshPeerCount = 0
|
||||
@Published private(set) var visibleGeohashPeerCount = 0
|
||||
@@ -143,13 +129,6 @@ final class PeerListModel: ObservableObject {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.groupStore.$groups
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
peerIdentityStore.$encryptionStatuses
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
@@ -204,12 +183,13 @@ final class PeerListModel: ObservableObject {
|
||||
let myPeerID = chatViewModel.meshService.myPeerID
|
||||
let meshRows = allPeers.map { peer in
|
||||
let isMe = peer.peerID == myPeerID
|
||||
let fingerprint = isMe ? nil : chatViewModel.getFingerprint(for: peer.peerID)
|
||||
let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||
let verifiedBadge = !peer.isConnected && isVerifiedFingerprint
|
||||
// Vouched is subordinate to verified: never show both seals.
|
||||
let vouchedBadge = !isVerifiedFingerprint
|
||||
&& (fingerprint.map { chatViewModel.isVouchedFingerprint($0) } ?? false)
|
||||
let verifiedBadge: Bool
|
||||
if !isMe && !peer.isConnected,
|
||||
let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) {
|
||||
verifiedBadge = peerIdentityStore.isVerified(fingerprint)
|
||||
} else {
|
||||
verifiedBadge = false
|
||||
}
|
||||
|
||||
return MeshPeerRow(
|
||||
peerID: peer.peerID,
|
||||
@@ -222,8 +202,7 @@ final class PeerListModel: ObservableObject {
|
||||
isReachable: peer.isReachable,
|
||||
isMutualFavorite: peer.isMutualFavorite,
|
||||
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
|
||||
showsVerifiedBadgeWhenOffline: verifiedBadge,
|
||||
showsVouchedBadge: vouchedBadge
|
||||
showsVerifiedBadgeWhenOffline: verifiedBadge
|
||||
)
|
||||
}
|
||||
|
||||
@@ -238,40 +217,22 @@ final class PeerListModel: ObservableObject {
|
||||
}
|
||||
|
||||
let geohashPeople = buildGeohashPeople()
|
||||
let groupRows = buildGroupRows()
|
||||
|
||||
self.meshRows = meshRows
|
||||
reachableMeshPeerCount = meshCounts.reachable
|
||||
connectedMeshPeerCount = meshCounts.connected
|
||||
self.geohashPeople = geohashPeople
|
||||
visibleGeohashPeerCount = geohashPeople.count
|
||||
self.groupRows = groupRows
|
||||
renderID = (
|
||||
meshRows.map {
|
||||
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
||||
} +
|
||||
geohashPeople.map {
|
||||
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
|
||||
} +
|
||||
groupRows.map {
|
||||
"group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)"
|
||||
}
|
||||
).joined(separator: "|")
|
||||
}
|
||||
|
||||
private func buildGroupRows() -> [GroupChatRow] {
|
||||
let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint()
|
||||
return chatViewModel.groupStore.groups.map { group in
|
||||
GroupChatRow(
|
||||
peerID: group.peerID,
|
||||
name: group.name,
|
||||
memberCount: group.members.count,
|
||||
isCreator: group.creatorFingerprint == myFingerprint,
|
||||
hasUnread: chatViewModel.hasUnreadMessages(for: group.peerID)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildGeohashPeople() -> [GeohashPersonRow] {
|
||||
let myHex = currentGeohashIdentityHex()
|
||||
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
|
||||
|
||||
@@ -108,13 +108,7 @@ struct PrivateConversationHeaderState: Equatable {
|
||||
let encryptionStatus: EncryptionStatus?
|
||||
|
||||
var supportsFavoriteToggle: Bool {
|
||||
!conversationPeerID.isGeoDM && !conversationPeerID.isGroup
|
||||
}
|
||||
|
||||
/// Group chats have no single peer identity behind the header: no
|
||||
/// fingerprint screen, no per-peer encryption badge.
|
||||
var isGroupConversation: Bool {
|
||||
conversationPeerID.isGroup
|
||||
!conversationPeerID.isGeoDM
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,13 +206,6 @@ final class PrivateConversationModel: ObservableObject {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.groupStore.$groups
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
@@ -242,26 +229,6 @@ final class PrivateConversationModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
|
||||
// Group chats: the "peer" is the whole crew. Name + member count in
|
||||
// the header; availability reads as mesh since group traffic floods
|
||||
// the local mesh, and the per-peer encryption badge does not apply.
|
||||
if conversationPeerID.isGroup {
|
||||
let displayName: String
|
||||
if let group = chatViewModel.groupStore.group(for: conversationPeerID) {
|
||||
displayName = "#\(group.name) (\(group.members.count))"
|
||||
} else {
|
||||
displayName = String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
||||
}
|
||||
return PrivateConversationHeaderState(
|
||||
conversationPeerID: conversationPeerID,
|
||||
headerPeerID: conversationPeerID,
|
||||
displayName: displayName,
|
||||
availability: .meshReachable,
|
||||
isFavorite: false,
|
||||
encryptionStatus: nil
|
||||
)
|
||||
}
|
||||
|
||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||
|
||||
@@ -3,19 +3,12 @@ import Combine
|
||||
import Foundation
|
||||
|
||||
struct FingerprintPresentationState: Equatable {
|
||||
let statusPeerID: PeerID
|
||||
let peerNickname: String
|
||||
let encryptionStatus: EncryptionStatus
|
||||
let theirFingerprint: String?
|
||||
let myFingerprint: String
|
||||
let isVerified: Bool
|
||||
/// Number of currently-valid vouches from peers the user verified
|
||||
/// (0 when the peer is explicitly verified — the stronger badge wins).
|
||||
let voucherCount: Int
|
||||
/// Display names of the (verified) vouchers, where known.
|
||||
let voucherNames: [String]
|
||||
|
||||
/// Vouched for by ≥1 peer the user verified (and not explicitly verified).
|
||||
var isVouched: Bool { voucherCount > 0 }
|
||||
|
||||
var canToggleVerification: Bool {
|
||||
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
|
||||
@@ -55,6 +48,10 @@ final class VerificationModel: ObservableObject {
|
||||
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
|
||||
}
|
||||
|
||||
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
||||
chatViewModel.beginQRVerification(with: qr)
|
||||
}
|
||||
|
||||
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
|
||||
guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
|
||||
return .invalid
|
||||
@@ -85,33 +82,14 @@ final class VerificationModel: ObservableObject {
|
||||
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
|
||||
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
|
||||
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
|
||||
let isVerified = theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||
|
||||
// Vouch state is recomputed on read: only vouchers still in the
|
||||
// verified set count, so removing a verification silently retires the
|
||||
// vouches that peer gave.
|
||||
let vouchers: [VouchRecord]
|
||||
if !isVerified, let theirFingerprint {
|
||||
vouchers = chatViewModel.identityManager.validVouchers(for: theirFingerprint)
|
||||
} else {
|
||||
vouchers = []
|
||||
}
|
||||
let voucherNames = vouchers.compactMap { record -> String? in
|
||||
guard let social = chatViewModel.identityManager.getSocialIdentity(for: record.voucherFingerprint) else {
|
||||
return nil
|
||||
}
|
||||
if let petname = social.localPetname, !petname.isEmpty { return petname }
|
||||
return social.claimedNickname.isEmpty ? nil : social.claimedNickname
|
||||
}
|
||||
|
||||
return FingerprintPresentationState(
|
||||
statusPeerID: statusPeerID,
|
||||
peerNickname: peerNickname,
|
||||
encryptionStatus: encryptionStatus,
|
||||
theirFingerprint: theirFingerprint,
|
||||
myFingerprint: chatViewModel.getMyFingerprint(),
|
||||
isVerified: isVerified,
|
||||
voucherCount: vouchers.count,
|
||||
voucherNames: voucherNames
|
||||
isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||
)
|
||||
}
|
||||
|
||||
@@ -144,17 +122,6 @@ final class VerificationModel: ObservableObject {
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Vouch state changes (ChatVouchCoordinator.notifyPeerTrustChanged)
|
||||
// are signalled via this notification rather than a published
|
||||
// property, so an open fingerprint sheet refreshes its vouched badge
|
||||
// live when a vouch batch is accepted.
|
||||
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 848 B |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 460 B After Width: | Height: | Size: 356 B |
|
Before Width: | Height: | Size: 833 B After Width: | Height: | Size: 429 B |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 833 B After Width: | Height: | Size: 429 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 597 B |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 5.2 KiB |
@@ -27,66 +27,6 @@
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_16x16.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_16x16@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_32x32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_32x32@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_128x128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_128x128@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_256x256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_256x256@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_512x512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_512x512@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 505 B |
|
Before Width: | Height: | Size: 930 B |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 930 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 73 KiB |
@@ -40,7 +40,6 @@ struct BitchatApp: App {
|
||||
.environmentObject(runtime.locationChannelsModel)
|
||||
.environmentObject(runtime.peerListModel)
|
||||
.environmentObject(runtime.appChromeModel)
|
||||
.environmentObject(runtime.boardAlertsModel)
|
||||
.onAppear {
|
||||
appDelegate.runtime = runtime
|
||||
runtime.start()
|
||||
@@ -104,20 +103,12 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||
let identifier = response.notification.request.identifier
|
||||
let actionIdentifier = response.actionIdentifier
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
|
||||
// Complete only after the response is handled: for a background
|
||||
// action (👋 wave) the system may suspend the app the moment the
|
||||
// completion handler runs, which would drop the queued send.
|
||||
Task { @MainActor in
|
||||
self.runtime?.handleNotificationResponse(
|
||||
identifier: identifier,
|
||||
actionIdentifier: actionIdentifier,
|
||||
userInfo: userInfo
|
||||
)
|
||||
completionHandler()
|
||||
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
|
||||
}
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
@@ -13,11 +14,28 @@ enum ImageUtilsError: Error {
|
||||
}
|
||||
|
||||
enum ImageUtils {
|
||||
private static let compressionQuality: CGFloat = 0.82
|
||||
private static let targetImageBytes: Int = 45_000
|
||||
private static let compressionQuality: CGFloat = 0.85
|
||||
// Upper bound for the compressed JPEG. This is only a ceiling: the encoder
|
||||
// keeps whatever a photo naturally weighs at `defaultMaxDimension` and
|
||||
// `compressionQuality`, and only steps quality down when a payload would
|
||||
// exceed this budget. It stays well under `FileTransferLimits.maxImageBytes`
|
||||
// (512 KiB) so the BLE path never overruns its cap.
|
||||
//
|
||||
// Wi-Fi bulk relevance: the old 45 KB / 448 px budget crushed every photo
|
||||
// to ~40 KB — below `TransportConfig.wifiBulkMinPayloadBytes` (64 KiB) — so
|
||||
// `WifiBulkPolicy.shouldOffer` never fired and the AWDL data plane was dead
|
||||
// in production. A genuinely detailed photo at `defaultMaxDimension` now
|
||||
// weighs well over 64 KiB, so it becomes Wi-Fi-bulk eligible to a capable
|
||||
// direct peer while still riding BLE fragmentation for everyone else.
|
||||
private static let targetImageBytes: Int = 200_000
|
||||
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
|
||||
// Longest-side ceiling for shared photos. 448 px was thumbnail-tier and
|
||||
// (together with the tiny byte budget) forced every photo below the Wi-Fi
|
||||
// bulk threshold. 1024 px keeps a shared photo legible and lets detailed
|
||||
// images clear 64 KiB, without approaching the 512 KiB hard cap.
|
||||
static let defaultMaxDimension: CGFloat = 1024
|
||||
|
||||
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||
static func processImage(at url: URL, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
|
||||
try validateImageSource(at: url)
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
@@ -47,34 +65,33 @@ enum ImageUtils {
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
// Scale the image first
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
|
||||
// Get CGImage from UIImage - this is the key to stripping metadata
|
||||
guard let cgImage = scaled.cgImage else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
|
||||
// Use CGImageDestination to encode without metadata (same as macOS)
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
|
||||
// Compress to target size
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
var dimension = maxDimension
|
||||
var jpegData: Data?
|
||||
// Downscale-and-compress until the payload fits the hard image cap.
|
||||
// A normal photo converges on the first pass; this loop only kicks
|
||||
// in for near-incompressible inputs (e.g. full-frame noise) that
|
||||
// would otherwise overrun `maxImageBytes` at the raised dimension.
|
||||
while true {
|
||||
let scaled = scaledImage(image, maxDimension: dimension)
|
||||
// Get CGImage from UIImage - this is the key to stripping metadata
|
||||
guard let cgImage = scaled.cgImage else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
guard let data = compressToBudget(cgImage) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
jpegData = data
|
||||
if data.count <= FileTransferLimits.maxImageBytes || dimension <= minRetryDimension {
|
||||
break
|
||||
}
|
||||
dimension = (dimension * dimensionRetryFactor).rounded(.down)
|
||||
}
|
||||
guard let finalData = jpegData else { throw ImageUtilsError.encodingFailed }
|
||||
|
||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
try finalData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
}
|
||||
@@ -93,66 +110,49 @@ enum ImageUtils {
|
||||
UIGraphicsEndImageContext()
|
||||
return rendered ?? image
|
||||
}
|
||||
|
||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||
return nil
|
||||
}
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
return nil
|
||||
}
|
||||
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||
// By only specifying compression quality and no metadata keys,
|
||||
// we ensure a clean JPEG with no privacy-leaking information
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageDestinationLossyCompressionQuality: quality
|
||||
]
|
||||
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
return nil
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
#else
|
||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
let width = inputCG.width
|
||||
let height = inputCG.height
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
guard let cgImage = context.makeImage() else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
var dimension = maxDimension
|
||||
var jpegData: Data?
|
||||
// See the iOS path: normal photos converge immediately; the loop
|
||||
// only shrinks further for near-incompressible inputs so the
|
||||
// output never overruns `maxImageBytes`.
|
||||
while true {
|
||||
let scaled = scaledImage(image, maxDimension: dimension)
|
||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
let width = inputCG.width
|
||||
let height = inputCG.height
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
guard let cgImage = context.makeImage() else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
guard let data = compressToBudget(cgImage) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
jpegData = data
|
||||
if data.count <= FileTransferLimits.maxImageBytes || dimension <= minRetryDimension {
|
||||
break
|
||||
}
|
||||
dimension = (dimension * dimensionRetryFactor).rounded(.down)
|
||||
}
|
||||
guard let finalData = jpegData else { throw ImageUtilsError.encodingFailed }
|
||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
try finalData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
}
|
||||
@@ -172,6 +172,31 @@ enum ImageUtils {
|
||||
scaledImage.unlockFocus()
|
||||
return scaledImage
|
||||
}
|
||||
#endif
|
||||
|
||||
// When even the quality floor can't get an image under the byte budget,
|
||||
// shrink the longest side by this factor and re-encode. Bounded below so
|
||||
// the retry loop always terminates.
|
||||
private static let dimensionRetryFactor: CGFloat = 0.75
|
||||
private static let minRetryDimension: CGFloat = 256
|
||||
|
||||
/// Encodes `cgImage` to JPEG, stepping quality down toward
|
||||
/// `targetImageBytes`. Shared by both platforms.
|
||||
private static func compressToBudget(_ cgImage: CGImage) -> Data? {
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||
return nil
|
||||
}
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
}
|
||||
}
|
||||
return jpegData
|
||||
}
|
||||
|
||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||
@@ -193,7 +218,6 @@ enum ImageUtils {
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
||||
let formatter = DateFormatter()
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
//
|
||||
// PTTAudioCodec.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Streaming PCM -> AAC-LC encoder for live voice. Stateful (the AAC encoder
|
||||
/// carries a bit reservoir across frames); one instance per burst.
|
||||
/// Not thread-safe — confine to one queue.
|
||||
final class PTTFrameEncoder {
|
||||
private let converter: AVAudioConverter
|
||||
private var pendingInput: [AVAudioPCMBuffer] = []
|
||||
|
||||
init?() {
|
||||
guard let pcm = PTTAudioFormat.pcmFormat,
|
||||
let aac = PTTAudioFormat.aacFormat,
|
||||
let converter = AVAudioConverter(from: pcm, to: aac)
|
||||
else { return nil }
|
||||
converter.bitRate = PTTAudioFormat.bitRate
|
||||
self.converter = converter
|
||||
}
|
||||
|
||||
/// Feeds PCM (16 kHz mono float) and returns every complete AAC frame the
|
||||
/// encoder produced. Frames come out ~130 bytes each at 16 kbps.
|
||||
func encode(_ buffer: AVAudioPCMBuffer) -> [Data] {
|
||||
pendingInput.append(buffer)
|
||||
return drainConverter()
|
||||
}
|
||||
|
||||
private func drainConverter() -> [Data] {
|
||||
var frames: [Data] = []
|
||||
while true {
|
||||
let output = AVAudioCompressedBuffer(
|
||||
format: converter.outputFormat,
|
||||
packetCapacity: 8,
|
||||
maximumPacketSize: max(converter.maximumOutputPacketSize, 1)
|
||||
)
|
||||
var error: NSError?
|
||||
let status = converter.convert(to: output, error: &error) { [weak self] _, outStatus in
|
||||
guard let self, let next = self.pendingInput.first else {
|
||||
outStatus.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
self.pendingInput.removeFirst()
|
||||
outStatus.pointee = .haveData
|
||||
return next
|
||||
}
|
||||
if status == .error {
|
||||
SecureLogger.error("PTT encode failed: \(error?.localizedDescription ?? "unknown")", category: .session)
|
||||
return frames
|
||||
}
|
||||
frames.append(contentsOf: Self.extractPackets(from: output))
|
||||
// .haveData means the output buffer filled and more may be ready;
|
||||
// anything else means the converter wants more input.
|
||||
if status != .haveData { return frames }
|
||||
}
|
||||
}
|
||||
|
||||
private static func extractPackets(from buffer: AVAudioCompressedBuffer) -> [Data] {
|
||||
guard buffer.packetCount > 0, let descriptions = buffer.packetDescriptions else { return [] }
|
||||
var frames: [Data] = []
|
||||
frames.reserveCapacity(Int(buffer.packetCount))
|
||||
for index in 0..<Int(buffer.packetCount) {
|
||||
let description = descriptions[index]
|
||||
guard description.mDataByteSize > 0 else { continue }
|
||||
let start = buffer.data.advanced(by: Int(description.mStartOffset))
|
||||
frames.append(Data(bytes: start, count: Int(description.mDataByteSize)))
|
||||
}
|
||||
return frames
|
||||
}
|
||||
}
|
||||
|
||||
/// Streaming AAC-LC -> PCM decoder for live voice. Stateful; one instance per
|
||||
/// inbound burst. Not thread-safe — confine to one queue/actor.
|
||||
final class PTTFrameDecoder {
|
||||
private let converter: AVAudioConverter
|
||||
private let pcmFormat: AVAudioFormat
|
||||
private let aacFormat: AVAudioFormat
|
||||
|
||||
init?() {
|
||||
guard let pcm = PTTAudioFormat.pcmFormat,
|
||||
let aac = PTTAudioFormat.aacFormat,
|
||||
let converter = AVAudioConverter(from: aac, to: pcm)
|
||||
else { return nil }
|
||||
self.converter = converter
|
||||
self.pcmFormat = pcm
|
||||
self.aacFormat = aac
|
||||
}
|
||||
|
||||
/// Decodes one raw AAC frame to PCM. Returns nil for malformed input or
|
||||
/// while the decoder is still priming (the first frame of a stream).
|
||||
func decode(_ frame: Data) -> AVAudioPCMBuffer? {
|
||||
guard !frame.isEmpty, frame.count <= 8 * 1024 else { return nil }
|
||||
|
||||
let input = AVAudioCompressedBuffer(format: aacFormat, packetCapacity: 1, maximumPacketSize: frame.count)
|
||||
frame.withUnsafeBytes { raw in
|
||||
guard let base = raw.baseAddress else { return }
|
||||
input.data.copyMemory(from: base, byteCount: frame.count)
|
||||
}
|
||||
input.byteLength = UInt32(frame.count)
|
||||
input.packetCount = 1
|
||||
input.packetDescriptions?.pointee = AudioStreamPacketDescription(
|
||||
mStartOffset: 0,
|
||||
mVariableFramesInPacket: 0,
|
||||
mDataByteSize: UInt32(frame.count)
|
||||
)
|
||||
|
||||
guard let output = AVAudioPCMBuffer(
|
||||
pcmFormat: pcmFormat,
|
||||
frameCapacity: PTTAudioFormat.samplesPerFrame * 2
|
||||
) else { return nil }
|
||||
|
||||
var consumed = false
|
||||
var error: NSError?
|
||||
let status = converter.convert(to: output, error: &error) { _, outStatus in
|
||||
if consumed {
|
||||
outStatus.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
consumed = true
|
||||
outStatus.pointee = .haveData
|
||||
return input
|
||||
}
|
||||
guard status != .error else {
|
||||
SecureLogger.debug("PTT decode failed: \(error?.localizedDescription ?? "unknown")", category: .session)
|
||||
return nil
|
||||
}
|
||||
return output.frameLength > 0 ? output : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample-rate/channel converter from the microphone's native format to the
|
||||
/// 16 kHz mono processing format. Stateful; not thread-safe.
|
||||
final class PTTInputResampler {
|
||||
private let converter: AVAudioConverter
|
||||
private let outputFormat: AVAudioFormat
|
||||
private let ratio: Double
|
||||
|
||||
init?(inputFormat: AVAudioFormat) {
|
||||
guard let pcm = PTTAudioFormat.pcmFormat,
|
||||
let converter = AVAudioConverter(from: inputFormat, to: pcm)
|
||||
else { return nil }
|
||||
self.converter = converter
|
||||
self.outputFormat = pcm
|
||||
self.ratio = PTTAudioFormat.sampleRate / inputFormat.sampleRate
|
||||
}
|
||||
|
||||
func resample(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
|
||||
let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 64
|
||||
guard let output = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else { return nil }
|
||||
|
||||
var consumed = false
|
||||
var error: NSError?
|
||||
let status = converter.convert(to: output, error: &error) { _, outStatus in
|
||||
if consumed {
|
||||
outStatus.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
consumed = true
|
||||
outStatus.pointee = .haveData
|
||||
return buffer
|
||||
}
|
||||
guard status != .error else {
|
||||
SecureLogger.debug("PTT resample failed: \(error?.localizedDescription ?? "unknown")", category: .session)
|
||||
return nil
|
||||
}
|
||||
return output.frameLength > 0 ? output : nil
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
//
|
||||
// PTTAudioFormat.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
/// Shared audio parameters for live push-to-talk: AAC-LC, 16 kHz, mono,
|
||||
/// ~16 kbps — deliberately identical to `VoiceRecorder`'s voice-note settings
|
||||
/// so a burst's finalized `.m4a` and its live frames sound the same.
|
||||
enum PTTAudioFormat {
|
||||
static let sampleRate: Double = 16_000
|
||||
static let channelCount: AVAudioChannelCount = 1
|
||||
static let bitRate = 16_000
|
||||
/// AAC-LC frame size is fixed by the codec: 1024 samples = 64 ms at 16 kHz.
|
||||
static let samplesPerFrame: AVAudioFrameCount = 1024
|
||||
static var frameDuration: TimeInterval { Double(samplesPerFrame) / sampleRate }
|
||||
|
||||
/// Uncompressed processing format (deinterleaved float PCM).
|
||||
static var pcmFormat: AVAudioFormat? {
|
||||
AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: channelCount)
|
||||
}
|
||||
|
||||
/// Compressed wire format.
|
||||
static var aacFormat: AVAudioFormat? {
|
||||
var description = AudioStreamBasicDescription(
|
||||
mSampleRate: sampleRate,
|
||||
mFormatID: kAudioFormatMPEG4AAC,
|
||||
mFormatFlags: 0,
|
||||
mBytesPerPacket: 0,
|
||||
mFramesPerPacket: samplesPerFrame,
|
||||
mBytesPerFrame: 0,
|
||||
mChannelsPerFrame: channelCount,
|
||||
mBitsPerChannel: 0,
|
||||
mReserved: 0
|
||||
)
|
||||
return AVAudioFormat(streamDescription: &description)
|
||||
}
|
||||
|
||||
/// Voice-note container settings for the finalized `.m4a`, mirroring
|
||||
/// `VoiceRecorder.startRecording()`.
|
||||
static var voiceNoteFileSettings: [String: Any] {
|
||||
[
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: sampleRate,
|
||||
AVNumberOfChannelsKey: Int(channelCount),
|
||||
AVEncoderBitRateKey: bitRate
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds ADTS-framed AAC so a receiver can persist a burst progressively:
|
||||
/// unlike `.m4a` (whose moov atom only exists after close), an ADTS `.aac`
|
||||
/// stream is playable at any prefix — a partially received burst is still a
|
||||
/// replayable voice note.
|
||||
enum ADTSFramer {
|
||||
private static let headerSize = 7
|
||||
/// MPEG-4 sampling frequency index for 16 kHz.
|
||||
private static let samplingFrequencyIndex: UInt8 = 8
|
||||
private static let channelConfiguration: UInt8 = 1
|
||||
|
||||
/// Wraps one raw AAC-LC frame in an ADTS header.
|
||||
static func frame(_ aacFrame: Data) -> Data {
|
||||
let frameLength = aacFrame.count + headerSize
|
||||
var data = Data(capacity: frameLength)
|
||||
// Syncword 0xFFF, MPEG-4, layer 00, no CRC.
|
||||
data.append(0xFF)
|
||||
data.append(0xF1)
|
||||
// Profile AAC-LC (audio object type 2 -> bits 01), frequency index,
|
||||
// private bit 0, channel config high bit.
|
||||
data.append((0b01 << 6) | (samplingFrequencyIndex << 2) | ((channelConfiguration >> 2) & 0x1))
|
||||
data.append(((channelConfiguration & 0x3) << 6) | UInt8((frameLength >> 11) & 0x3))
|
||||
data.append(UInt8((frameLength >> 3) & 0xFF))
|
||||
data.append(UInt8((frameLength & 0x7) << 5) | 0x1F)
|
||||
// Buffer fullness 0x7FF (VBR), one AAC frame per ADTS frame.
|
||||
data.append(0xFC)
|
||||
data.append(aacFrame)
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
//
|
||||
// PTTBurstPlayer.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Plays one inbound live voice burst with a small jitter buffer.
|
||||
///
|
||||
/// Frames are decoded and scheduled back-to-back on an `AVAudioPlayerNode`;
|
||||
/// an underrun (missing/late packets) simply pauses output until the next
|
||||
/// buffer arrives, which self-heals timing without explicit silence
|
||||
/// insertion. Playback starts once `TransportConfig.pttJitterBufferSeconds`
|
||||
/// of audio is queued or `pttJitterDeadlineSeconds` has elapsed.
|
||||
@MainActor
|
||||
final class PTTBurstPlayer {
|
||||
private let engine = AVAudioEngine()
|
||||
private let node = AVAudioPlayerNode()
|
||||
private let decoder: PTTFrameDecoder
|
||||
|
||||
private var queuedBuffers: [AVAudioPCMBuffer] = []
|
||||
private var queuedDuration: TimeInterval = 0
|
||||
private var scheduledCount = 0
|
||||
private var engineStarted = false
|
||||
private var finished = false
|
||||
private var stopped = false
|
||||
private var deadlineTask: Task<Void, Never>?
|
||||
|
||||
private(set) var isPlaying = false
|
||||
|
||||
init?() {
|
||||
guard let format = PTTAudioFormat.pcmFormat, let decoder = PTTFrameDecoder() else { return nil }
|
||||
self.decoder = decoder
|
||||
engine.attach(node)
|
||||
engine.connect(node, to: engine.mainMixerNode, format: format)
|
||||
|
||||
deadlineTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttJitterDeadlineSeconds * 1_000_000_000))
|
||||
self?.startIfReady(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes and queues frames (in burst order). Starts playback when the
|
||||
/// jitter buffer fills.
|
||||
func enqueue(_ frames: [Data]) {
|
||||
guard !stopped else { return }
|
||||
for frame in frames {
|
||||
guard let pcm = decoder.decode(frame) else { continue }
|
||||
if engineStarted {
|
||||
schedule(pcm)
|
||||
} else {
|
||||
queuedBuffers.append(pcm)
|
||||
queuedDuration += Double(pcm.frameLength) / PTTAudioFormat.sampleRate
|
||||
}
|
||||
}
|
||||
startIfReady(force: false)
|
||||
}
|
||||
|
||||
/// The burst ended: stop once everything scheduled has played out.
|
||||
func finishAfterDrain() {
|
||||
finished = true
|
||||
stopIfDrained()
|
||||
}
|
||||
|
||||
/// Immediate stop (cancel, another playback taking over, teardown).
|
||||
func stop() {
|
||||
guard !stopped else { return }
|
||||
stopped = true
|
||||
deadlineTask?.cancel()
|
||||
queuedBuffers = []
|
||||
if engineStarted {
|
||||
node.stop()
|
||||
engine.stop()
|
||||
}
|
||||
isPlaying = false
|
||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||
}
|
||||
|
||||
private func startIfReady(force: Bool) {
|
||||
guard !engineStarted, !stopped, !queuedBuffers.isEmpty else { return }
|
||||
guard force || queuedDuration >= TransportConfig.pttJitterBufferSeconds else { return }
|
||||
|
||||
#if os(iOS)
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
||||
try session.setActive(true, options: [])
|
||||
} catch {
|
||||
SecureLogger.error("PTT playback session activation failed: \(error)", category: .session)
|
||||
}
|
||||
#endif
|
||||
|
||||
engine.prepare()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session)
|
||||
stopped = true
|
||||
return
|
||||
}
|
||||
engineStarted = true
|
||||
isPlaying = true
|
||||
VoiceNotePlaybackCoordinator.shared.activate(self)
|
||||
node.play()
|
||||
|
||||
let buffered = queuedBuffers
|
||||
queuedBuffers = []
|
||||
queuedDuration = 0
|
||||
for buffer in buffered {
|
||||
schedule(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
private func schedule(_ buffer: AVAudioPCMBuffer) {
|
||||
scheduledCount += 1
|
||||
node.scheduleBuffer(buffer) { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.scheduledCount -= 1
|
||||
self.stopIfDrained()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopIfDrained() {
|
||||
guard finished, scheduledCount <= 0 else { return }
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
extension PTTBurstPlayer: ExclusivePlayback {
|
||||
/// A live stream can't meaningfully pause; yielding the floor stops it.
|
||||
/// The burst keeps assembling to file, so nothing is lost.
|
||||
nonisolated func pauseForExclusivity() {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
//
|
||||
// PTTCaptureEngine.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Captures microphone audio for a live push-to-talk burst, producing both:
|
||||
/// - live AAC frames via `onFrames` (called on the capture queue), and
|
||||
/// - a finalized `.m4a` voice note on `stop()` — the same artifact
|
||||
/// `VoiceRecorder` produces, so the existing voice-note send pipeline
|
||||
/// handles delivery to receivers that missed the live stream.
|
||||
final class PTTCaptureEngine {
|
||||
/// Hard cap matching `VoiceRecorder.maxRecordingDuration`: past it the
|
||||
/// engine keeps running (the UI owns the gesture) but stops encoding.
|
||||
private static let maxCaptureDuration: TimeInterval = 120
|
||||
|
||||
/// Recreated on every `start()`: an engine whose input unit was
|
||||
/// instantiated against an earlier (playback-only or inactive) audio
|
||||
/// session keeps reporting a dead 0 Hz / 2 ch input format and fails to
|
||||
/// enable the mic (AURemoteIO -10851, observed on iPhone field tests).
|
||||
private var engine = AVAudioEngine()
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.ptt.capture", qos: .userInitiated)
|
||||
|
||||
// Capture-queue-confined state.
|
||||
private var resampler: PTTInputResampler?
|
||||
private var encoder: PTTFrameEncoder?
|
||||
private var file: AVAudioFile?
|
||||
private var fileURL: URL?
|
||||
private var encodedFrameCount = 0
|
||||
private var running = false
|
||||
private var captureStart = Date()
|
||||
/// Whether `engine.start()` succeeded for the current capture (main-actor
|
||||
/// callers only; see `stopEngineIfStarted`).
|
||||
private var engineStarted = false
|
||||
|
||||
/// Called on the capture queue with each batch of encoded AAC frames.
|
||||
var onFrames: (([Data]) -> Void)?
|
||||
|
||||
enum CaptureError: Error {
|
||||
case inputUnavailable
|
||||
case audioSetupFailed
|
||||
}
|
||||
|
||||
func start(outputURL: URL) throws {
|
||||
#if os(iOS)
|
||||
try Self.configureAudioSession()
|
||||
#endif
|
||||
|
||||
// Fresh engine per capture so its input unit binds to the session
|
||||
// that is active *now* (see `engine` doc comment).
|
||||
engine = AVAudioEngine()
|
||||
let inputFormat = engine.inputNode.outputFormat(forBus: 0)
|
||||
guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else {
|
||||
SecureLogger.error("PTT: capture input unavailable (input reports \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
|
||||
throw CaptureError.inputUnavailable
|
||||
}
|
||||
guard let resampler = PTTInputResampler(inputFormat: inputFormat),
|
||||
let encoder = PTTFrameEncoder(),
|
||||
let pcmFormat = PTTAudioFormat.pcmFormat
|
||||
else { throw CaptureError.audioSetupFailed }
|
||||
|
||||
let file = try AVAudioFile(
|
||||
forWriting: outputURL,
|
||||
settings: PTTAudioFormat.voiceNoteFileSettings,
|
||||
commonFormat: pcmFormat.commonFormat,
|
||||
interleaved: pcmFormat.isInterleaved
|
||||
)
|
||||
|
||||
queue.sync {
|
||||
self.resampler = resampler
|
||||
self.encoder = encoder
|
||||
self.file = file
|
||||
self.fileURL = outputURL
|
||||
self.encodedFrameCount = 0
|
||||
self.captureStart = Date()
|
||||
self.running = true
|
||||
}
|
||||
|
||||
engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
|
||||
self?.queue.async { self?.process(buffer) }
|
||||
}
|
||||
engine.prepare()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
SecureLogger.error("PTT: capture engine failed to start (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch): \(error)", category: .session)
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
queue.sync { self.teardown(deleteFile: true) }
|
||||
throw error
|
||||
}
|
||||
engineStarted = true
|
||||
SecureLogger.info("PTT: capture engine running (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
|
||||
}
|
||||
|
||||
/// Stops capture and finalizes the `.m4a`. Returns the file URL and the
|
||||
/// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long).
|
||||
func stop() -> (url: URL?, encodedFrames: Int) {
|
||||
stopEngineIfStarted()
|
||||
let result: (URL?, Int) = queue.sync {
|
||||
let url = fileURL
|
||||
let frames = encodedFrameCount
|
||||
teardown(deleteFile: false)
|
||||
return (url, frames)
|
||||
}
|
||||
#if os(iOS)
|
||||
Self.deactivateAudioSession()
|
||||
#endif
|
||||
return result
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
stopEngineIfStarted()
|
||||
queue.sync { teardown(deleteFile: true) }
|
||||
#if os(iOS)
|
||||
Self.deactivateAudioSession()
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Touching `inputNode` on an engine that never started instantiates its
|
||||
/// input unit against whatever session is active and spams AURemoteIO
|
||||
/// errors — a canceled-before-start hold must not touch the engine.
|
||||
private func stopEngineIfStarted() {
|
||||
guard engineStarted else { return }
|
||||
engineStarted = false
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
}
|
||||
|
||||
// MARK: - Capture queue
|
||||
|
||||
private func process(_ buffer: AVAudioPCMBuffer) {
|
||||
guard running,
|
||||
Date().timeIntervalSince(captureStart) < Self.maxCaptureDuration,
|
||||
let resampled = resampler?.resample(buffer)
|
||||
else { return }
|
||||
|
||||
do {
|
||||
try file?.write(from: resampled)
|
||||
} catch {
|
||||
SecureLogger.error("PTT capture file write failed: \(error)", category: .session)
|
||||
}
|
||||
|
||||
guard let frames = encoder?.encode(resampled), !frames.isEmpty else { return }
|
||||
encodedFrameCount += frames.count
|
||||
onFrames?(frames)
|
||||
}
|
||||
|
||||
private func teardown(deleteFile: Bool) {
|
||||
running = false
|
||||
// Releasing the AVAudioFile finalizes the .m4a container.
|
||||
file = nil
|
||||
encoder = nil
|
||||
resampler = nil
|
||||
if deleteFile, let url = fileURL {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
fileURL = nil
|
||||
}
|
||||
|
||||
// MARK: - Audio session (iOS)
|
||||
|
||||
#if os(iOS)
|
||||
private static func configureAudioSession() throws {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
#if targetEnvironment(simulator)
|
||||
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothA2DP])
|
||||
#else
|
||||
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP])
|
||||
#endif
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
private static func deactivateAudioSession() {
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
//
|
||||
// PTTSettings.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// User preference for live push-to-talk voice. One switch controls both
|
||||
/// directions: streaming your holds live, and auto-playing inbound bursts.
|
||||
/// Off means voice messages behave exactly like classic voice notes.
|
||||
enum PTTSettings {
|
||||
private static let liveVoiceEnabledKey = "ptt.liveVoiceEnabled"
|
||||
|
||||
static var liveVoiceEnabled: Bool {
|
||||
get { UserDefaults.standard.object(forKey: liveVoiceEnabledKey) as? Bool ?? true }
|
||||
set { UserDefaults.standard.set(newValue, forKey: liveVoiceEnabledKey) }
|
||||
}
|
||||
|
||||
/// Autoplay is foreground-only: audio must never start from the
|
||||
/// background.
|
||||
@MainActor
|
||||
static var isAppActive: Bool {
|
||||
#if os(iOS)
|
||||
return UIApplication.shared.applicationState == .active
|
||||
#elseif os(macOS)
|
||||
return NSApplication.shared.isActive
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
//
|
||||
// VoiceCaptureSession.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 Foundation
|
||||
|
||||
/// Capture backend behind the composer's hold-to-record gesture.
|
||||
/// `VoiceRecordingViewModel` drives one session per press; the concrete type
|
||||
/// decides *how* audio leaves the device: `VoiceNoteCaptureSession` records a
|
||||
/// note delivered on release (today's behavior), `PTTLiveVoiceSession`
|
||||
/// additionally streams frames live while the button is held.
|
||||
@MainActor
|
||||
protocol VoiceCaptureSession: AnyObject {
|
||||
/// Whether audio is leaving the device in real time while recording —
|
||||
/// drives the composer's LIVE treatment.
|
||||
var isLive: Bool { get }
|
||||
func requestPermission() async -> Bool
|
||||
func start() async throws
|
||||
/// Stops capture and returns the finalized voice-note file, or nil when
|
||||
/// nothing valid was captured.
|
||||
func finish() async -> URL?
|
||||
func cancel() async
|
||||
}
|
||||
|
||||
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
|
||||
@MainActor
|
||||
final class VoiceNoteCaptureSession: VoiceCaptureSession {
|
||||
var isLive: Bool { false }
|
||||
|
||||
func requestPermission() async -> Bool {
|
||||
await VoiceRecorder.shared.requestPermission()
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
try await VoiceRecorder.shared.startRecording()
|
||||
}
|
||||
|
||||
func finish() async -> URL? {
|
||||
await VoiceRecorder.shared.stopRecording()
|
||||
}
|
||||
|
||||
func cancel() async {
|
||||
await VoiceRecorder.shared.cancelRecording()
|
||||
}
|
||||
}
|
||||
|
||||
/// Live push-to-talk backend: streams `VoiceBurstPacket`s to one peer while
|
||||
/// recording, then finalizes the same audio as a standard voice note whose
|
||||
/// file name carries the burst ID (`voice_<burstID>.m4a`) so receivers that
|
||||
/// heard the live stream absorb the note silently instead of seeing a
|
||||
/// duplicate.
|
||||
@MainActor
|
||||
final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
let burstID: Data
|
||||
|
||||
private let sendPacket: (Data) -> Void
|
||||
private let capture = PTTCaptureEngine()
|
||||
/// Capture-queue-confined stream state: packetizes frames and lazily
|
||||
/// emits START so packet order is guaranteed by queue serialization.
|
||||
private final class StreamState {
|
||||
var packetizer: VoiceBurstPacketizer
|
||||
var sentStart = false
|
||||
init(burstID: Data) {
|
||||
packetizer = VoiceBurstPacketizer(burstID: burstID)
|
||||
}
|
||||
}
|
||||
private let stream: StreamState
|
||||
private var startDate: Date?
|
||||
private var completed = false
|
||||
|
||||
var isLive: Bool { true }
|
||||
|
||||
/// - Parameter sendPacket: delivers one encoded `VoiceBurstPacket` to the
|
||||
/// target peer; must be safe to call from any queue (BLEService hops to
|
||||
/// its own message queue internally).
|
||||
init(sendPacket: @escaping (Data) -> Void) {
|
||||
self.burstID = VoiceBurstPacket.makeBurstID()
|
||||
self.sendPacket = sendPacket
|
||||
self.stream = StreamState(burstID: burstID)
|
||||
}
|
||||
|
||||
func requestPermission() async -> Bool {
|
||||
await VoiceRecorder.shared.requestPermission()
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
let outputURL = try Self.makeOutputURL(burstID: burstID)
|
||||
let sendPacket = sendPacket
|
||||
let stream = stream
|
||||
capture.onFrames = { frames in
|
||||
if !stream.sentStart {
|
||||
stream.sentStart = true
|
||||
if let start = VoiceBurstPacket(
|
||||
burstID: stream.packetizer.burstID,
|
||||
seq: 0,
|
||||
kind: .start(codec: .aacLC16kMono)
|
||||
) {
|
||||
sendPacket(start.encode())
|
||||
}
|
||||
}
|
||||
for frame in frames {
|
||||
for packet in stream.packetizer.add(frame) {
|
||||
sendPacket(packet)
|
||||
}
|
||||
}
|
||||
// Flush per callback batch: at ~130-byte frames the budget fits
|
||||
// one frame per packet anyway, and holding residue would add
|
||||
// ~100 ms of avoidable latency.
|
||||
for packet in stream.packetizer.flush() {
|
||||
sendPacket(packet)
|
||||
}
|
||||
}
|
||||
do {
|
||||
try capture.start(outputURL: outputURL)
|
||||
} catch {
|
||||
// The HAL can briefly report a dead input right after the audio
|
||||
// session (re)activates while the route settles; one retry after
|
||||
// a short pause covers it (observed on iPhone field tests).
|
||||
SecureLogger.warning("PTT: capture start failed (\(error)) — retrying once after route settle", category: .session)
|
||||
try? await Task.sleep(nanoseconds: 150_000_000)
|
||||
try capture.start(outputURL: outputURL)
|
||||
}
|
||||
startDate = Date()
|
||||
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) capture started", category: .session)
|
||||
}
|
||||
|
||||
func finish() async -> URL? {
|
||||
guard !completed else { return nil }
|
||||
completed = true
|
||||
|
||||
let elapsed = startDate.map { Date().timeIntervalSince($0) } ?? 0
|
||||
let (url, encodedFrames) = capture.stop()
|
||||
// stop() drained the capture queue, so touching `stream` is safe now.
|
||||
|
||||
guard elapsed >= VoiceRecorder.minRecordingDuration, let url else {
|
||||
sendControlPacket(.canceled)
|
||||
if let url {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for packet in stream.packetizer.flush() {
|
||||
sendPacket(packet)
|
||||
}
|
||||
let durationMs = UInt32((Double(encodedFrames) * PTTAudioFormat.frameDuration * 1000).rounded())
|
||||
sendControlPacket(.end(totalDataPackets: stream.packetizer.dataPacketCount, durationMs: durationMs))
|
||||
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) finished — \(stream.packetizer.dataPacketCount) data packets, \(encodedFrames) frames, \(durationMs) ms", category: .session)
|
||||
return url
|
||||
}
|
||||
|
||||
func cancel() async {
|
||||
guard !completed else { return }
|
||||
completed = true
|
||||
capture.cancel()
|
||||
sendControlPacket(.canceled)
|
||||
}
|
||||
|
||||
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
|
||||
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
|
||||
sendPacket(packet.encode())
|
||||
}
|
||||
|
||||
private static func makeOutputURL(burstID: Data) throws -> URL {
|
||||
let base = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let directory = base
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
.appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
return directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a")
|
||||
}
|
||||
}
|
||||
@@ -181,35 +181,23 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
}
|
||||
}
|
||||
|
||||
/// Something that can hold the app's single audio-playback slot and yield it
|
||||
/// when another playback starts (voice notes pause; live bursts stop).
|
||||
protocol ExclusivePlayback: AnyObject {
|
||||
func pauseForExclusivity()
|
||||
}
|
||||
|
||||
extension VoiceNotePlaybackController: ExclusivePlayback {
|
||||
func pauseForExclusivity() {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures only one voice playback (note or live burst) runs at a time.
|
||||
/// Ensures only one voice note plays at a time.
|
||||
final class VoiceNotePlaybackCoordinator {
|
||||
static let shared = VoiceNotePlaybackCoordinator()
|
||||
|
||||
private weak var activeController: (any ExclusivePlayback)?
|
||||
private weak var activeController: VoiceNotePlaybackController?
|
||||
|
||||
private init() {}
|
||||
|
||||
func activate(_ controller: any ExclusivePlayback) {
|
||||
func activate(_ controller: VoiceNotePlaybackController) {
|
||||
if activeController === controller {
|
||||
return
|
||||
}
|
||||
activeController?.pauseForExclusivity()
|
||||
activeController?.pause()
|
||||
activeController = controller
|
||||
}
|
||||
|
||||
func deactivate(_ controller: any ExclusivePlayback) {
|
||||
func deactivate(_ controller: VoiceNotePlaybackController) {
|
||||
if activeController === controller {
|
||||
activeController = nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import AVFoundation
|
||||
actor VoiceRecorder {
|
||||
enum RecorderError: Error {
|
||||
case microphoneAccessDenied
|
||||
case recorderInitializationFailed
|
||||
case recordingInProgress
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,12 @@ final class WaveformCache {
|
||||
}
|
||||
}
|
||||
|
||||
func purgeAll() {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.cache.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
||||
guard bins > 0 else { return nil }
|
||||
// Use autoreleasepool to manage memory from audio buffer allocations
|
||||
|
||||
@@ -88,6 +88,8 @@ import BitFoundation
|
||||
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
||||
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
||||
struct EphemeralIdentity {
|
||||
let peerID: PeerID // 8 random bytes
|
||||
let sessionStart: Date
|
||||
var handshakeState: HandshakeState
|
||||
}
|
||||
|
||||
@@ -96,6 +98,7 @@ enum HandshakeState {
|
||||
case initiated
|
||||
case inProgress
|
||||
case completed(fingerprint: String)
|
||||
case failed(reason: String)
|
||||
}
|
||||
|
||||
/// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair.
|
||||
@@ -107,6 +110,7 @@ struct CryptographicIdentity: Codable {
|
||||
// Optional Ed25519 signing public key (used to authenticate public messages)
|
||||
var signingPublicKey: Data? = nil
|
||||
let firstSeen: Date
|
||||
let lastHandshake: Date?
|
||||
}
|
||||
|
||||
/// Represents the social layer of identity - user-assigned names and trust relationships.
|
||||
@@ -122,37 +126,13 @@ struct SocialIdentity: Codable {
|
||||
var notes: String?
|
||||
}
|
||||
|
||||
/// Trust ladder: unknown → casual → vouched → trusted → verified.
|
||||
///
|
||||
/// Persistence compatibility: `TrustLevel` is stored by its *String* raw
|
||||
/// value ("unknown", "casual", …), not by ordinal position, so inserting
|
||||
/// `vouched` mid-ladder cannot corrupt previously persisted values — every
|
||||
/// pre-existing case keeps the exact raw value it was written with. The
|
||||
/// `vouched` tier is additionally never persisted into `SocialIdentity`
|
||||
/// (it's recomputed on read from stored vouches), so downgraded builds never
|
||||
/// encounter the unfamiliar raw value.
|
||||
enum TrustLevel: String, Codable {
|
||||
case unknown
|
||||
case casual
|
||||
/// Transitively trusted: vouched for by at least one peer *I* verified.
|
||||
/// Derived at read time — never written to persistent storage.
|
||||
case vouched
|
||||
case trusted
|
||||
case verified
|
||||
}
|
||||
|
||||
// MARK: - Vouching (transitive verification)
|
||||
|
||||
/// One accepted vouch: a peer I verified (the voucher) attested that they
|
||||
/// verified the vouchee. Validity is recomputed on read — a record only
|
||||
/// counts while its voucher remains in `verifiedFingerprints` and its
|
||||
/// timestamp is within `VouchAttestation.maxAge` — so unverifying a voucher
|
||||
/// silently invalidates the vouches they gave without a cascade delete.
|
||||
struct VouchRecord: Codable, Equatable {
|
||||
let voucherFingerprint: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
// MARK: - Identity Cache
|
||||
|
||||
/// Persistent storage for identity mappings and relationships.
|
||||
@@ -174,21 +154,9 @@ struct IdentityCache: Codable {
|
||||
|
||||
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
||||
var blockedNostrPubkeys: Set<String> = []
|
||||
|
||||
// Vouching (transitive verification). All three fields are Optional so
|
||||
// caches persisted before this feature decode cleanly — the synthesized
|
||||
// decoder uses decodeIfPresent for optionals, and a missing key must not
|
||||
// trip the "unreadable cache" recovery path that discards everything.
|
||||
|
||||
// Vouchee fingerprint -> accepted vouches (capped per vouchee)
|
||||
var vouchesByVouchee: [String: [VouchRecord]]? = nil
|
||||
|
||||
// Peer fingerprint -> when we last sent them a vouch batch (rate limit)
|
||||
var vouchBatchSentAt: [String: Date]? = nil
|
||||
|
||||
// Fingerprint -> when we verified it (orders outgoing vouch batches;
|
||||
// entries verified before this field exists sort as oldest)
|
||||
var verifiedAt: [String: Date]? = nil
|
||||
|
||||
// Schema version for future migrations
|
||||
var version: Int = 1
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -108,6 +108,8 @@ protocol SecureIdentityStateManagerProtocol {
|
||||
func updateSocialIdentity(_ identity: SocialIdentity)
|
||||
|
||||
// MARK: Favorites Management
|
||||
func getFavorites() -> Set<String>
|
||||
func setFavorite(_ fingerprint: String, isFavorite: Bool)
|
||||
func isFavorite(fingerprint: String) -> Bool
|
||||
|
||||
// MARK: Blocked Users Management
|
||||
@@ -121,7 +123,8 @@ protocol SecureIdentityStateManagerProtocol {
|
||||
|
||||
// MARK: Ephemeral Session Management
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
||||
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
|
||||
|
||||
// MARK: Cleanup
|
||||
func clearAllIdentityData()
|
||||
func removeEphemeralSession(peerID: PeerID)
|
||||
@@ -130,16 +133,6 @@ protocol SecureIdentityStateManagerProtocol {
|
||||
func setVerified(fingerprint: String, verified: Bool)
|
||||
func isVerified(fingerprint: String) -> Bool
|
||||
func getVerifiedFingerprints() -> Set<String>
|
||||
|
||||
// MARK: Vouching (transitive verification)
|
||||
@discardableResult
|
||||
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
|
||||
func validVouchers(for fingerprint: String) -> [VouchRecord]
|
||||
func isVouched(fingerprint: String) -> Bool
|
||||
func lastVouchBatchSent(to fingerprint: String) -> Date?
|
||||
func markVouchBatchSent(to fingerprint: String, at date: Date)
|
||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
|
||||
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
|
||||
}
|
||||
|
||||
/// Singleton manager for secure identity state persistence and retrieval.
|
||||
@@ -318,13 +311,21 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
fingerprint: fingerprint,
|
||||
publicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
|
||||
firstSeen: existing.firstSeen
|
||||
firstSeen: existing.firstSeen,
|
||||
lastHandshake: now
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = existing
|
||||
} else {
|
||||
// Update signing key
|
||||
// Update signing key and lastHandshake
|
||||
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
||||
self.cryptographicIdentities[fingerprint] = existing
|
||||
let updated = CryptographicIdentity(
|
||||
fingerprint: existing.fingerprint,
|
||||
publicKey: existing.publicKey,
|
||||
signingPublicKey: existing.signingPublicKey,
|
||||
firstSeen: existing.firstSeen,
|
||||
lastHandshake: now
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = updated
|
||||
}
|
||||
// Persist updated state (already assigned in branches above)
|
||||
} else {
|
||||
@@ -333,7 +334,8 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
fingerprint: fingerprint,
|
||||
publicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey,
|
||||
firstSeen: now
|
||||
firstSeen: now,
|
||||
lastHandshake: now
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = entry
|
||||
}
|
||||
@@ -498,7 +500,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions[peerID] = EphemeralIdentity(handshakeState: handshakeState)
|
||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
||||
peerID: peerID,
|
||||
sessionStart: Date(),
|
||||
handshakeState: handshakeState
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,20 +550,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
queue.async(flags: .barrier) {
|
||||
if verified {
|
||||
self.cache.verifiedFingerprints.insert(fingerprint)
|
||||
var verifiedAt = self.cache.verifiedAt ?? [:]
|
||||
verifiedAt[fingerprint] = Date()
|
||||
self.cache.verifiedAt = verifiedAt
|
||||
} else {
|
||||
self.cache.verifiedFingerprints.remove(fingerprint)
|
||||
self.cache.verifiedAt?.removeValue(forKey: fingerprint)
|
||||
}
|
||||
|
||||
|
||||
// Update trust level if social identity exists
|
||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||
identity.trustLevel = verified ? .verified : .casual
|
||||
self.cache.socialIdentities[fingerprint] = identity
|
||||
}
|
||||
|
||||
|
||||
self.saveIdentityCache()
|
||||
}
|
||||
}
|
||||
@@ -574,159 +576,6 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Vouching (transitive verification)
|
||||
|
||||
/// Maximum vouchers retained per vouchee (most recent kept).
|
||||
static let maxVouchersPerVouchee = 8
|
||||
|
||||
/// Records an accepted vouch, enforcing every accept-policy gate that can
|
||||
/// be evaluated against stored state (signature verification is the
|
||||
/// caller's job — it needs the sender's announce-bound signing key):
|
||||
/// - the voucher must be a fingerprint *I* verified
|
||||
/// - self-vouches are ignored
|
||||
/// - vouches for peers I already verified are ignored (nothing to add)
|
||||
/// - attestations outside the validity window are ignored
|
||||
/// - at most `maxVouchersPerVouchee` vouchers are kept per vouchee
|
||||
///
|
||||
/// Returns true when the vouch was stored (or refreshed).
|
||||
@discardableResult
|
||||
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
|
||||
recordVouch(
|
||||
voucheeFingerprint: voucheeFingerprint,
|
||||
voucherFingerprint: voucherFingerprint,
|
||||
timestamp: timestamp,
|
||||
now: Date()
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date, now: Date) -> Bool {
|
||||
queue.sync(flags: .barrier) {
|
||||
guard voucheeFingerprint != voucherFingerprint,
|
||||
self.cache.verifiedFingerprints.contains(voucherFingerprint),
|
||||
!self.cache.verifiedFingerprints.contains(voucheeFingerprint) else {
|
||||
return false
|
||||
}
|
||||
let age = now.timeIntervalSince(timestamp)
|
||||
guard age <= VouchAttestation.maxAge, age >= -VouchAttestation.maxClockSkew else {
|
||||
return false
|
||||
}
|
||||
|
||||
var records = self.cache.vouchesByVouchee?[voucheeFingerprint] ?? []
|
||||
if let index = records.firstIndex(where: { $0.voucherFingerprint == voucherFingerprint }) {
|
||||
let newest = max(records[index].timestamp, timestamp)
|
||||
records[index] = VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: newest)
|
||||
} else {
|
||||
records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp))
|
||||
}
|
||||
// Keep the most recent vouchers up to the cap.
|
||||
records.sort { $0.timestamp > $1.timestamp }
|
||||
let capped = Array(records.prefix(Self.maxVouchersPerVouchee))
|
||||
guard capped.contains(where: { $0.voucherFingerprint == voucherFingerprint }) else {
|
||||
return false // Full of fresher vouches; nothing changed.
|
||||
}
|
||||
|
||||
var vouches = self.cache.vouchesByVouchee ?? [:]
|
||||
vouches[voucheeFingerprint] = capped
|
||||
self.cache.vouchesByVouchee = vouches
|
||||
self.saveIdentityCache()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// The vouches that currently count for `fingerprint`. Validity is
|
||||
/// recomputed here rather than maintained by cascade deletes: a record
|
||||
/// only counts while its voucher is still verified-by-me and its
|
||||
/// timestamp is within the expiry window.
|
||||
func validVouchers(for fingerprint: String) -> [VouchRecord] {
|
||||
validVouchers(for: fingerprint, now: Date())
|
||||
}
|
||||
|
||||
func validVouchers(for fingerprint: String, now: Date) -> [VouchRecord] {
|
||||
queue.sync {
|
||||
self.validVouchersLocked(for: fingerprint, now: now)
|
||||
}
|
||||
}
|
||||
|
||||
/// Requires `queue`.
|
||||
private func validVouchersLocked(for fingerprint: String, now: Date) -> [VouchRecord] {
|
||||
guard let records = cache.vouchesByVouchee?[fingerprint] else { return [] }
|
||||
return records.filter { record in
|
||||
record.voucherFingerprint != fingerprint
|
||||
&& cache.verifiedFingerprints.contains(record.voucherFingerprint)
|
||||
&& now.timeIntervalSince(record.timestamp) <= VouchAttestation.maxAge
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the peer has at least one valid vouch and no explicit
|
||||
/// verification of ours.
|
||||
func isVouched(fingerprint: String) -> Bool {
|
||||
isVouched(fingerprint: fingerprint, now: Date())
|
||||
}
|
||||
|
||||
func isVouched(fingerprint: String, now: Date) -> Bool {
|
||||
queue.sync {
|
||||
guard !self.cache.verifiedFingerprints.contains(fingerprint) else { return false }
|
||||
return !self.validVouchersLocked(for: fingerprint, now: now).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
/// The trust level to display: explicit verification wins, then the
|
||||
/// persisted level, with `vouched` layered in (derived, never persisted)
|
||||
/// between `casual` and `trusted`.
|
||||
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel {
|
||||
effectiveTrustLevel(for: fingerprint, now: Date())
|
||||
}
|
||||
|
||||
func effectiveTrustLevel(for fingerprint: String, now: Date) -> TrustLevel {
|
||||
queue.sync {
|
||||
if self.cache.verifiedFingerprints.contains(fingerprint) { return .verified }
|
||||
let stored = self.cache.socialIdentities[fingerprint]?.trustLevel ?? .unknown
|
||||
let vouched = !self.validVouchersLocked(for: fingerprint, now: now).isEmpty
|
||||
switch stored {
|
||||
case .verified, .trusted:
|
||||
return stored
|
||||
case .vouched, .casual, .unknown:
|
||||
if vouched { return .vouched }
|
||||
// `.vouched` should never be persisted; degrade defensively.
|
||||
return stored == .vouched ? .casual : stored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lastVouchBatchSent(to fingerprint: String) -> Date? {
|
||||
queue.sync { cache.vouchBatchSentAt?[fingerprint] }
|
||||
}
|
||||
|
||||
func markVouchBatchSent(to fingerprint: String, at date: Date) {
|
||||
queue.async(flags: .barrier) {
|
||||
var sentAt = self.cache.vouchBatchSentAt ?? [:]
|
||||
sentAt[fingerprint] = date
|
||||
self.cache.vouchBatchSentAt = sentAt
|
||||
self.saveIdentityCache()
|
||||
}
|
||||
}
|
||||
|
||||
/// The peer's announce-bound Ed25519 signing key, if seen this session.
|
||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||
queue.sync { cryptographicIdentities[fingerprint]?.signingPublicKey }
|
||||
}
|
||||
|
||||
/// Verified fingerprints ordered most recently verified first (entries
|
||||
/// without a recorded verification time sort last), excluding the given
|
||||
/// fingerprint. Feeds the outgoing vouch batch.
|
||||
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
|
||||
queue.sync {
|
||||
let verifiedAt = cache.verifiedAt ?? [:]
|
||||
let ordered = cache.verifiedFingerprints
|
||||
.filter { $0 != fingerprint }
|
||||
.sorted {
|
||||
(verifiedAt[$0] ?? .distantPast, $0) > (verifiedAt[$1] ?? .distantPast, $1)
|
||||
}
|
||||
return Array(ordered.prefix(limit))
|
||||
}
|
||||
}
|
||||
|
||||
var debugNicknameIndex: [String: Set<String>] {
|
||||
queue.sync { cache.nicknameIndex }
|
||||
}
|
||||
|
||||
@@ -33,12 +33,18 @@
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_bitchat-bulk._tcp</string>
|
||||
</array>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>bitchat uses peer-to-peer Wi-Fi to transfer large photos and voice notes directly between nearby devices.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
|
||||
@@ -13,6 +13,13 @@ extension BitchatMessage {
|
||||
enum Media {
|
||||
case voice(URL)
|
||||
case image(URL)
|
||||
|
||||
var url: URL {
|
||||
switch self {
|
||||
case .voice(let url), .image(let url):
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
||||
|
||||
@@ -7,6 +7,7 @@ struct BitchatPeer: Equatable {
|
||||
let peerID: PeerID // Hex-encoded peer ID
|
||||
let noisePublicKey: Data
|
||||
let nickname: String
|
||||
let lastSeen: Date
|
||||
let isConnected: Bool
|
||||
let isReachable: Bool
|
||||
|
||||
@@ -76,13 +77,14 @@ struct BitchatPeer: Equatable {
|
||||
peerID: PeerID,
|
||||
noisePublicKey: Data,
|
||||
nickname: String,
|
||||
lastSeen _: Date = Date(),
|
||||
lastSeen: Date = Date(),
|
||||
isConnected: Bool = false,
|
||||
isReachable: Bool = false
|
||||
) {
|
||||
self.peerID = peerID
|
||||
self.noisePublicKey = noisePublicKey
|
||||
self.nickname = nickname
|
||||
self.lastSeen = lastSeen
|
||||
self.isConnected = isConnected
|
||||
self.isReachable = isReachable
|
||||
|
||||
|
||||
@@ -16,19 +16,14 @@ enum CommandInfo: String, Identifiable {
|
||||
// suggesting a spelling the processor rejects teaches users dead ends.
|
||||
case block
|
||||
case clear
|
||||
case group
|
||||
case help
|
||||
case hug
|
||||
case message = "msg"
|
||||
case slap
|
||||
case pay
|
||||
case unblock
|
||||
case who
|
||||
case favorite = "fav"
|
||||
case unfavorite = "unfav"
|
||||
case ping
|
||||
case trace
|
||||
case drop
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
@@ -36,14 +31,8 @@ enum CommandInfo: String, Identifiable {
|
||||
|
||||
var placeholder: String? {
|
||||
switch self {
|
||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace:
|
||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||
case .group:
|
||||
return "<" + String(localized: "content.input.group_placeholder") + ">"
|
||||
case .pay:
|
||||
return "<" + String(localized: "content.input.token_placeholder") + ">"
|
||||
case .drop:
|
||||
return "<" + String(localized: "content.input.note_placeholder") + ">"
|
||||
case .clear, .help, .who:
|
||||
return nil
|
||||
}
|
||||
@@ -53,36 +42,24 @@ enum CommandInfo: String, Identifiable {
|
||||
switch self {
|
||||
case .block: String(localized: "content.commands.block")
|
||||
case .clear: String(localized: "content.commands.clear")
|
||||
case .group: String(localized: "content.commands.group")
|
||||
case .help: String(localized: "content.commands.help")
|
||||
case .hug: String(localized: "content.commands.hug")
|
||||
case .message: String(localized: "content.commands.message")
|
||||
case .pay: String(localized: "content.commands.pay")
|
||||
case .slap: String(localized: "content.commands.slap")
|
||||
case .unblock: String(localized: "content.commands.unblock")
|
||||
case .who: String(localized: "content.commands.who")
|
||||
case .favorite: String(localized: "content.commands.favorite")
|
||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
||||
case .ping: String(localized: "content.commands.ping")
|
||||
case .trace: String(localized: "content.commands.trace")
|
||||
case .drop: String(localized: "content.commands.drop")
|
||||
}
|
||||
}
|
||||
|
||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
||||
var commands: [CommandInfo] = [.block, .unblock, .clear, .drop, .help, .hug, .message, .slap, .who]
|
||||
// Cashu tokens are bearer instruments: in a public geohash any nearby
|
||||
// stranger can redeem one, so don't *suggest* /pay there (the
|
||||
// processor still allows it behind an explicit "public" confirm).
|
||||
// Payments make sense in every DM and in mesh public.
|
||||
if !isGeoPublic {
|
||||
commands.append(.pay)
|
||||
}
|
||||
// The processor rejects favorites, groups, and mesh diagnostics in
|
||||
// geohash contexts, so only suggest them where they work: mesh.
|
||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
|
||||
// The processor rejects favorites in geohash contexts, so only
|
||||
// suggest them where they actually work: mesh.
|
||||
if isGeoPublic || isGeoDM {
|
||||
return commands
|
||||
return baseCommands
|
||||
}
|
||||
return commands + [.favorite, .unfavorite, .ping, .trace, .group]
|
||||
return baseCommands + [.favorite, .unfavorite]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
// REQUEST_SYNC payload TLV (type, length16, value)
|
||||
// - 0x01: P (uint8) — Golomb-Rice parameter
|
||||
// - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
||||
// - 0x03: data (opaque) — GR bitstream bytes (MSB-first)
|
||||
// - 0x04: types (SyncTypeFlags) — packet types the filter covers
|
||||
// - 0x05: sinceTimestamp (uint64, big-endian) — filter coverage cursor
|
||||
// - 0x06: fragmentIdFilter (UTF-8) — comma-separated 16-hex-char (8-byte)
|
||||
// fragment stream IDs; restricts the fragment diff to exactly those
|
||||
// streams (targeted resync for stalled reassemblies)
|
||||
struct RequestSyncPacket {
|
||||
/// Maximum fragment IDs one 0x06 filter may carry. Each ID encodes as
|
||||
/// 16 hex chars plus a comma separator, so the largest encoded value is
|
||||
/// 60 * 17 - 1 = 1019 bytes, which fits the 1024-byte decoder cap.
|
||||
static let maxFragmentIdFilterCount = 60
|
||||
|
||||
let p: Int
|
||||
let m: UInt32
|
||||
let data: Data
|
||||
@@ -23,29 +12,6 @@ struct RequestSyncPacket {
|
||||
let sinceTimestamp: UInt64?
|
||||
let fragmentIdFilter: String?
|
||||
|
||||
/// Encodes 8-byte fragment stream IDs as the 0x06 filter string,
|
||||
/// dropping malformed IDs and capping at `maxFragmentIdFilterCount`.
|
||||
static func encodeFragmentIdFilter(_ fragmentIDs: [Data]) -> String? {
|
||||
let tokens = fragmentIDs
|
||||
.filter { $0.count == 8 }
|
||||
.prefix(maxFragmentIdFilterCount)
|
||||
.map { $0.hexEncodedString() }
|
||||
guard !tokens.isEmpty else { return nil }
|
||||
return tokens.joined(separator: ",")
|
||||
}
|
||||
|
||||
/// Decodes a 0x06 filter string back into 8-byte fragment stream IDs,
|
||||
/// ignoring malformed tokens and capping at `maxFragmentIdFilterCount`.
|
||||
static func decodeFragmentIdFilter(_ filter: String?) -> Set<Data>? {
|
||||
guard let filter else { return nil }
|
||||
var ids: Set<Data> = []
|
||||
for token in filter.split(separator: ",").prefix(maxFragmentIdFilterCount) {
|
||||
guard token.count == 16, let id = Data(hexString: String(token)) else { continue }
|
||||
ids.insert(id)
|
||||
}
|
||||
return ids.isEmpty ? nil : ids
|
||||
}
|
||||
|
||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
|
||||
self.p = p
|
||||
self.m = m
|
||||
@@ -122,9 +88,7 @@ struct RequestSyncPacket {
|
||||
sinceTimestamp = ts
|
||||
}
|
||||
case 0x06:
|
||||
// Same acceptance cap as the GCS payload; an oversized filter
|
||||
// is ignored rather than failing the whole request.
|
||||
if v.count <= maxAcceptBytes, let fid = String(data: v, encoding: .utf8) {
|
||||
if let fid = String(data: v, encoding: .utf8) {
|
||||
fragmentIdFilter = fid
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -723,7 +723,7 @@ final class NoiseHandshakeState {
|
||||
return messageBuffer
|
||||
}
|
||||
|
||||
func readMessage(_ message: Data, expectedPayloadLength _: Int = 0) throws -> Data {
|
||||
func readMessage(_ message: Data, expectedPayloadLength: Int = 0) throws -> Data {
|
||||
|
||||
guard currentPattern < messagePatterns.count else {
|
||||
throw NoiseError.handshakeComplete
|
||||
|
||||
@@ -21,6 +21,12 @@ enum NoiseSecurityConstants {
|
||||
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
||||
|
||||
// Handshake timeout - abandon incomplete handshakes
|
||||
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
||||
|
||||
// Maximum concurrent sessions per peer
|
||||
static let maxSessionsPerPeer = 3
|
||||
|
||||
// Rate limiting
|
||||
static let maxHandshakesPerMinute = 10
|
||||
static let maxMessagesPerSecond = 100
|
||||
|
||||
@@ -14,4 +14,5 @@ enum NoiseSecurityError: Error {
|
||||
case messageTooLarge
|
||||
case invalidPeerID
|
||||
case rateLimitExceeded
|
||||
case handshakeTimeout
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import BitFoundation
|
||||
|
||||
final class NoiseSessionManager {
|
||||
private var sessions: [PeerID: NoiseSession] = [:]
|
||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||
|
||||
@@ -21,6 +23,8 @@ final class NoiseSessionManager {
|
||||
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||
|
||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||
self.localStaticKey = localStaticKey
|
||||
self.keychain = keychain
|
||||
self.sessionFactory = { peerID, role in
|
||||
SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
@@ -33,10 +37,12 @@ final class NoiseSessionManager {
|
||||
|
||||
#if DEBUG
|
||||
init(
|
||||
localStaticKey _: Curve25519.KeyAgreement.PrivateKey,
|
||||
keychain _: KeychainManagerProtocol,
|
||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
keychain: KeychainManagerProtocol,
|
||||
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
|
||||
) {
|
||||
self.localStaticKey = localStaticKey
|
||||
self.keychain = keychain
|
||||
self.sessionFactory = sessionFactory
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -6,12 +6,14 @@ struct NostrIdentity: Codable {
|
||||
let privateKey: Data
|
||||
let publicKey: Data
|
||||
let npub: String // Bech32-encoded public key
|
||||
|
||||
let createdAt: Date
|
||||
|
||||
/// Memberwise initializer
|
||||
init(privateKey: Data, publicKey: Data, npub: String, createdAt _: Date) {
|
||||
init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) {
|
||||
self.privateKey = privateKey
|
||||
self.publicKey = publicKey
|
||||
self.npub = npub
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
/// Generate a new Nostr identity
|
||||
@@ -37,6 +39,12 @@ struct NostrIdentity: Codable {
|
||||
self.privateKey = privateKeyData
|
||||
self.publicKey = xOnlyPubkey
|
||||
self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
|
||||
self.createdAt = Date()
|
||||
}
|
||||
|
||||
/// Get signing key for event signatures
|
||||
func signingKey() throws -> P256K.Signing.PrivateKey {
|
||||
try P256K.Signing.PrivateKey(dataRepresentation: privateKey)
|
||||
}
|
||||
|
||||
/// Get Schnorr signing key for Nostr event signatures
|
||||
|
||||
@@ -15,7 +15,7 @@ final class NostrIdentityBridge {
|
||||
|
||||
private let keychain: KeychainManagerProtocol
|
||||
|
||||
init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) {
|
||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
@@ -37,6 +37,14 @@ final class NostrIdentityBridge {
|
||||
return nostrIdentity
|
||||
}
|
||||
|
||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
||||
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
if let data = nostrPubkey.data(using: .utf8) {
|
||||
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Nostr public key associated with a Noise public key
|
||||
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
@@ -49,10 +57,29 @@ final class NostrIdentityBridge {
|
||||
|
||||
/// Clear all Nostr identity associations and current identity
|
||||
func clearAllAssociations() {
|
||||
// Must go through the injected keychain, not raw SecItem calls:
|
||||
// under test that keychain is in-memory, and a direct delete here
|
||||
// would wipe the developer's real Nostr identity on every test run.
|
||||
keychain.deleteAll(service: keychainService)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
kSecReturnAttributes as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||
for item in items {
|
||||
var deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService
|
||||
]
|
||||
if let account = item[kSecAttrAccount as String] as? String {
|
||||
deleteQuery[kSecAttrAccount as String] = account
|
||||
}
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
}
|
||||
} else if status == errSecItemNotFound {
|
||||
// nothing persisted; no action needed
|
||||
}
|
||||
|
||||
deviceSeedCache = nil
|
||||
// Also drop the in-memory derived per-geohash identities. These hold the
|
||||
@@ -86,13 +113,6 @@ final class NostrIdentityBridge {
|
||||
return seed
|
||||
}
|
||||
|
||||
/// Derive a deterministic, unlinkable Nostr identity for a mesh-bridge
|
||||
/// rendezvous cell. Distinct HMAC label keeps it unlinkable from the
|
||||
/// geohash-chat identity for the same cell string.
|
||||
func deriveIdentity(forBridgeRendezvous cell: String) throws -> NostrIdentity {
|
||||
try deriveIdentity(forGeohash: "bridge|" + cell)
|
||||
}
|
||||
|
||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||
/// if the candidate is not a valid secp256k1 private key.
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import BitFoundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// NIP-13 proof-of-work for Nostr events.
|
||||
///
|
||||
/// Outgoing kind-20000 geohash messages mine a `["nonce", "<value>", "<target>"]`
|
||||
/// tag so the event ID carries at least `target` leading zero bits. Inbound
|
||||
/// events are scored (never hard-rejected — the network has clients that do
|
||||
/// not mine): validated PoW at or above `rateLimitBypassBits` relaxes the
|
||||
/// per-sender public rate limit, everything else keeps the strict limits.
|
||||
enum NostrPoW {
|
||||
|
||||
// MARK: - Tuning
|
||||
|
||||
/// Difficulty (leading zero bits of the event ID) mined onto outgoing
|
||||
/// geohash messages. 8 bits is ~256 hash attempts — typically well under
|
||||
/// 100 ms on any supported device.
|
||||
static let targetBits = 8
|
||||
|
||||
/// Inbound events whose validated NIP-13 difficulty is at least this many
|
||||
/// bits skip the per-sender rate-limit bucket (the content-flood bucket
|
||||
/// still applies). See `MessageRateLimiter.allow`.
|
||||
static let rateLimitBypassBits = 8
|
||||
|
||||
/// Hard cap on mining wall-clock time. When it hits, the committed target
|
||||
/// steps down until a difficulty reachable in a small extra budget is
|
||||
/// found and the message is sent anyway — mining never blocks sending.
|
||||
static let miningTimeCap: TimeInterval = 2.0
|
||||
|
||||
/// Budget for each stepped-down attempt after the main cap (or a task
|
||||
/// cancellation) hits.
|
||||
private static let fallbackTimeCap: TimeInterval = 0.15
|
||||
|
||||
/// The hot loop checks the deadline and task cancellation every this many
|
||||
/// hash attempts.
|
||||
private static let checkInterval: UInt64 = 1024
|
||||
|
||||
/// The nonce value is a fixed-width hex counter so the serialized event
|
||||
/// template can be mutated in place without reallocation.
|
||||
private static let nonceLength = 16
|
||||
|
||||
// MARK: - Scoring
|
||||
|
||||
/// Number of leading zero bits in a byte sequence (NIP-13 difficulty of
|
||||
/// an event-ID hash).
|
||||
static func leadingZeroBits<Bytes: Sequence<UInt8>>(_ bytes: Bytes) -> Int {
|
||||
var total = 0
|
||||
for byte in bytes {
|
||||
if byte == 0 {
|
||||
total += 8
|
||||
} else {
|
||||
total += byte.leadingZeroBitCount
|
||||
break
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/// Validated NIP-13 difficulty of an inbound event.
|
||||
///
|
||||
/// The committed target in the nonce tag is what counts: the actual
|
||||
/// leading zero bits of the ID must meet it (otherwise the claim is void
|
||||
/// and the event scores 0), and work beyond the commitment earns no extra
|
||||
/// credit — this stops spammers who mine a low target from getting lucky
|
||||
/// high scores. Events without a well-formed commitment score 0.
|
||||
static func validatedDifficulty(idHex: String, tags: [[String]]) -> Int {
|
||||
guard let nonceTag = tags.last(where: { $0.first == "nonce" }),
|
||||
nonceTag.count >= 3,
|
||||
let committed = Int(nonceTag[2]),
|
||||
committed > 0, committed <= 256,
|
||||
let idData = Data(hexString: idHex)
|
||||
else {
|
||||
return 0
|
||||
}
|
||||
return leadingZeroBits(idData) >= committed ? committed : 0
|
||||
}
|
||||
|
||||
// MARK: - Mining
|
||||
|
||||
/// Mine a `["nonce", value, target]` tag for the given unsigned-event
|
||||
/// fields. Nonisolated async: runs off the calling actor.
|
||||
///
|
||||
/// Bounded by `miningTimeCap`: when the cap hits — or the surrounding
|
||||
/// task is cancelled — the committed target steps down (halving to 0,
|
||||
/// which any hash satisfies) so the event still ships promptly with an
|
||||
/// honest commitment at the difficulty actually reached. Returns nil only
|
||||
/// if canonical serialization fails; the caller then sends unmined.
|
||||
static func mineNonceTag(
|
||||
pubkey: String,
|
||||
createdAt: Int,
|
||||
kind: Int,
|
||||
tags: [[String]],
|
||||
content: String,
|
||||
targetBits: Int = NostrPoW.targetBits
|
||||
) async -> [String]? {
|
||||
var target = min(max(targetBits, 0), 256)
|
||||
var budget = miningTimeCap
|
||||
while true {
|
||||
if let tag = mineAttempt(
|
||||
pubkey: pubkey,
|
||||
createdAt: createdAt,
|
||||
kind: kind,
|
||||
baseTags: tags,
|
||||
content: content,
|
||||
targetBits: target,
|
||||
budget: budget
|
||||
) {
|
||||
return tag
|
||||
}
|
||||
// Target 0 succeeds on the first hash, so reaching it with nil
|
||||
// means serialization itself failed — give up on mining.
|
||||
if target == 0 { return nil }
|
||||
target /= 2
|
||||
budget = fallbackTimeCap
|
||||
}
|
||||
}
|
||||
|
||||
/// One bounded mining pass at a fixed committed target. Allocation-light:
|
||||
/// the canonical serialization is built once and only the fixed-width
|
||||
/// nonce bytes are rewritten per attempt (the event ID is recomputed for
|
||||
/// every attempt, per NIP-13). Returns nil on timeout/cancellation or if
|
||||
/// the template could not be built.
|
||||
private static func mineAttempt(
|
||||
pubkey: String,
|
||||
createdAt: Int,
|
||||
kind: Int,
|
||||
baseTags: [[String]],
|
||||
content: String,
|
||||
targetBits: Int,
|
||||
budget: TimeInterval
|
||||
) -> [String]? {
|
||||
let targetString = String(targetBits)
|
||||
guard let template = serializedTemplate(
|
||||
pubkey: pubkey,
|
||||
createdAt: createdAt,
|
||||
kind: kind,
|
||||
baseTags: baseTags,
|
||||
content: content,
|
||||
targetString: targetString
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
var buffer = template.buffer
|
||||
let nonceRange = template.nonceRange
|
||||
|
||||
let deadline = DispatchTime.now().uptimeNanoseconds &+ UInt64(budget * 1_000_000_000)
|
||||
let hexDigits = [UInt8]("0123456789abcdef".utf8)
|
||||
var nonce = UInt64.random(in: .min ... .max)
|
||||
var attempts: UInt64 = 0
|
||||
|
||||
while true {
|
||||
// Write the nonce as 16 lowercase hex chars, in place.
|
||||
var value = nonce
|
||||
var index = nonceRange.upperBound
|
||||
while index > nonceRange.lowerBound {
|
||||
index -= 1
|
||||
buffer[index] = hexDigits[Int(value & 0xF)]
|
||||
value >>= 4
|
||||
}
|
||||
|
||||
if leadingZeroBits(SHA256.hash(data: buffer)) >= targetBits {
|
||||
// Identical to the bytes just written into the buffer.
|
||||
return ["nonce", String(format: "%016llx", nonce), targetString]
|
||||
}
|
||||
|
||||
nonce &+= 1
|
||||
attempts &+= 1
|
||||
if attempts % checkInterval == 0,
|
||||
Task.isCancelled || DispatchTime.now().uptimeNanoseconds >= deadline {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical NIP-01 serialization of the event with a placeholder nonce,
|
||||
/// plus the byte range of the nonce value inside it.
|
||||
///
|
||||
/// The range is located by serializing twice with two same-length
|
||||
/// placeholders and diffing the buffers — the only differing bytes are
|
||||
/// the nonce value, so this stays correct however `JSONSerialization`
|
||||
/// escapes the surrounding fields (and even if the content contains the
|
||||
/// placeholder text itself).
|
||||
private static func serializedTemplate(
|
||||
pubkey: String,
|
||||
createdAt: Int,
|
||||
kind: Int,
|
||||
baseTags: [[String]],
|
||||
content: String,
|
||||
targetString: String
|
||||
) -> (buffer: Data, nonceRange: Range<Int>)? {
|
||||
func serialize(noncePlaceholder: String) -> Data? {
|
||||
var tags = baseTags
|
||||
tags.append(["nonce", noncePlaceholder, targetString])
|
||||
let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content]
|
||||
return try? JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||
}
|
||||
|
||||
guard let zeros = serialize(noncePlaceholder: String(repeating: "0", count: nonceLength)),
|
||||
let effs = serialize(noncePlaceholder: String(repeating: "f", count: nonceLength)),
|
||||
zeros.count == effs.count
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var firstDiff = -1
|
||||
var lastDiff = -1
|
||||
for index in 0..<zeros.count where zeros[index] != effs[index] {
|
||||
if firstDiff < 0 { firstDiff = index }
|
||||
lastDiff = index
|
||||
}
|
||||
guard firstDiff >= 0, lastDiff - firstDiff + 1 == nonceLength else { return nil }
|
||||
return (zeros, firstDiff..<(firstDiff + nonceLength))
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,6 @@ struct NostrProtocol {
|
||||
case giftWrap = 1059 // NIP-59 gift wrap
|
||||
case ephemeralEvent = 20000
|
||||
case geohashPresence = 20001
|
||||
case deletion = 5 // NIP-09 event deletion request
|
||||
/// Sealed courier envelope parked on relays under its rotating
|
||||
/// recipient tag (`#x`). Regular (stored) kind so it survives until
|
||||
/// its NIP-40 expiration — the whole point is store-and-forward.
|
||||
case courierDrop = 1401
|
||||
}
|
||||
|
||||
/// Create a NIP-17 private message
|
||||
@@ -175,63 +170,6 @@ struct NostrProtocol {
|
||||
nickname: String? = nil,
|
||||
teleported: Bool = false
|
||||
) throws -> NostrEvent {
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported),
|
||||
content: content
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a kind-20000 geohash message carrying a NIP-13 proof-of-work
|
||||
/// nonce tag (see `NostrPoW`). Mining runs off the calling actor and is
|
||||
/// bounded by `NostrPoW.miningTimeCap`; when the cap hits (or the
|
||||
/// surrounding task is cancelled) the event ships at the highest
|
||||
/// committed difficulty still met, and if mining is impossible it ships
|
||||
/// unmined — sending is never blocked.
|
||||
static func createMinedEphemeralGeohashEvent(
|
||||
content: String,
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
nickname: String? = nil,
|
||||
teleported: Bool = false,
|
||||
powTargetBits: Int = NostrPoW.targetBits
|
||||
) async throws -> NostrEvent {
|
||||
var tags = ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported)
|
||||
// Fix created_at up front: the mined nonce commits to the full
|
||||
// serialized event, so the signed event must reuse the exact value.
|
||||
let createdAt = Int(Date().timeIntervalSince1970)
|
||||
if let nonceTag = await NostrPoW.mineNonceTag(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: createdAt,
|
||||
kind: EventKind.ephemeralEvent.rawValue,
|
||||
tags: tags,
|
||||
content: content,
|
||||
targetBits: powTargetBits
|
||||
) {
|
||||
tags.append(nonceTag)
|
||||
}
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)),
|
||||
kind: .ephemeralEvent,
|
||||
tags: tags,
|
||||
content: content
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Tags for a kind-20000 geohash message (shared by the plain and mined
|
||||
/// variants).
|
||||
private static func ephemeralGeohashTags(
|
||||
geohash: String,
|
||||
nickname: String?,
|
||||
teleported: Bool
|
||||
) -> [[String]] {
|
||||
var tags = [["g", geohash]]
|
||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||
tags.append(["n", nickname])
|
||||
@@ -239,7 +177,15 @@ struct NostrProtocol {
|
||||
if teleported {
|
||||
tags.append(["t", "teleport"])
|
||||
}
|
||||
return tags
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: tags,
|
||||
content: content
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a geohash presence heartbeat (kind 20001)
|
||||
@@ -260,105 +206,17 @@ struct NostrProtocol {
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
// MARK: - Mesh bridge (rendezvous) events
|
||||
|
||||
/// Create a mesh-bridge public message (kind 20000) for a geohash-cell
|
||||
/// rendezvous. The distinct `r` tag keeps bridge traffic out of geohash
|
||||
/// channel subscriptions (which filter on `#g`); `m` carries the original
|
||||
/// mesh message ID so receivers dedup the bridged copy against the radio
|
||||
/// copy by timeline ID.
|
||||
static func createBridgeMeshEvent(
|
||||
content: String,
|
||||
cell: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
nickname: String? = nil,
|
||||
meshMessageID: String? = nil
|
||||
) throws -> NostrEvent {
|
||||
var tags = [["r", cell]]
|
||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||
tags.append(["n", nickname])
|
||||
}
|
||||
if let meshMessageID = meshMessageID?.trimmedOrNilIfEmpty {
|
||||
tags.append(["m", meshMessageID])
|
||||
}
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: tags,
|
||||
content: content
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a mesh-bridge presence heartbeat (kind 20001) on a rendezvous
|
||||
/// cell: empty content, `r` tag only — the bridge analogue of geohash
|
||||
/// presence, counted into "people across the bridge".
|
||||
static func createBridgePresenceEvent(
|
||||
cell: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["r", cell]],
|
||||
content: ""
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a courier drop (kind 1401): an opaque sealed courier envelope
|
||||
/// parked on relays. `x` is the hex recipient tag the recipient (or a
|
||||
/// gateway acting for them) subscribes for; the NIP-40 expiration tracks
|
||||
/// the envelope expiry so honoring relays garbage-collect the drop. The
|
||||
/// signing identity should be a throwaway — the envelope authenticates
|
||||
/// its sender internally via Noise-X, and linking drops to a stable
|
||||
/// publisher key would leak courier traffic patterns.
|
||||
static func createCourierDropEvent(
|
||||
envelope: Data,
|
||||
recipientTagHex: String,
|
||||
expiresAt: Date,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let tags = [
|
||||
["x", recipientTagHex],
|
||||
["expiration", String(Int(expiresAt.timeIntervalSince1970))],
|
||||
]
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .courierDrop,
|
||||
tags: tags,
|
||||
content: envelope.base64EncodedString()
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
||||
/// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays
|
||||
/// drop the note in step with a bridged board post's expiry.
|
||||
static func createGeohashTextNote(
|
||||
content: String,
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
nickname: String? = nil,
|
||||
expiresAt: Date? = nil,
|
||||
urgent: Bool = false
|
||||
nickname: String? = nil
|
||||
) throws -> NostrEvent {
|
||||
var tags = [["g", geohash]]
|
||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||
tags.append(["n", nickname])
|
||||
}
|
||||
if let expiresAt {
|
||||
tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))])
|
||||
}
|
||||
if urgent {
|
||||
tags.append(["t", "urgent"])
|
||||
}
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
@@ -369,25 +227,7 @@ struct NostrProtocol {
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a NIP-09 deletion request for one of our own events. Relays that
|
||||
/// honor NIP-09 drop the referenced event; it must be signed by the same
|
||||
/// key that signed the original.
|
||||
static func createDeleteEvent(
|
||||
ofEventID eventID: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .deletion,
|
||||
tags: [["e", eventID]],
|
||||
content: ""
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private static func createSeal(
|
||||
@@ -634,6 +474,37 @@ struct NostrProtocol {
|
||||
return sharedSecretData
|
||||
}
|
||||
|
||||
// Direct version that doesn't try to add prefixes
|
||||
private static func deriveSharedSecretDirect(
|
||||
privateKey: P256K.Schnorr.PrivateKey,
|
||||
publicKey: Data
|
||||
) throws -> Data {
|
||||
// Direct shared secret calculation
|
||||
|
||||
// Convert Schnorr private key to KeyAgreement private key
|
||||
let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey(
|
||||
dataRepresentation: privateKey.dataRepresentation
|
||||
)
|
||||
|
||||
// Use the public key as-is (should already have prefix)
|
||||
let keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey(
|
||||
dataRepresentation: publicKey,
|
||||
format: .compressed
|
||||
)
|
||||
|
||||
// Perform ECDH
|
||||
let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(
|
||||
with: keyAgreementPublicKey,
|
||||
format: .compressed
|
||||
)
|
||||
|
||||
// Convert SharedSecret to Data
|
||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||
|
||||
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
||||
return sharedSecretData
|
||||
}
|
||||
|
||||
private static func randomizedTimestamp() -> Date {
|
||||
// Add random offset to current time for privacy
|
||||
// This prevents timing correlation attacks while the actual message timestamp
|
||||
@@ -763,8 +634,11 @@ struct NostrEvent: Codable {
|
||||
|
||||
enum NostrError: Error {
|
||||
case invalidPublicKey
|
||||
case invalidPrivateKey
|
||||
case invalidEvent
|
||||
case invalidCiphertext
|
||||
case signingFailed
|
||||
case encryptionFailed
|
||||
}
|
||||
|
||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
||||
|
||||
@@ -117,6 +117,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
let url: String
|
||||
var isConnected: Bool = false
|
||||
var lastError: Error?
|
||||
var lastConnectedAt: Date?
|
||||
var messagesSent: Int = 0
|
||||
var messagesReceived: Int = 0
|
||||
var reconnectAttempts: Int = 0
|
||||
@@ -183,26 +184,11 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
private var subscriptionRequestState: [String: SubscriptionRequestState] = [:]
|
||||
|
||||
// Track EOSE per subscription to signal when initial stored events are
|
||||
// done. Completion is scoped to relays the REQ actually reached: targets
|
||||
// still mid-connect must not hold the callback hostage until the fallback
|
||||
// timer (a dead relay of five used to pin "loading" for the full 10s).
|
||||
// Track EOSE per subscription to signal when initial stored events are done
|
||||
private struct EOSETracker {
|
||||
/// Targets the REQ has not been delivered to yet (still connecting).
|
||||
var awaitingSend: Set<String>
|
||||
/// Relays that received the REQ and have not sent EOSE yet.
|
||||
var awaitingEOSE: Set<String>
|
||||
/// True once any relay received the REQ (or answered with EOSE) —
|
||||
/// completion with zero sends would mean "done" without ever asking.
|
||||
var didSend = false
|
||||
var pendingRelays: Set<String>
|
||||
var callback: () -> Void
|
||||
let epoch: Int
|
||||
|
||||
/// Done when every relay that got the REQ has resolved, provided at
|
||||
/// least one did — or when every target dropped out entirely.
|
||||
var isComplete: Bool {
|
||||
(didSend && awaitingEOSE.isEmpty) || (awaitingSend.isEmpty && awaitingEOSE.isEmpty)
|
||||
}
|
||||
}
|
||||
private var eoseTrackers: [String: EOSETracker] = [:]
|
||||
private var eoseTrackerEpoch = 0
|
||||
@@ -361,6 +347,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
relays[index].nextReconnectTime = nil
|
||||
if resetState {
|
||||
relays[index].lastError = nil
|
||||
relays[index].lastConnectedAt = nil
|
||||
relays[index].lastDisconnectedAt = nil
|
||||
relays[index].messagesSent = 0
|
||||
relays[index].messagesReceived = 0
|
||||
@@ -808,7 +795,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) {
|
||||
eoseTrackerEpoch += 1
|
||||
let epoch = eoseTrackerEpoch
|
||||
eoseTrackers[id] = EOSETracker(awaitingSend: relayURLs, awaitingEOSE: [], callback: callback, epoch: epoch)
|
||||
eoseTrackers[id] = EOSETracker(pendingRelays: relayURLs, callback: callback, epoch: epoch)
|
||||
// Fallback timeout to avoid hanging if a relay never sends EOSE.
|
||||
dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
@@ -948,19 +935,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
toSend[id] = state.messageString
|
||||
}
|
||||
for (id, messageString) in toSend {
|
||||
if self.subscriptions[relayUrl]?.contains(id) == true {
|
||||
// Already subscribed on this relay (e.g. a tracker promoted
|
||||
// after an earlier flush): its EOSE is coming, count it.
|
||||
markEOSESubscribed(id: id, relayUrl: relayUrl)
|
||||
continue
|
||||
}
|
||||
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
|
||||
startPendingEOSETrackingIfNeeded(id: id)
|
||||
// Mark at send *initiation*, not in the async completion: a fast
|
||||
// relay's EOSE could otherwise complete the tracker while this
|
||||
// relay — REQ already on the wire — still sat in awaitingSend.
|
||||
// If the send fails the socket is going down with it, and the
|
||||
// disconnect settle (or the fallback timer) releases the wait.
|
||||
markEOSESubscribed(id: id, relayUrl: relayUrl)
|
||||
connection.send(.string(messageString)) { [weak self, weak connection] error in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -1042,12 +1018,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
case .eose(let subId):
|
||||
if var tracker = eoseTrackers[subId] {
|
||||
// An EOSE proves the relay received the REQ even if the local
|
||||
// send completion hasn't run yet.
|
||||
tracker.awaitingSend.remove(relayUrl)
|
||||
tracker.awaitingEOSE.remove(relayUrl)
|
||||
tracker.didSend = true
|
||||
if tracker.isComplete {
|
||||
tracker.pendingRelays.remove(relayUrl)
|
||||
if tracker.pendingRelays.isEmpty {
|
||||
eoseTrackers.removeValue(forKey: subId)
|
||||
tracker.callback()
|
||||
} else {
|
||||
@@ -1103,6 +1075,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
relays[index].isConnected = isConnected
|
||||
relays[index].lastError = error
|
||||
if isConnected {
|
||||
relays[index].lastConnectedAt = dependencies.now()
|
||||
relays[index].reconnectAttempts = 0 // Reset on successful connection
|
||||
relays[index].nextReconnectTime = nil
|
||||
} else {
|
||||
@@ -1127,11 +1100,9 @@ final class NostrRelayManager: ObservableObject {
|
||||
/// callbacks; treat it as done and let the remaining relays (or the
|
||||
/// fallback timeout) drive completion.
|
||||
private func settleEOSETrackers(droppingRelay relayUrl: String) {
|
||||
for (id, var tracker) in eoseTrackers
|
||||
where tracker.awaitingSend.contains(relayUrl) || tracker.awaitingEOSE.contains(relayUrl) {
|
||||
tracker.awaitingSend.remove(relayUrl)
|
||||
tracker.awaitingEOSE.remove(relayUrl)
|
||||
if tracker.isComplete {
|
||||
for (id, var tracker) in eoseTrackers where tracker.pendingRelays.contains(relayUrl) {
|
||||
tracker.pendingRelays.remove(relayUrl)
|
||||
if tracker.pendingRelays.isEmpty {
|
||||
eoseTrackers.removeValue(forKey: id)
|
||||
tracker.callback()
|
||||
} else {
|
||||
@@ -1140,24 +1111,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any of `relayUrls` currently holds a live connection. Lets
|
||||
/// subscribers distinguish "loaded, empty" from "never reached a relay"
|
||||
/// when an EOSE fallback fires.
|
||||
func isAnyRelayConnected(among relayUrls: [String]) -> Bool {
|
||||
let targets = Set(relayUrls)
|
||||
return relays.contains { targets.contains($0.url) && $0.isConnected }
|
||||
}
|
||||
|
||||
/// Marks the REQ as delivered to `relayUrl`: EOSE completion now waits on
|
||||
/// this relay instead of the never-connected remainder.
|
||||
private func markEOSESubscribed(id: String, relayUrl: String) {
|
||||
guard var tracker = eoseTrackers[id],
|
||||
tracker.awaitingSend.remove(relayUrl) != nil else { return }
|
||||
tracker.awaitingEOSE.insert(relayUrl)
|
||||
tracker.didSend = true
|
||||
eoseTrackers[id] = tracker
|
||||
}
|
||||
|
||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
subscriptions.removeValue(forKey: relayUrl)
|
||||
@@ -1510,29 +1463,6 @@ struct NostrFilter: Encodable {
|
||||
filter.limit = limit
|
||||
return filter
|
||||
}
|
||||
|
||||
// For the mesh bridge: rendezvous messages (kind 20000) and presence
|
||||
// (kind 20001) tagged `#r` with one or more cells (own + neighbors).
|
||||
static func bridgeRendezvous(_ cells: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [20000, 20001]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["r": cells]
|
||||
filter.limit = limit
|
||||
return filter
|
||||
}
|
||||
|
||||
// For courier drops: sealed envelopes (kind 1401) parked under rotating
|
||||
// recipient tags (`#x`, hex). Callers pass every candidate tag (adjacent
|
||||
// UTC days x recipients) in one filter.
|
||||
static func courierDrops(recipientTagsHex: [String], since: Date? = nil, limit: Int = 100) -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [NostrProtocol.EventKind.courierDrop.rawValue]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["x": recipientTagsHex]
|
||||
filter.limit = limit
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic coding key for tag filters
|
||||
|
||||
@@ -28,12 +28,14 @@ struct BitchatFilePacket {
|
||||
|
||||
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
||||
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
||||
func encode() -> Data? {
|
||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
||||
/// `FileTransferLimits.maxWifiBulkPayloadBytes`.
|
||||
func encode(limit: Int = FileTransferLimits.maxPayloadBytes) -> Data? {
|
||||
let resolvedSize = fileSize ?? UInt64(content.count)
|
||||
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
||||
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
|
||||
guard resolvedSize <= UInt64(limit) else { return nil }
|
||||
guard content.count <= Int(UInt32.max) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
||||
|
||||
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||
var big = value.bigEndian
|
||||
@@ -66,7 +68,9 @@ struct BitchatFilePacket {
|
||||
}
|
||||
|
||||
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
||||
static func decode(_ data: Data) -> BitchatFilePacket? {
|
||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
||||
/// the (smaller of the) accepted-offer size and the Wi-Fi bulk ceiling.
|
||||
static func decode(_ data: Data, limit: Int = FileTransferLimits.maxPayloadBytes) -> BitchatFilePacket? {
|
||||
var cursor = data.startIndex
|
||||
let end = data.endIndex
|
||||
|
||||
@@ -126,7 +130,7 @@ struct BitchatFilePacket {
|
||||
for byte in value {
|
||||
size = (size << 8) | UInt64(byte)
|
||||
}
|
||||
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
||||
if size > UInt64(limit) {
|
||||
return nil
|
||||
}
|
||||
fileSize = size
|
||||
@@ -135,7 +139,7 @@ struct BitchatFilePacket {
|
||||
mimeType = String(data: Data(value), encoding: .utf8)
|
||||
case .content:
|
||||
let proposedSize = content.count + value.count
|
||||
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
||||
if proposedSize > limit {
|
||||
return nil
|
||||
}
|
||||
content.append(contentsOf: value)
|
||||
@@ -145,7 +149,7 @@ struct BitchatFilePacket {
|
||||
}
|
||||
|
||||
guard !content.isEmpty else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
||||
return BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: fileSize ?? UInt64(content.count),
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
/// - Efficient binary message encoding
|
||||
/// - Message fragmentation for large payloads
|
||||
/// - TTL-based routing for mesh networks
|
||||
/// - Privacy features: message padding and randomized relay jitter
|
||||
/// - Privacy features like padding and timing obfuscation
|
||||
/// - Integration points for end-to-end encryption
|
||||
///
|
||||
/// ## Protocol Design
|
||||
@@ -38,20 +38,18 @@
|
||||
/// 7. **Decoding**: Binary data parsed back to message objects
|
||||
///
|
||||
/// ## Security Considerations
|
||||
/// - Message padding (to 256/512/1024/2048-byte blocks) obscures actual content length
|
||||
/// - Randomized relay jitter reduces the traffic-analysis signal; there is no
|
||||
/// cover traffic or per-message timing obfuscation
|
||||
/// - Message padding obscures actual content length
|
||||
/// - Timing obfuscation prevents traffic analysis
|
||||
/// - Integration with Noise Protocol for E2E encryption
|
||||
/// - No persistent identifiers in protocol headers
|
||||
///
|
||||
/// ## Message Types
|
||||
/// - **Announce/Leave**: Peer presence notifications
|
||||
/// - **Message**: Public chat messages
|
||||
/// - **Message**: User chat messages (broadcast or directed)
|
||||
/// - **Fragment**: Multi-part message handling
|
||||
/// - **NoiseHandshake/NoiseEncrypted**: Encrypted channel establishment and
|
||||
/// all private payloads (messages, delivery acks, read receipts)
|
||||
/// - **CourierEnvelope**: Sealed store-and-forward mail
|
||||
/// - **RequestSync/FileTransfer**: Gossip history sync and media transfer
|
||||
/// - **Delivery/Read**: Message acknowledgments
|
||||
/// - **Noise**: Encrypted channel establishment
|
||||
/// - **Version**: Protocol version negotiation
|
||||
///
|
||||
/// ## Future Extensions
|
||||
/// The protocol is designed to be extensible:
|
||||
@@ -74,28 +72,22 @@ enum NoisePayloadType: UInt8 {
|
||||
case privateMessage = 0x01 // Private chat message
|
||||
case readReceipt = 0x02 // Message was read
|
||||
case delivered = 0x03 // Message was delivered
|
||||
// Private groups (0x04/0x05 reserved by other features)
|
||||
case groupInvite = 0x06 // Creator-signed group state (invite)
|
||||
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
||||
// Live voice (push-to-talk)
|
||||
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket)
|
||||
// Wi-Fi bulk transport negotiation (AWDL data plane for large media)
|
||||
case bulkTransferOffer = 0x04 // Offer to move a large file over peer-to-peer Wi-Fi
|
||||
case bulkTransferResponse = 0x05 // Accept/decline reply to a bulk transfer offer
|
||||
// Verification (QR-based OOB binding)
|
||||
case verifyChallenge = 0x10 // Verification challenge
|
||||
case verifyResponse = 0x11 // Verification response
|
||||
// Transitive verification (web of trust)
|
||||
case vouch = 0x12 // Batch of vouch attestations
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .privateMessage: return "privateMessage"
|
||||
case .readReceipt: return "readReceipt"
|
||||
case .delivered: return "delivered"
|
||||
case .groupInvite: return "groupInvite"
|
||||
case .groupKeyUpdate: return "groupKeyUpdate"
|
||||
case .voiceFrame: return "voiceFrame"
|
||||
case .bulkTransferOffer: return "bulkTransferOffer"
|
||||
case .bulkTransferResponse: return "bulkTransferResponse"
|
||||
case .verifyChallenge: return "verifyChallenge"
|
||||
case .verifyResponse: return "verifyResponse"
|
||||
case .vouch: return "vouch"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,12 +119,6 @@ protocol BitchatDelegate: AnyObject {
|
||||
// Low-level events for better separation of concerns
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||
|
||||
// Encrypted group broadcast (opaque envelope; decrypted by the group coordinator)
|
||||
func didReceiveGroupMessage(payload: Data, timestamp: Date)
|
||||
|
||||
// Public live-voice burst packet (signature-verified by the transport)
|
||||
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date)
|
||||
|
||||
// Bluetooth state updates for user notifications
|
||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||
@@ -152,14 +138,6 @@ extension BitchatDelegate {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceiveGroupMessage(payload: Data, timestamp: Date) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
//
|
||||
// BoardPackets.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
// MARK: - Board wire format (MessageType.boardPost payloads)
|
||||
//
|
||||
// TLV layout (type u8, length u16 big-endian, value), matching REQUEST_SYNC:
|
||||
// - 0x01: kind (u8) — 0x01 post, 0x02 tombstone
|
||||
// - 0x02: postID (16B random)
|
||||
// - 0x03: geohash (UTF-8, empty = mesh-local board, max 12 chars)
|
||||
// - 0x04: content (UTF-8, 1...512 bytes) [post]
|
||||
// - 0x05: authorSigningKey (32B Ed25519 public key)
|
||||
// - 0x06: authorNickname (UTF-8, max 64 bytes)
|
||||
// - 0x07: createdAt (u64 big-endian, ms) [post]
|
||||
// - 0x08: expiresAt (u64 big-endian, ms, max 7 days after createdAt) [post]
|
||||
// - 0x09: flags (u8, bit0 = urgent) [post]
|
||||
// - 0x0A: signature (64B Ed25519)
|
||||
// - 0x0B: deletedAt (u64 big-endian, ms) [tombstone]
|
||||
// Unknown TLVs are skipped for forward compatibility.
|
||||
|
||||
enum BoardWireConstants {
|
||||
static let postIDLength = 16
|
||||
static let signingKeyLength = 32
|
||||
static let signatureLength = 64
|
||||
static let contentMaxBytes = 512
|
||||
static let nicknameMaxBytes = 64
|
||||
static let geohashMaxLength = 12
|
||||
/// Posts may live at most 7 days past their creation timestamp.
|
||||
static let maxLifetimeMs: UInt64 = 7 * 24 * 60 * 60 * 1000
|
||||
static let postSigningContext = "bitchat-board-v1"
|
||||
static let tombstoneSigningContext = "bitchat-board-del-v1"
|
||||
static let geohashAlphabet = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
}
|
||||
|
||||
private enum BoardTLVType: UInt8 {
|
||||
case kind = 0x01
|
||||
case postID = 0x02
|
||||
case geohash = 0x03
|
||||
case content = 0x04
|
||||
case authorSigningKey = 0x05
|
||||
case authorNickname = 0x06
|
||||
case createdAt = 0x07
|
||||
case expiresAt = 0x08
|
||||
case flags = 0x09
|
||||
case signature = 0x0A
|
||||
case deletedAt = 0x0B
|
||||
}
|
||||
|
||||
private enum BoardWireKind: UInt8 {
|
||||
case post = 0x01
|
||||
case tombstone = 0x02
|
||||
}
|
||||
|
||||
/// A signed, persistent bulletin-board notice.
|
||||
struct BoardPostPacket: Equatable {
|
||||
let postID: Data
|
||||
/// Empty string scopes the post to the mesh-local board.
|
||||
let geohash: String
|
||||
let content: String
|
||||
let authorSigningKey: Data
|
||||
let authorNickname: String
|
||||
let createdAt: UInt64
|
||||
let expiresAt: UInt64
|
||||
let flags: UInt8
|
||||
let signature: Data
|
||||
|
||||
static let urgentFlag: UInt8 = 0x01
|
||||
|
||||
var isUrgent: Bool { flags & Self.urgentFlag != 0 }
|
||||
|
||||
/// Canonical bytes covered by the Ed25519 signature. Variable-length
|
||||
/// fields are length-prefixed so no two field combinations can collide.
|
||||
static func signingBytes(
|
||||
postID: Data,
|
||||
geohash: String,
|
||||
content: String,
|
||||
authorSigningKey: Data,
|
||||
authorNickname: String,
|
||||
createdAt: UInt64,
|
||||
expiresAt: UInt64,
|
||||
flags: UInt8
|
||||
) -> Data {
|
||||
var out = Data()
|
||||
BoardWireEncoding.appendContext(BoardWireConstants.postSigningContext, to: &out)
|
||||
out.append(postID)
|
||||
BoardWireEncoding.appendLengthPrefixed(Data(geohash.utf8), to: &out)
|
||||
BoardWireEncoding.appendLengthPrefixed(Data(content.utf8), to: &out)
|
||||
out.append(authorSigningKey)
|
||||
BoardWireEncoding.appendLengthPrefixed(Data(authorNickname.utf8), to: &out)
|
||||
BoardWireEncoding.appendUInt64(createdAt, to: &out)
|
||||
BoardWireEncoding.appendUInt64(expiresAt, to: &out)
|
||||
out.append(flags)
|
||||
return out
|
||||
}
|
||||
|
||||
var signingBytes: Data {
|
||||
Self.signingBytes(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: authorSigningKey,
|
||||
authorNickname: authorNickname,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: flags
|
||||
)
|
||||
}
|
||||
|
||||
func verifySignature() -> Bool {
|
||||
BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// A signed deletion marker. Only the author's key can produce one; receivers
|
||||
/// keep it until the post's original expiry so the delete outruns the post.
|
||||
struct BoardTombstonePacket: Equatable {
|
||||
let postID: Data
|
||||
let authorSigningKey: Data
|
||||
let deletedAt: UInt64
|
||||
let signature: Data
|
||||
|
||||
static func signingBytes(postID: Data, deletedAt: UInt64) -> Data {
|
||||
var out = Data()
|
||||
BoardWireEncoding.appendContext(BoardWireConstants.tombstoneSigningContext, to: &out)
|
||||
out.append(postID)
|
||||
BoardWireEncoding.appendUInt64(deletedAt, to: &out)
|
||||
return out
|
||||
}
|
||||
|
||||
var signingBytes: Data {
|
||||
Self.signingBytes(postID: postID, deletedAt: deletedAt)
|
||||
}
|
||||
|
||||
func verifySignature() -> Bool {
|
||||
BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoded board payload: either a live post or a tombstone.
|
||||
enum BoardWire: Equatable {
|
||||
case post(BoardPostPacket)
|
||||
case tombstone(BoardTombstonePacket)
|
||||
|
||||
func encode() -> Data {
|
||||
var out = Data()
|
||||
func putTLV(_ t: BoardTLVType, _ v: Data) {
|
||||
out.append(t.rawValue)
|
||||
let len = UInt16(v.count)
|
||||
out.append(UInt8((len >> 8) & 0xFF))
|
||||
out.append(UInt8(len & 0xFF))
|
||||
out.append(v)
|
||||
}
|
||||
switch self {
|
||||
case .post(let post):
|
||||
putTLV(.kind, Data([BoardWireKind.post.rawValue]))
|
||||
putTLV(.postID, post.postID)
|
||||
putTLV(.geohash, Data(post.geohash.utf8))
|
||||
putTLV(.content, Data(post.content.utf8))
|
||||
putTLV(.authorSigningKey, post.authorSigningKey)
|
||||
putTLV(.authorNickname, Data(post.authorNickname.utf8))
|
||||
putTLV(.createdAt, BoardWireEncoding.uint64Data(post.createdAt))
|
||||
putTLV(.expiresAt, BoardWireEncoding.uint64Data(post.expiresAt))
|
||||
putTLV(.flags, Data([post.flags]))
|
||||
putTLV(.signature, post.signature)
|
||||
case .tombstone(let tombstone):
|
||||
putTLV(.kind, Data([BoardWireKind.tombstone.rawValue]))
|
||||
putTLV(.postID, tombstone.postID)
|
||||
putTLV(.authorSigningKey, tombstone.authorSigningKey)
|
||||
putTLV(.deletedAt, BoardWireEncoding.uint64Data(tombstone.deletedAt))
|
||||
putTLV(.signature, tombstone.signature)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// Structural decode; the caller must still verify the signature before
|
||||
/// ingesting (`verifySignature()`).
|
||||
static func decode(from data: Data) -> BoardWire? {
|
||||
var off = data.startIndex
|
||||
var kind: BoardWireKind?
|
||||
var postID: Data?
|
||||
var geohash: String?
|
||||
var content: String?
|
||||
var contentBytes = 0
|
||||
var authorSigningKey: Data?
|
||||
var authorNickname: String?
|
||||
var nicknameBytes = 0
|
||||
var createdAt: UInt64?
|
||||
var expiresAt: UInt64?
|
||||
var flags: UInt8?
|
||||
var signature: Data?
|
||||
var deletedAt: UInt64?
|
||||
|
||||
while off + 3 <= data.endIndex {
|
||||
let t = data[off]; off += 1
|
||||
let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2
|
||||
guard off + len <= data.endIndex else { return nil }
|
||||
let v = data.subdata(in: off..<(off + len)); off += len
|
||||
switch BoardTLVType(rawValue: t) {
|
||||
case .kind:
|
||||
guard v.count == 1 else { return nil }
|
||||
kind = BoardWireKind(rawValue: v[v.startIndex])
|
||||
case .postID:
|
||||
guard v.count == BoardWireConstants.postIDLength else { return nil }
|
||||
postID = v
|
||||
case .geohash:
|
||||
guard v.count <= BoardWireConstants.geohashMaxLength else { return nil }
|
||||
geohash = String(data: v, encoding: .utf8)
|
||||
case .content:
|
||||
guard v.count <= BoardWireConstants.contentMaxBytes else { return nil }
|
||||
contentBytes = v.count
|
||||
content = String(data: v, encoding: .utf8)
|
||||
case .authorSigningKey:
|
||||
guard v.count == BoardWireConstants.signingKeyLength else { return nil }
|
||||
authorSigningKey = v
|
||||
case .authorNickname:
|
||||
guard v.count <= BoardWireConstants.nicknameMaxBytes else { return nil }
|
||||
nicknameBytes = v.count
|
||||
authorNickname = String(data: v, encoding: .utf8)
|
||||
case .createdAt:
|
||||
createdAt = BoardWireEncoding.uint64(from: v)
|
||||
case .expiresAt:
|
||||
expiresAt = BoardWireEncoding.uint64(from: v)
|
||||
case .flags:
|
||||
guard v.count == 1 else { return nil }
|
||||
flags = v[v.startIndex]
|
||||
case .signature:
|
||||
guard v.count == BoardWireConstants.signatureLength else { return nil }
|
||||
signature = v
|
||||
case .deletedAt:
|
||||
deletedAt = BoardWireEncoding.uint64(from: v)
|
||||
case nil:
|
||||
continue // forward compatible; ignore unknown TLVs
|
||||
}
|
||||
}
|
||||
|
||||
guard let postID, let authorSigningKey, let signature else { return nil }
|
||||
|
||||
switch kind {
|
||||
case .post:
|
||||
guard let geohash, let content, let authorNickname,
|
||||
let createdAt, let expiresAt, let flags,
|
||||
contentBytes >= 1,
|
||||
nicknameBytes <= BoardWireConstants.nicknameMaxBytes,
|
||||
isValidGeohashField(geohash),
|
||||
expiresAt > createdAt,
|
||||
expiresAt - createdAt <= BoardWireConstants.maxLifetimeMs else {
|
||||
return nil
|
||||
}
|
||||
return .post(BoardPostPacket(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: authorSigningKey,
|
||||
authorNickname: authorNickname,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: flags,
|
||||
signature: signature
|
||||
))
|
||||
case .tombstone:
|
||||
guard let deletedAt else { return nil }
|
||||
return .tombstone(BoardTombstonePacket(
|
||||
postID: postID,
|
||||
authorSigningKey: authorSigningKey,
|
||||
deletedAt: deletedAt,
|
||||
signature: signature
|
||||
))
|
||||
case nil:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func verifySignature() -> Bool {
|
||||
switch self {
|
||||
case .post(let post): return post.verifySignature()
|
||||
case .tombstone(let tombstone): return tombstone.verifySignature()
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheap TLV peek for relay policy: is this payload an urgent post?
|
||||
/// Avoids a full decode on the hot relay path.
|
||||
static func urgentFlag(in data: Data) -> Bool {
|
||||
var off = data.startIndex
|
||||
while off + 3 <= data.endIndex {
|
||||
let t = data[off]; off += 1
|
||||
let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2
|
||||
guard off + len <= data.endIndex else { return false }
|
||||
if t == BoardTLVType.flags.rawValue, len == 1 {
|
||||
return data[off] & BoardPostPacket.urgentFlag != 0
|
||||
}
|
||||
off += len
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Empty geohash = mesh-local board; otherwise 1-12 chars of the geohash
|
||||
/// base32 alphabet.
|
||||
private static func isValidGeohashField(_ geohash: String) -> Bool {
|
||||
geohash.isEmpty || geohash.allSatisfy { BoardWireConstants.geohashAlphabet.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
enum BoardWireEncoding {
|
||||
static func appendContext(_ context: String, to out: inout Data) {
|
||||
let bytes = Data(context.utf8)
|
||||
out.append(UInt8(min(bytes.count, 255)))
|
||||
out.append(bytes.prefix(255))
|
||||
}
|
||||
|
||||
static func appendLengthPrefixed(_ value: Data, to out: inout Data) {
|
||||
let len = UInt16(min(value.count, Int(UInt16.max)))
|
||||
out.append(UInt8((len >> 8) & 0xFF))
|
||||
out.append(UInt8(len & 0xFF))
|
||||
out.append(value.prefix(Int(UInt16.max)))
|
||||
}
|
||||
|
||||
static func appendUInt64(_ value: UInt64, to out: inout Data) {
|
||||
var be = value.bigEndian
|
||||
withUnsafeBytes(of: &be) { out.append(contentsOf: $0) }
|
||||
}
|
||||
|
||||
static func uint64Data(_ value: UInt64) -> Data {
|
||||
var out = Data()
|
||||
appendUInt64(value, to: &out)
|
||||
return out
|
||||
}
|
||||
|
||||
static func uint64(from data: Data) -> UInt64? {
|
||||
guard data.count == 8 else { return nil }
|
||||
var value: UInt64 = 0
|
||||
for byte in data { value = (value << 8) | UInt64(byte) }
|
||||
return value
|
||||
}
|
||||
|
||||
static func verify(signature: Data, over message: Data, publicKey: Data) -> Bool {
|
||||
guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else {
|
||||
return false
|
||||
}
|
||||
return key.isValidSignature(signature, for: message)
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,11 @@ enum Geohash {
|
||||
return map
|
||||
}()
|
||||
|
||||
/// Validates a geohash string at any channel precision (1-12 characters).
|
||||
/// Validates a geohash string for building-level precision (8 characters).
|
||||
/// - Parameter geohash: The geohash string to validate
|
||||
/// - Returns: true if a non-empty base32 geohash of at most 12 characters
|
||||
static func isValidGeohash(_ geohash: String) -> Bool {
|
||||
guard (1...12).contains(geohash.count) else { return false }
|
||||
/// - Returns: true if valid 8-character base32 geohash, false otherwise
|
||||
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
|
||||
guard geohash.count == 8 else { return false }
|
||||
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
/// Mesh-bridge uplink: a mesh-only peer asks a bridge gateway to
|
||||
/// publish its signed rendezvous event. Directed, like `toGateway`.
|
||||
case toBridge = 0x03
|
||||
/// Mesh-bridge downlink: a bridge gateway rebroadcasts a rendezvous
|
||||
/// event from a remote island. Broadcast, like `fromGateway`.
|
||||
/// Old clients fail the Direction decode on 0x03/0x04 and drop the
|
||||
/// carrier quietly — bridge traffic degrades to invisible, not junk.
|
||||
case fromBridge = 0x04
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -9,25 +9,19 @@ struct AnnouncementPacket {
|
||||
let signingPublicKey: Data // Ed25519 public key for signing
|
||||
let directNeighbors: [Data]? // 8-byte peer IDs
|
||||
let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients)
|
||||
/// Rendezvous geohash cell this peer bridges, when advertising `.bridge`.
|
||||
/// Coarse (cell-level) by design; lets mesh-only peers compose correctly
|
||||
/// tagged rendezvous events without their own location fix.
|
||||
let bridgeGeohash: String?
|
||||
|
||||
init(
|
||||
nickname: String,
|
||||
noisePublicKey: Data,
|
||||
signingPublicKey: Data,
|
||||
directNeighbors: [Data]?,
|
||||
capabilities: PeerCapabilities? = nil,
|
||||
bridgeGeohash: String? = nil
|
||||
capabilities: PeerCapabilities? = nil
|
||||
) {
|
||||
self.nickname = nickname
|
||||
self.noisePublicKey = noisePublicKey
|
||||
self.signingPublicKey = signingPublicKey
|
||||
self.directNeighbors = directNeighbors
|
||||
self.capabilities = capabilities
|
||||
self.bridgeGeohash = bridgeGeohash
|
||||
}
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
@@ -36,7 +30,6 @@ struct AnnouncementPacket {
|
||||
case signingPublicKey = 0x03
|
||||
case directNeighbors = 0x04
|
||||
case capabilities = 0x05
|
||||
case bridgeGeohash = 0x06
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
@@ -81,15 +74,6 @@ struct AnnouncementPacket {
|
||||
data.append(capabilityBytes)
|
||||
}
|
||||
|
||||
// TLV for bridge rendezvous cell (optional; old clients skip it)
|
||||
if let bridgeGeohash = bridgeGeohash,
|
||||
let cellData = bridgeGeohash.data(using: .utf8),
|
||||
!cellData.isEmpty, cellData.count <= 12 {
|
||||
data.append(TLVType.bridgeGeohash.rawValue)
|
||||
data.append(UInt8(cellData.count))
|
||||
data.append(cellData)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -100,7 +84,6 @@ struct AnnouncementPacket {
|
||||
var signingPublicKey: Data?
|
||||
var directNeighbors: [Data]?
|
||||
var capabilities: PeerCapabilities?
|
||||
var bridgeGeohash: String?
|
||||
|
||||
while offset + 2 <= data.count {
|
||||
let typeRaw = data[offset]
|
||||
@@ -133,10 +116,6 @@ struct AnnouncementPacket {
|
||||
}
|
||||
case .capabilities:
|
||||
capabilities = PeerCapabilities(encoded: Data(value))
|
||||
case .bridgeGeohash:
|
||||
if length <= 12 {
|
||||
bridgeGeohash = String(data: value, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||
@@ -150,8 +129,7 @@ struct AnnouncementPacket {
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey,
|
||||
directNeighbors: directNeighbors,
|
||||
capabilities: capabilities,
|
||||
bridgeGeohash: bridgeGeohash
|
||||
capabilities: capabilities
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@ import BitFoundation
|
||||
extension PeerCapabilities {
|
||||
/// Capabilities this build advertises in its announce packets.
|
||||
/// Each feature adds its bit here when it ships.
|
||||
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups]
|
||||
static let localSupported: PeerCapabilities = TransportConfig.wifiBulkEnabled ? [.wifiBulk] : []
|
||||
}
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
//
|
||||
// VoiceBurstPacket.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Audio codec of a live voice burst. START packets carry it so receivers can
|
||||
/// reject bursts they can't decode instead of feeding garbage to the decoder.
|
||||
enum VoiceBurstCodec: UInt8 {
|
||||
/// AAC-LC, 16 kHz, mono, ~16 kbps — matches the voice-note recorder, so
|
||||
/// the finalized `.m4a` and the live frames come from the same encoder
|
||||
/// settings.
|
||||
case aacLC16kMono = 0x01
|
||||
}
|
||||
|
||||
/// One packet of a live push-to-talk voice burst (the inner payload of
|
||||
/// `NoisePayloadType.voiceFrame`, and — for public mesh bursts — the payload
|
||||
/// of `MessageType.voiceFrame`).
|
||||
///
|
||||
/// Wire format:
|
||||
/// ```
|
||||
/// [burstID: 8][seq: UInt16 BE][flags: UInt8][payload…]
|
||||
/// ```
|
||||
/// - flags 0x01 (START): payload = [codec: UInt8]
|
||||
/// - flags 0x02 (END): payload = [totalDataPackets: UInt16 BE][durationMs: UInt32 BE]
|
||||
/// - flags 0x04 (CANCELED): empty payload; receivers discard the burst
|
||||
/// - flags 0x00 (data): payload = repeated [length: UInt16 BE][AAC frame]
|
||||
struct VoiceBurstPacket: Equatable {
|
||||
enum Kind: Equatable {
|
||||
case start(codec: VoiceBurstCodec)
|
||||
case frames([Data])
|
||||
case end(totalDataPackets: UInt16, durationMs: UInt32)
|
||||
case canceled
|
||||
}
|
||||
|
||||
static let burstIDSize = 8
|
||||
private static let headerSize = burstIDSize + 2 + 1
|
||||
/// Sanity cap on frames per packet; real packets carry 1-2 frames.
|
||||
static let maxFramesPerPacket = 8
|
||||
|
||||
private enum Flags {
|
||||
static let start: UInt8 = 0x01
|
||||
static let end: UInt8 = 0x02
|
||||
static let canceled: UInt8 = 0x04
|
||||
}
|
||||
|
||||
let burstID: Data
|
||||
let seq: UInt16
|
||||
let kind: Kind
|
||||
|
||||
init?(burstID: Data, seq: UInt16, kind: Kind) {
|
||||
guard burstID.count == Self.burstIDSize else { return nil }
|
||||
if case .frames(let frames) = kind {
|
||||
guard !frames.isEmpty,
|
||||
frames.count <= Self.maxFramesPerPacket,
|
||||
frames.allSatisfy({ !$0.isEmpty && $0.count <= Int(UInt16.max) })
|
||||
else { return nil }
|
||||
}
|
||||
self.burstID = burstID
|
||||
self.seq = seq
|
||||
self.kind = kind
|
||||
}
|
||||
|
||||
func encode() -> Data {
|
||||
var data = Data(capacity: Self.headerSize + payloadSize)
|
||||
data.append(burstID)
|
||||
data.append(UInt8((seq >> 8) & 0xFF))
|
||||
data.append(UInt8(seq & 0xFF))
|
||||
switch kind {
|
||||
case .start(let codec):
|
||||
data.append(Flags.start)
|
||||
data.append(codec.rawValue)
|
||||
case .frames(let frames):
|
||||
data.append(0)
|
||||
for frame in frames {
|
||||
let length = UInt16(frame.count)
|
||||
data.append(UInt8((length >> 8) & 0xFF))
|
||||
data.append(UInt8(length & 0xFF))
|
||||
data.append(frame)
|
||||
}
|
||||
case .end(let totalDataPackets, let durationMs):
|
||||
data.append(Flags.end)
|
||||
data.append(UInt8((totalDataPackets >> 8) & 0xFF))
|
||||
data.append(UInt8(totalDataPackets & 0xFF))
|
||||
for shift in stride(from: 24, through: 0, by: -8) {
|
||||
data.append(UInt8((durationMs >> UInt32(shift)) & 0xFF))
|
||||
}
|
||||
case .canceled:
|
||||
data.append(Flags.canceled)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> VoiceBurstPacket? {
|
||||
// Work on a re-based copy so subscripting is offset-safe.
|
||||
let data = Data(data)
|
||||
guard data.count >= headerSize else { return nil }
|
||||
|
||||
let burstID = data.prefix(burstIDSize)
|
||||
let seq = (UInt16(data[burstIDSize]) << 8) | UInt16(data[burstIDSize + 1])
|
||||
let flags = data[burstIDSize + 2]
|
||||
let payload = data.dropFirst(headerSize)
|
||||
|
||||
let kind: Kind
|
||||
switch flags {
|
||||
case Flags.start:
|
||||
guard let codecByte = payload.first,
|
||||
let codec = VoiceBurstCodec(rawValue: codecByte)
|
||||
else { return nil }
|
||||
kind = .start(codec: codec)
|
||||
case Flags.end:
|
||||
guard payload.count >= 6 else { return nil }
|
||||
let bytes = Array(payload)
|
||||
let total = (UInt16(bytes[0]) << 8) | UInt16(bytes[1])
|
||||
let duration = bytes[2...5].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||
kind = .end(totalDataPackets: total, durationMs: duration)
|
||||
case Flags.canceled:
|
||||
kind = .canceled
|
||||
case 0:
|
||||
var frames: [Data] = []
|
||||
var offset = payload.startIndex
|
||||
while offset < payload.endIndex {
|
||||
guard payload.distance(from: offset, to: payload.endIndex) >= 2 else { return nil }
|
||||
let length = (Int(payload[offset]) << 8) | Int(payload[payload.index(after: offset)])
|
||||
offset = payload.index(offset, offsetBy: 2)
|
||||
guard length > 0,
|
||||
payload.distance(from: offset, to: payload.endIndex) >= length,
|
||||
frames.count < maxFramesPerPacket
|
||||
else { return nil }
|
||||
let end = payload.index(offset, offsetBy: length)
|
||||
frames.append(Data(payload[offset..<end]))
|
||||
offset = end
|
||||
}
|
||||
guard !frames.isEmpty else { return nil }
|
||||
kind = .frames(frames)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return VoiceBurstPacket(burstID: Data(burstID), seq: seq, kind: kind)
|
||||
}
|
||||
|
||||
static func makeBurstID() -> Data {
|
||||
var bytes = Data(count: burstIDSize)
|
||||
let result = bytes.withUnsafeMutableBytes {
|
||||
SecRandomCopyBytes(kSecRandomDefault, burstIDSize, $0.baseAddress!)
|
||||
}
|
||||
guard result == errSecSuccess else {
|
||||
return Data((0..<burstIDSize).map { _ in UInt8.random(in: .min ... .max) })
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
private var payloadSize: Int {
|
||||
switch kind {
|
||||
case .start: return 1
|
||||
case .frames(let frames): return frames.reduce(0) { $0 + 2 + $1.count }
|
||||
case .end: return 6
|
||||
case .canceled: return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Greedy packetizer for outgoing bursts: batches encoded frames into
|
||||
/// `VoiceBurstPacket`s without exceeding the byte budget that keeps each
|
||||
/// packet in a single BLE frame after Noise encryption and padding.
|
||||
/// Not thread-safe — confine to one queue.
|
||||
struct VoiceBurstPacketizer {
|
||||
let burstID: Data
|
||||
private let budget: Int
|
||||
private var pendingFrames: [Data] = []
|
||||
private var pendingSize = 0
|
||||
/// seq 0 is reserved for START; data packets start at 1.
|
||||
private(set) var nextSeq: UInt16 = 1
|
||||
private(set) var dataPacketCount: UInt16 = 0
|
||||
|
||||
init(burstID: Data, budget: Int = TransportConfig.pttMaxBurstContentBytes) {
|
||||
self.burstID = burstID
|
||||
self.budget = budget
|
||||
}
|
||||
|
||||
/// Adds one encoded frame, returning any packets that became full.
|
||||
/// Frames larger than the budget are dropped (the encoder's ~130-byte
|
||||
/// frames never hit this; it guards against misconfiguration looping).
|
||||
mutating func add(_ frame: Data) -> [Data] {
|
||||
let frameCost = 2 + frame.count
|
||||
guard VoiceBurstPacket.burstIDSize + 3 + frameCost <= budget else { return [] }
|
||||
|
||||
var packets: [Data] = []
|
||||
if !pendingFrames.isEmpty,
|
||||
VoiceBurstPacket.burstIDSize + 3 + pendingSize + frameCost > budget
|
||||
|| pendingFrames.count >= VoiceBurstPacket.maxFramesPerPacket {
|
||||
packets.append(contentsOf: flush())
|
||||
}
|
||||
pendingFrames.append(frame)
|
||||
pendingSize += frameCost
|
||||
return packets
|
||||
}
|
||||
|
||||
/// Emits any buffered frames as a final data packet.
|
||||
mutating func flush() -> [Data] {
|
||||
guard !pendingFrames.isEmpty,
|
||||
let packet = VoiceBurstPacket(burstID: burstID, seq: nextSeq, kind: .frames(pendingFrames))
|
||||
else {
|
||||
pendingFrames = []
|
||||
pendingSize = 0
|
||||
return []
|
||||
}
|
||||
pendingFrames = []
|
||||
pendingSize = 0
|
||||
nextSeq &+= 1
|
||||
dataPacketCount &+= 1
|
||||
return [packet.encode()]
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
//
|
||||
// VouchAttestation.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// A signed statement that the *sender of the enclosing Noise payload* has
|
||||
/// verified the identity described here ("transitive verification").
|
||||
///
|
||||
/// The voucher's identity is deliberately implicit: attestations only travel
|
||||
/// inside an authenticated Noise session (`NoisePayloadType.vouch`), so the
|
||||
/// receiver verifies the Ed25519 signature against the session peer's
|
||||
/// announce-bound signing key and stores the vouch keyed by that peer's
|
||||
/// fingerprint. Nothing in the attestation names the voucher, so a captured
|
||||
/// attestation cannot be replayed by a third party whose signing key doesn't
|
||||
/// match.
|
||||
///
|
||||
/// Wire format — single attestation (TLV, 1-byte type + 1-byte length):
|
||||
/// - `0x01` voucheeFingerprint: 32 bytes, SHA-256 of the vouchee's Noise static key
|
||||
/// - `0x02` voucheeSigningKey: 32 bytes, Ed25519; anchors the vouch to a concrete identity
|
||||
/// - `0x03` timestamp: 8 bytes big-endian, milliseconds since 1970
|
||||
/// - `0x04` signature: 64 bytes, Ed25519 by the VOUCHER's signing key over
|
||||
/// `"bitchat-vouch-v1" | voucheeFingerprint | voucheeSigningKey | timestamp`
|
||||
///
|
||||
/// Unknown TLV types are skipped for forward compatibility.
|
||||
///
|
||||
/// Batch format (the `vouch` Noise payload body):
|
||||
/// `[count: UInt8]` then per attestation `[length: UInt16 BE][attestation TLV]`.
|
||||
struct VouchAttestation: Equatable {
|
||||
static let signingContext = "bitchat-vouch-v1"
|
||||
/// Receiver-side expiry for attestations.
|
||||
static let maxAge: TimeInterval = 30 * 24 * 60 * 60
|
||||
/// Tolerated clock skew for attestations timestamped in the future.
|
||||
static let maxClockSkew: TimeInterval = 60 * 60
|
||||
/// Upper bound of attestations carried/accepted in one batch payload.
|
||||
static let maxBatchCount = 16
|
||||
|
||||
static let fingerprintSize = 32
|
||||
static let signingKeySize = 32
|
||||
static let signatureSize = 64
|
||||
|
||||
let voucheeFingerprint: Data // 32 bytes
|
||||
let voucheeSigningKey: Data // 32 bytes
|
||||
let timestampMs: UInt64
|
||||
let signature: Data // 64 bytes
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case voucheeFingerprint = 0x01
|
||||
case voucheeSigningKey = 0x02
|
||||
case timestamp = 0x03
|
||||
case signature = 0x04
|
||||
}
|
||||
|
||||
var voucheeFingerprintHex: String { voucheeFingerprint.hexEncodedString() }
|
||||
|
||||
var timestamp: Date { Date(timeIntervalSince1970: TimeInterval(timestampMs) / 1000) }
|
||||
|
||||
/// The exact bytes the voucher signs.
|
||||
static func signableBytes(
|
||||
voucheeFingerprint: Data,
|
||||
voucheeSigningKey: Data,
|
||||
timestampMs: UInt64
|
||||
) -> Data {
|
||||
var message = Data(signingContext.utf8)
|
||||
message.append(voucheeFingerprint)
|
||||
message.append(voucheeSigningKey)
|
||||
var timestampBE = timestampMs.bigEndian
|
||||
withUnsafeBytes(of: ×tampBE) { message.append(contentsOf: $0) }
|
||||
return message
|
||||
}
|
||||
|
||||
var signableBytes: Data {
|
||||
Self.signableBytes(
|
||||
voucheeFingerprint: voucheeFingerprint,
|
||||
voucheeSigningKey: voucheeSigningKey,
|
||||
timestampMs: timestampMs
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds and signs an attestation. `sign` is the voucher's Ed25519
|
||||
/// signing primitive (e.g. `Transport.noiseSignData`).
|
||||
static func build(
|
||||
voucheeFingerprint: Data,
|
||||
voucheeSigningKey: Data,
|
||||
timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
sign: (Data) -> Data?
|
||||
) -> VouchAttestation? {
|
||||
guard voucheeFingerprint.count == fingerprintSize,
|
||||
voucheeSigningKey.count == signingKeySize else { return nil }
|
||||
let message = signableBytes(
|
||||
voucheeFingerprint: voucheeFingerprint,
|
||||
voucheeSigningKey: voucheeSigningKey,
|
||||
timestampMs: timestampMs
|
||||
)
|
||||
guard let signature = sign(message), signature.count == signatureSize else { return nil }
|
||||
return VouchAttestation(
|
||||
voucheeFingerprint: voucheeFingerprint,
|
||||
voucheeSigningKey: voucheeSigningKey,
|
||||
timestampMs: timestampMs,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
|
||||
/// Verifies the Ed25519 signature against the voucher's announce-bound
|
||||
/// signing key.
|
||||
func verifySignature(voucherSigningKey: Data) -> Bool {
|
||||
guard let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: voucherSigningKey) else {
|
||||
return false
|
||||
}
|
||||
return publicKey.isValidSignature(signature, for: signableBytes)
|
||||
}
|
||||
|
||||
/// Whether the attestation is outside its validity window (older than
|
||||
/// `maxAge`, or timestamped implausibly far in the future).
|
||||
func isExpired(now: Date = Date()) -> Bool {
|
||||
let age = now.timeIntervalSince(timestamp)
|
||||
return age > Self.maxAge || age < -Self.maxClockSkew
|
||||
}
|
||||
|
||||
// MARK: - Encoding
|
||||
|
||||
func encode() -> Data? {
|
||||
guard voucheeFingerprint.count == Self.fingerprintSize,
|
||||
voucheeSigningKey.count == Self.signingKeySize,
|
||||
signature.count == Self.signatureSize else { return nil }
|
||||
var data = Data()
|
||||
func appendTLV(_ type: TLVType, _ value: Data) {
|
||||
data.append(type.rawValue)
|
||||
data.append(UInt8(value.count))
|
||||
data.append(value)
|
||||
}
|
||||
appendTLV(.voucheeFingerprint, voucheeFingerprint)
|
||||
appendTLV(.voucheeSigningKey, voucheeSigningKey)
|
||||
var timestampBE = timestampMs.bigEndian
|
||||
appendTLV(.timestamp, withUnsafeBytes(of: ×tampBE) { Data($0) })
|
||||
appendTLV(.signature, signature)
|
||||
return data
|
||||
}
|
||||
|
||||
static func decode(from data: Data) -> VouchAttestation? {
|
||||
var fingerprint: Data?
|
||||
var signingKey: Data?
|
||||
var timestampMs: UInt64?
|
||||
var signature: Data?
|
||||
|
||||
var offset = data.startIndex
|
||||
while offset < data.endIndex {
|
||||
guard data.index(offset, offsetBy: 2, limitedBy: data.endIndex) != nil,
|
||||
offset + 1 < data.endIndex else { return nil }
|
||||
let type = data[offset]
|
||||
let length = Int(data[offset + 1])
|
||||
let valueStart = offset + 2
|
||||
guard let valueEnd = data.index(valueStart, offsetBy: length, limitedBy: data.endIndex) else {
|
||||
return nil
|
||||
}
|
||||
let value = Data(data[valueStart..<valueEnd])
|
||||
switch TLVType(rawValue: type) {
|
||||
case .voucheeFingerprint:
|
||||
guard value.count == fingerprintSize else { return nil }
|
||||
fingerprint = value
|
||||
case .voucheeSigningKey:
|
||||
guard value.count == signingKeySize else { return nil }
|
||||
signingKey = value
|
||||
case .timestamp:
|
||||
guard value.count == 8 else { return nil }
|
||||
timestampMs = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
|
||||
case .signature:
|
||||
guard value.count == signatureSize else { return nil }
|
||||
signature = value
|
||||
case nil:
|
||||
break // Unknown TLV: skip for forward compatibility.
|
||||
}
|
||||
offset = valueEnd
|
||||
}
|
||||
|
||||
guard let fingerprint, let signingKey, let timestampMs, let signature else { return nil }
|
||||
return VouchAttestation(
|
||||
voucheeFingerprint: fingerprint,
|
||||
voucheeSigningKey: signingKey,
|
||||
timestampMs: timestampMs,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Batch encoding
|
||||
|
||||
/// Encodes up to `maxBatchCount` attestations into one payload body.
|
||||
static func encodeList(_ attestations: [VouchAttestation]) -> Data? {
|
||||
guard !attestations.isEmpty, attestations.count <= maxBatchCount else { return nil }
|
||||
var data = Data()
|
||||
data.append(UInt8(attestations.count))
|
||||
for attestation in attestations {
|
||||
guard let encoded = attestation.encode(), encoded.count <= Int(UInt16.max) else { return nil }
|
||||
var lengthBE = UInt16(encoded.count).bigEndian
|
||||
withUnsafeBytes(of: &lengthBE) { data.append(contentsOf: $0) }
|
||||
data.append(encoded)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/// Decodes a batch payload, dropping malformed entries and ignoring
|
||||
/// anything beyond `maxBatchCount` (sender-declared count is not trusted).
|
||||
static func decodeList(from data: Data) -> [VouchAttestation] {
|
||||
guard data.count > 1 else { return [] }
|
||||
let declaredCount = Int(data[data.startIndex])
|
||||
let limit = min(declaredCount, maxBatchCount)
|
||||
var attestations: [VouchAttestation] = []
|
||||
var offset = data.startIndex + 1
|
||||
while attestations.count < limit, offset < data.endIndex {
|
||||
guard let lengthEnd = data.index(offset, offsetBy: 2, limitedBy: data.endIndex) else { break }
|
||||
let length = Int(data[offset]) << 8 | Int(data[offset + 1])
|
||||
guard let entryEnd = data.index(lengthEnd, offsetBy: length, limitedBy: data.endIndex) else { break }
|
||||
if let attestation = decode(from: Data(data[lengthEnd..<entryEnd])) {
|
||||
attestations.append(attestation)
|
||||
}
|
||||
offset = entryEnd
|
||||
}
|
||||
return attestations
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,14 @@ import Foundation
|
||||
/// Manages autocomplete functionality for chat
|
||||
final class AutocompleteService {
|
||||
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
|
||||
|
||||
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
|
||||
|
||||
private let commands = [
|
||||
"/msg", "/who", "/clear",
|
||||
"/hug", "/slap", "/fav", "/unfav",
|
||||
"/block", "/unblock"
|
||||
]
|
||||
|
||||
/// Get autocomplete suggestions for current text
|
||||
func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) {
|
||||
let textToPosition = String(text.prefix(cursorPosition))
|
||||
@@ -66,6 +73,26 @@ final class AutocompleteService {
|
||||
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
|
||||
}
|
||||
|
||||
private func getCommandSuggestions(_ text: String) -> ([String], NSRange)? {
|
||||
guard let regex = commandRegex else { return nil }
|
||||
|
||||
let nsText = text as NSString
|
||||
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length))
|
||||
|
||||
guard let match = matches.last else { return nil }
|
||||
|
||||
let fullRange = match.range(at: 0)
|
||||
let captureRange = match.range(at: 1)
|
||||
let prefix = nsText.substring(with: captureRange).lowercased()
|
||||
|
||||
let suggestions = commands
|
||||
.filter { $0.hasPrefix("/\(prefix)") }
|
||||
.sorted()
|
||||
.prefix(5)
|
||||
|
||||
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
|
||||
}
|
||||
|
||||
private func needsArgument(command: String) -> Bool {
|
||||
switch command {
|
||||
case "/who", "/clear":
|
||||
|
||||
@@ -14,8 +14,6 @@ struct BLEFileTransferHandlerEnvironment {
|
||||
let localNickname: () -> String
|
||||
/// Snapshot of known peers keyed by ID (registry read).
|
||||
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
||||
/// Verifies a packet's signature against a candidate signing key (registry path).
|
||||
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
||||
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
||||
/// Tracks the broadcast file packet for gossip sync.
|
||||
@@ -46,54 +44,49 @@ final class BLEFileTransferHandler {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
/// Returns `false` when the packet fails sender authentication and must
|
||||
/// not be relayed onward. Every other outcome returns `true`: files
|
||||
/// directed to another peer are forwarded untouched, and local-only drops
|
||||
/// (malformed payload, quota, save failure) don't affect multi-hop
|
||||
/// delivery to nodes that may handle them fine.
|
||||
@discardableResult
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||
/// `payloadLimit` defaults to the Bluetooth cap; Wi-Fi bulk deliveries
|
||||
/// pass the ceiling that was enforced against the accepted offer.
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID, payloadLimit: Int = FileTransferLimits.maxPayloadBytes) {
|
||||
let env = environment
|
||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true }
|
||||
|
||||
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
|
||||
return true
|
||||
}
|
||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
|
||||
|
||||
let peersSnapshot = env.peersSnapshot()
|
||||
guard let senderNickname = resolveSenderNickname(
|
||||
packet: packet,
|
||||
from: peerID,
|
||||
isBroadcast: !deliveryPlan.isPrivateMessage,
|
||||
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peerID,
|
||||
localPeerID: env.localPeerID(),
|
||||
localNickname: env.localNickname(),
|
||||
peers: peersSnapshot,
|
||||
env: env
|
||||
) else {
|
||||
allowConnectedUnverified: true
|
||||
) ?? env.signedSenderDisplayName(packet, peerID) else {
|
||||
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
|
||||
return
|
||||
}
|
||||
if deliveryPlan.shouldTrackForSync {
|
||||
env.trackPacketSeen(packet)
|
||||
}
|
||||
|
||||
let filePacket: BitchatFilePacket
|
||||
let mime: MimeType
|
||||
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
|
||||
switch BLEIncomingFileValidator.validate(payload: packet.payload, limit: payloadLimit) {
|
||||
case .success(let acceptance):
|
||||
filePacket = acceptance.filePacket
|
||||
mime = acceptance.mime
|
||||
case .failure(.malformedPayload):
|
||||
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
||||
return true
|
||||
return
|
||||
case .failure(.payloadTooLarge(let bytes)):
|
||||
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
||||
return true
|
||||
return
|
||||
case .failure(.unsupportedMime(let mimeType, let bytes)):
|
||||
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
|
||||
return true
|
||||
return
|
||||
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
|
||||
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
|
||||
return true
|
||||
return
|
||||
}
|
||||
|
||||
// BCH-01-002: Enforce storage quota before saving
|
||||
@@ -106,7 +99,7 @@ final class BLEFileTransferHandler {
|
||||
mime.defaultExtension,
|
||||
mime.category.rawValue
|
||||
) else {
|
||||
return true
|
||||
return
|
||||
}
|
||||
|
||||
if deliveryPlan.isPrivateMessage {
|
||||
@@ -122,66 +115,11 @@ final class BLEFileTransferHandler {
|
||||
originalSender: nil,
|
||||
isPrivate: deliveryPlan.isPrivateMessage,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
// Received messages need an explicit status: BitchatMessage
|
||||
// defaults private messages to .sending, which the media views
|
||||
// render as an in-flight send (empty reveal mask, disabled tap).
|
||||
deliveryStatus: deliveryPlan.isPrivateMessage
|
||||
? .delivered(to: env.localNickname(), at: ts)
|
||||
: nil
|
||||
senderPeerID: peerID
|
||||
)
|
||||
|
||||
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
||||
|
||||
env.deliverMessage(message)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Resolves the authenticated display name for a file transfer's sender.
|
||||
///
|
||||
/// Directed (private) transfers are addressed to us specifically and keep
|
||||
/// the lenient connected-peer path. Broadcast transfers carry an
|
||||
/// attacker-controllable `senderID` exactly like public messages and public
|
||||
/// voice frames — registry membership alone is NOT proof of identity, so a
|
||||
/// valid packet signature from the claimed sender is required before we
|
||||
/// trust it. Without this, a peer that observed a public voice burst could
|
||||
/// spoof a broadcast `voice_<burstID>.m4a` note under the talker's ID and
|
||||
/// overwrite the signature-verified live bubble with attacker audio.
|
||||
private func resolveSenderNickname(
|
||||
packet: BitchatPacket,
|
||||
from peerID: PeerID,
|
||||
isBroadcast: Bool,
|
||||
peers: [PeerID: BLEPeerInfo],
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) -> String? {
|
||||
guard isBroadcast else {
|
||||
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peerID,
|
||||
localPeerID: env.localPeerID(),
|
||||
localNickname: env.localNickname(),
|
||||
peers: peers,
|
||||
allowConnectedUnverified: true
|
||||
) ?? env.signedSenderDisplayName(packet, peerID)
|
||||
}
|
||||
|
||||
// Our own broadcasts replayed back via gossip sync (ttl==0) are
|
||||
// trivially authentic and cannot be verified against the peer registry
|
||||
// or identity cache, so exempt self exactly as `BLEPublicMessageHandler`
|
||||
// does. Verify against the signing key already in the
|
||||
// (synchronously-updated) registry first, then fall back to the
|
||||
// persisted-identity signature lookup for peers not yet cached there.
|
||||
let isSelf = peerID == env.localPeerID()
|
||||
let registrySigningKey = peers[peerID]?.signingPublicKey
|
||||
let verifiedViaRegistry = !isSelf && (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false)
|
||||
let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID)
|
||||
guard isSelf || verifiedViaRegistry || signedDisplayName != nil else { return nil }
|
||||
|
||||
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peerID,
|
||||
localPeerID: env.localPeerID(),
|
||||
localNickname: env.localNickname(),
|
||||
peers: peers,
|
||||
allowConnectedUnverified: false
|
||||
) ?? signedDisplayName
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,12 +42,17 @@ enum BLEIncomingFileRejection: Error, Equatable {
|
||||
}
|
||||
|
||||
enum BLEIncomingFileValidator {
|
||||
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
||||
guard let filePacket = BitchatFilePacket.decode(payload) else {
|
||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk deliveries
|
||||
/// pass the ceiling enforced against the accepted offer.
|
||||
static func validate(
|
||||
payload: Data,
|
||||
limit: Int = FileTransferLimits.maxPayloadBytes
|
||||
) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
||||
guard let filePacket = BitchatFilePacket.decode(payload, limit: limit) else {
|
||||
return .failure(.malformedPayload)
|
||||
}
|
||||
|
||||
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
|
||||
guard FileTransferLimits.isValidPayload(filePacket.content.count, limit: limit) else {
|
||||
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
||||
}
|
||||
|
||||
|
||||
@@ -61,11 +61,9 @@ struct BLEFragmentAssemblyBuffer {
|
||||
}
|
||||
|
||||
private struct Metadata {
|
||||
let type: UInt8
|
||||
let total: Int
|
||||
let timestamp: Date
|
||||
let isBroadcast: Bool
|
||||
var lastFragmentAt: Date
|
||||
var lastResyncRequestAt: Date?
|
||||
}
|
||||
|
||||
private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:]
|
||||
@@ -107,15 +105,7 @@ struct BLEFragmentAssemblyBuffer {
|
||||
return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started)
|
||||
}
|
||||
|
||||
// Only actual progress resets the stall clock: fragment packets
|
||||
// bypass the packet deduplicator, so relayed duplicates of an
|
||||
// already-held index must not keep suppressing the targeted
|
||||
// REQUEST_SYNC for a stalled stream.
|
||||
let isNewIndex = fragmentsByKey[header.key]?[header.index] == nil
|
||||
fragmentsByKey[header.key]?[header.index] = header.fragmentData
|
||||
if isNewIndex {
|
||||
metadataByKey[header.key]?.lastFragmentAt = now
|
||||
}
|
||||
|
||||
guard let fragments = fragmentsByKey[header.key],
|
||||
fragments.count == header.total else {
|
||||
@@ -148,58 +138,10 @@ struct BLEFragmentAssemblyBuffer {
|
||||
}
|
||||
|
||||
fragmentsByKey[header.key] = [:]
|
||||
metadataByKey[header.key] = Metadata(
|
||||
total: header.total,
|
||||
timestamp: now,
|
||||
isBroadcast: header.isBroadcastFragment,
|
||||
lastFragmentAt: now
|
||||
)
|
||||
metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Fragment stream IDs (8-byte, big-endian) of incomplete broadcast
|
||||
/// reassemblies that have not seen a new fragment for `stalledAfter`
|
||||
/// seconds — candidates for a targeted REQUEST_SYNC. Each returned
|
||||
/// stream is marked so it is not re-requested within `retryAfter`.
|
||||
/// At most `RequestSyncPacket.maxFragmentIdFilterCount` streams are
|
||||
/// returned per pass — the wire filter cannot carry more — selected
|
||||
/// oldest-stall first; overflow streams stay unmarked and eligible for
|
||||
/// the next pass. Directed reassemblies are excluded: peers only archive
|
||||
/// broadcast fragments for gossip sync, so a targeted request cannot
|
||||
/// recover them.
|
||||
mutating func stalledBroadcastFragmentIDs(
|
||||
stalledAfter: TimeInterval,
|
||||
retryAfter: TimeInterval,
|
||||
now: Date = Date()
|
||||
) -> [Data] {
|
||||
var candidates: [(key: BLEFragmentKey, lastFragmentAt: Date)] = []
|
||||
for (key, metadata) in metadataByKey {
|
||||
guard metadata.isBroadcast,
|
||||
let fragments = fragmentsByKey[key],
|
||||
fragments.count < metadata.total,
|
||||
now.timeIntervalSince(metadata.lastFragmentAt) >= stalledAfter else { continue }
|
||||
if let lastRequest = metadata.lastResyncRequestAt,
|
||||
now.timeIntervalSince(lastRequest) < retryAfter { continue }
|
||||
candidates.append((key: key, lastFragmentAt: metadata.lastFragmentAt))
|
||||
}
|
||||
|
||||
// Mark only the streams that will actually go on the wire, so the
|
||||
// overflow is not silently suppressed for `retryAfter`.
|
||||
let selected = candidates
|
||||
.sorted {
|
||||
if $0.lastFragmentAt != $1.lastFragmentAt {
|
||||
return $0.lastFragmentAt < $1.lastFragmentAt
|
||||
}
|
||||
return ($0.key.sender, $0.key.id) < ($1.key.sender, $1.key.id)
|
||||
}
|
||||
.prefix(RequestSyncPacket.maxFragmentIdFilterCount)
|
||||
|
||||
return selected.map { candidate in
|
||||
metadataByKey[candidate.key]?.lastResyncRequestAt = now
|
||||
return withUnsafeBytes(of: candidate.key.id.bigEndian) { Data($0) }
|
||||
}
|
||||
}
|
||||
|
||||
private static func assemblyLimit(for originalType: UInt8) -> Int {
|
||||
if originalType == MessageType.fileTransfer.rawValue {
|
||||
// Allow headroom for TLV metadata and binary framing overhead.
|
||||
|
||||
@@ -33,21 +33,13 @@ final class BLEFragmentHandler {
|
||||
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
let env = environment
|
||||
guard let header = BLEFragmentHeader(packet: packet) else { return }
|
||||
|
||||
// Sync replay legitimately hands us our own fragments back (the RSR
|
||||
// ttl=0 restore path): after a relaunch the fragment store starts
|
||||
// empty, so our sync filter doesn't cover them and peers re-offer
|
||||
// them. Record them as seen — the next round's filter then covers
|
||||
// them and the redelivery stops — but skip assembly: we authored
|
||||
// the original, there is nothing to reassemble.
|
||||
// Don't process our own fragments
|
||||
if peerID == env.localPeerID() {
|
||||
if header.isBroadcastFragment {
|
||||
env.trackPacketSeen(packet)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let header = BLEFragmentHeader(packet: packet) else { return }
|
||||
|
||||
if header.isBroadcastFragment {
|
||||
env.trackPacketSeen(packet)
|
||||
}
|
||||
|
||||
@@ -78,21 +78,10 @@ struct BLEIngressLinkRegistry {
|
||||
return .failure(.selfLoopback(packetType: packet.type))
|
||||
}
|
||||
|
||||
if let boundPeerID, boundPeerID != claimedSenderID {
|
||||
if requiresDirectSenderBinding(packet) {
|
||||
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
||||
}
|
||||
// A direct announce claiming a new sender on a bound link is either
|
||||
// a spoof or a legitimate peer-ID rotation on a connection that
|
||||
// outlived the old ID. Attribute it to the claimed sender and let
|
||||
// it through: announces are self-authenticating, and only a
|
||||
// signature-verified announce may rebind the link (BLEService).
|
||||
if isDirectAnnounce(packet, directAnnounceTTL: directAnnounceTTL) {
|
||||
return .success(BLEIngressPacketContext(
|
||||
receivedFromPeerID: claimedSenderID,
|
||||
validationPeerID: claimedSenderID
|
||||
))
|
||||
}
|
||||
if let boundPeerID,
|
||||
boundPeerID != claimedSenderID,
|
||||
requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) {
|
||||
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
||||
}
|
||||
|
||||
let receivedFromPeerID = boundPeerID ?? claimedSenderID
|
||||
@@ -109,15 +98,12 @@ struct BLEIngressLinkRegistry {
|
||||
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
||||
}
|
||||
|
||||
private static func requiresDirectSenderBinding(_ packet: BitchatPacket) -> Bool {
|
||||
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
|
||||
// REQUEST_SYNC is never relayed, so on a bound link the claimed sender
|
||||
// must be the link peer — it elicits a full store replay, and the
|
||||
// response is addressed to whoever the sender claims to be.
|
||||
packet.type == MessageType.requestSync.rawValue
|
||||
}
|
||||
|
||||
static func isDirectAnnounce(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
|
||||
packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
|
||||
if packet.type == MessageType.requestSync.rawValue { return true }
|
||||
return packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
|
||||
}
|
||||
|
||||
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
|
||||
|
||||
@@ -203,19 +203,9 @@ final class BLELinkStateStore {
|
||||
|
||||
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
|
||||
assertOwned()
|
||||
var previousPeerID: PeerID?
|
||||
let updated = updatePeripheral(peripheralUUID) {
|
||||
previousPeerID = $0.peerID
|
||||
$0.peerID = peerID
|
||||
if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil {
|
||||
peerToPeripheralUUID[peerID] = peripheralUUID
|
||||
}
|
||||
guard updated != nil else { return }
|
||||
// Rebinding (peer-ID rotation): drop the retired ID's reverse mapping
|
||||
// so the old peer no longer claims this link.
|
||||
if let previousPeerID, previousPeerID != peerID,
|
||||
peerToPeripheralUUID[previousPeerID] == peripheralUUID {
|
||||
peerToPeripheralUUID.removeValue(forKey: previousPeerID)
|
||||
}
|
||||
peerToPeripheralUUID[peerID] = peripheralUUID
|
||||
}
|
||||
|
||||
func removePeripheral(_ peripheralID: String) -> PeerID? {
|
||||
|
||||
@@ -25,4 +25,9 @@ final class BLELogRateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
queue.sync {
|
||||
lastLogTimeByKey.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,12 @@ enum BLEOutboundPacketPolicy {
|
||||
switch MessageType(rawValue: packetType) {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
return true
|
||||
// voiceFrame is deliberately unpadded: padding to the 512 block would
|
||||
// push every ~490-byte signed voice packet over the MTU into the
|
||||
// fragment path.
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame:
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func priority(for packet: BitchatPacket, data _: Data) -> BLEOutboundWritePriority {
|
||||
static func priority(for packet: BitchatPacket, data: Data) -> BLEOutboundWritePriority {
|
||||
guard let messageType = MessageType(rawValue: packet.type) else { return .low }
|
||||
switch messageType {
|
||||
case .fragment:
|
||||
|
||||
@@ -10,8 +10,6 @@ struct BLEPeerInfo: Equatable {
|
||||
var isVerifiedNickname: Bool
|
||||
var lastSeen: Date
|
||||
var capabilities: PeerCapabilities = []
|
||||
/// Rendezvous cell from the peer's announce when it advertises `.bridge`.
|
||||
var bridgeGeohash: String?
|
||||
}
|
||||
|
||||
struct BLEPeerAnnounceUpdate: Equatable {
|
||||
@@ -114,20 +112,6 @@ struct BLEPeerRegistry {
|
||||
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)
|
||||
}
|
||||
|
||||
/// A rendezvous cell advertised by any bridge-capable peer, if one is
|
||||
/// known — lets location-less devices join the island's rendezvous.
|
||||
func advertisedBridgeGeohash() -> String? {
|
||||
peers.values
|
||||
.filter { $0.capabilities.contains(.bridge) }
|
||||
.compactMap(\.bridgeGeohash)
|
||||
.first
|
||||
}
|
||||
|
||||
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
||||
let connected = peers.filter { $0.value.isConnected }
|
||||
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
||||
@@ -152,6 +136,14 @@ struct BLEPeerRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
func collisionResolvedNickname(for peerID: PeerID, selfNickname: String) -> String? {
|
||||
guard let info = peers[peerID], info.isVerifiedNickname else { return nil }
|
||||
let hasCollision = peers.values.contains {
|
||||
$0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID
|
||||
} || selfNickname == info.nickname
|
||||
return hasCollision ? info.nickname + "#" + String(peerID.id.prefix(4)) : info.nickname
|
||||
}
|
||||
|
||||
mutating func markDisconnected(_ peerID: PeerID) {
|
||||
guard var info = peers[peerID] else { return }
|
||||
info.isConnected = false
|
||||
@@ -171,8 +163,7 @@ struct BLEPeerRegistry {
|
||||
signingPublicKey: Data?,
|
||||
isConnected: Bool,
|
||||
now: Date,
|
||||
capabilities: PeerCapabilities = [],
|
||||
bridgeGeohash: String? = nil
|
||||
capabilities: PeerCapabilities = []
|
||||
) -> BLEPeerAnnounceUpdate {
|
||||
let existing = peers[peerID]
|
||||
let update = BLEPeerAnnounceUpdate(
|
||||
@@ -189,8 +180,7 @@ struct BLEPeerRegistry {
|
||||
signingPublicKey: signingPublicKey,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: now,
|
||||
capabilities: capabilities,
|
||||
bridgeGeohash: bridgeGeohash
|
||||
capabilities: capabilities
|
||||
)
|
||||
|
||||
return update
|
||||
|
||||
@@ -51,26 +51,13 @@ struct BLEReceivePipeline {
|
||||
// Courier envelopes are directed opaque ciphertext like DMs; a
|
||||
// remote handover toward a relayed announce rides this same
|
||||
// deterministic relay treatment instead of the broadcast clamp.
|
||||
// Ping/pong diagnostics ride it too: probes need the same
|
||||
// deterministic multi-hop relay as DMs (always relay, jitter,
|
||||
// no TTL cap) so RTT and hop counts reflect the real path.
|
||||
// Directed nostrCarrier uplinks (mesh-only peer -> gateway) need
|
||||
// the same multi-hop treatment to reach a non-adjacent gateway.
|
||||
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|
||||
|| packet.type == MessageType.courierEnvelope.rawValue
|
||||
|| packet.type == MessageType.ping.rawValue
|
||||
|| packet.type == MessageType.pong.rawValue
|
||||
|| packet.type == MessageType.nostrCarrier.rawValue) && packet.recipientID != nil,
|
||||
|| packet.type == MessageType.courierEnvelope.rawValue) && packet.recipientID != nil,
|
||||
isFragment: packet.type == MessageType.fragment.rawValue,
|
||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||
isAnnounce: packet.type == MessageType.announce.rawValue,
|
||||
isRequestSync: packet.type == MessageType.requestSync.rawValue,
|
||||
// Board posts relay like broadcast messages; urgent ones get the
|
||||
// announce-class TTL headroom so alerts travel the extra hop.
|
||||
isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue
|
||||
&& BoardWire.urgentFlag(in: packet.payload),
|
||||
isVoiceFrame: packet.type == MessageType.voiceFrame.rawValue,
|
||||
degree: degree,
|
||||
highDegreeThreshold: highDegreeThreshold
|
||||
)
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Remembers recently seen bitchat peripherals (fresh discoveries and dropped
|
||||
/// links) so the service can arm pending background connections against them
|
||||
/// when the app leaves the foreground. Generic over the peripheral type so
|
||||
/// the eviction/expiry logic is testable without CoreBluetooth.
|
||||
final class BLERecentPeripheralCache<Peripheral> {
|
||||
private struct Entry {
|
||||
let peripheral: Peripheral
|
||||
var lastSeen: Date
|
||||
}
|
||||
|
||||
private var entries: [String: Entry] = [:]
|
||||
private let capacity: Int
|
||||
private let maxAge: TimeInterval
|
||||
|
||||
init(
|
||||
capacity: Int = TransportConfig.bleRecentPeripheralCacheCap,
|
||||
maxAge: TimeInterval = TransportConfig.bleRecentPeripheralMaxAgeSeconds
|
||||
) {
|
||||
self.capacity = capacity
|
||||
self.maxAge = maxAge
|
||||
}
|
||||
|
||||
var count: Int { entries.count }
|
||||
|
||||
func record(_ peripheral: Peripheral, peripheralID: String, at now: Date) {
|
||||
entries[peripheralID] = Entry(peripheral: peripheral, lastSeen: now)
|
||||
guard entries.count > capacity else { return }
|
||||
// Inserts overshoot capacity by at most one; evict the stalest entry
|
||||
if let stalest = entries.min(by: { $0.value.lastSeen < $1.value.lastSeen }) {
|
||||
entries.removeValue(forKey: stalest.key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Most-recently-seen peripherals eligible for a pending background
|
||||
/// connect, freshest first, capped at `limit`. Expired entries are
|
||||
/// pruned as a side effect.
|
||||
func reconnectTargets(
|
||||
now: Date,
|
||||
limit: Int,
|
||||
excluding: (String) -> Bool
|
||||
) -> [(peripheralID: String, peripheral: Peripheral)] {
|
||||
let cutoff = now.addingTimeInterval(-maxAge)
|
||||
entries = entries.filter { $0.value.lastSeen >= cutoff }
|
||||
guard limit > 0 else { return [] }
|
||||
return entries
|
||||
.filter { !excluding($0.key) }
|
||||
.sorted { $0.value.lastSeen > $1.value.lastSeen }
|
||||
.prefix(limit)
|
||||
.map { (peripheralID: $0.key, peripheral: $0.value.peripheral) }
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Tracks whether source-routed sends to a recipient appear to be working.
|
||||
///
|
||||
/// A routed unicast rides exactly one path, so a broken hop silently loses the
|
||||
/// packet where a flood would have healed around it. Rather than building a
|
||||
/// retransmission machine (MessageRouter already retries at a higher layer),
|
||||
/// this cache degrades: a routed send that sees no inbound traffic from the
|
||||
/// recipient within the confirmation window marks the route as failed, and
|
||||
/// subsequent sends fall back to flooding until the suppression TTL lapses.
|
||||
struct BLESourceRouteFailureCache {
|
||||
struct Config {
|
||||
/// How long a routed send may go unconfirmed before it counts as a
|
||||
/// route failure.
|
||||
var confirmationWindowSeconds: TimeInterval = TransportConfig.bleSourceRouteConfirmationWindowSeconds
|
||||
/// How long to flood instead of routing after a failure.
|
||||
var suppressionSeconds: TimeInterval = TransportConfig.bleSourceRouteSuppressionSeconds
|
||||
}
|
||||
|
||||
private struct State {
|
||||
var pendingSince: Date?
|
||||
var suppressedUntil: Date?
|
||||
}
|
||||
|
||||
private let config: Config
|
||||
private var states: [PeerID: State] = [:]
|
||||
|
||||
init(config: Config = Config()) {
|
||||
self.config = config
|
||||
}
|
||||
|
||||
/// Whether the next directed send to `recipient` may carry a source
|
||||
/// route. Flips the recipient into suppression when the last routed send
|
||||
/// went unconfirmed past the confirmation window.
|
||||
mutating func shouldAttemptRoute(to recipient: PeerID, now: Date = Date()) -> Bool {
|
||||
guard var state = states[recipient] else { return true }
|
||||
|
||||
if let until = state.suppressedUntil {
|
||||
guard now >= until else { return false }
|
||||
state.suppressedUntil = nil
|
||||
}
|
||||
|
||||
if let pending = state.pendingSince,
|
||||
now.timeIntervalSince(pending) > config.confirmationWindowSeconds {
|
||||
// The routed send was never confirmed: treat the route as broken
|
||||
// and flood until the suppression window lapses.
|
||||
state.pendingSince = nil
|
||||
state.suppressedUntil = now.addingTimeInterval(config.suppressionSeconds)
|
||||
states[recipient] = state
|
||||
return false
|
||||
}
|
||||
|
||||
states[recipient] = state
|
||||
return true
|
||||
}
|
||||
|
||||
/// Records that a source-routed packet was sent to `recipient`. Keeps the
|
||||
/// earliest unconfirmed send so back-to-back packets share one deadline.
|
||||
mutating func noteRoutedSend(to recipient: PeerID, now: Date = Date()) {
|
||||
var state = states[recipient] ?? State()
|
||||
if state.pendingSince == nil {
|
||||
state.pendingSince = now
|
||||
}
|
||||
states[recipient] = state
|
||||
}
|
||||
|
||||
/// Any inbound packet authored by `peer` confirms the pending routed send
|
||||
/// (delivery acks and replies arrive this way). Deliberately does not
|
||||
/// lift an active suppression: that traffic may have arrived via flood.
|
||||
mutating func noteInboundActivity(from peer: PeerID) {
|
||||
guard var state = states[peer] else { return }
|
||||
state.pendingSince = nil
|
||||
if state.suppressedUntil == nil {
|
||||
states.removeValue(forKey: peer)
|
||||
} else {
|
||||
states[peer] = state
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops entries that can no longer influence a routing decision. An
|
||||
/// expired-but-unconverted pending entry is kept for as long as the
|
||||
/// suppression it would trigger could still be active.
|
||||
mutating func prune(now: Date = Date()) {
|
||||
let pendingRetention = config.confirmationWindowSeconds + config.suppressionSeconds
|
||||
states = states.filter { _, state in
|
||||
if let until = state.suppressedUntil, now < until { return true }
|
||||
if let pending = state.pendingSince,
|
||||
now.timeIntervalSince(pending) <= pendingRetention {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Decides whether an outbound directed packet should carry a v2 source
|
||||
/// route. Pure gating logic so BLEService's hot send path stays a thin wire.
|
||||
enum BLESourceRouteOriginationPolicy {
|
||||
/// Returns the intermediate-hop route to attach, or nil to keep the
|
||||
/// current flood/direct-write behavior unchanged.
|
||||
///
|
||||
/// Routes are only originated when every gate passes:
|
||||
/// - we authored the packet (relays must not rewrite and re-sign someone
|
||||
/// else's packet; route-following for in-flight routed packets lives in
|
||||
/// `BLERouteForwardingPolicy`),
|
||||
/// - the packet is directed at a single peer (not broadcast),
|
||||
/// - the packet has TTL headroom to traverse hops (link-local TTL-0
|
||||
/// packets like REQUEST_SYNC never route),
|
||||
/// - the recipient is not directly connected (a direct write already
|
||||
/// delivers in one hop),
|
||||
/// - routing to the recipient is not suppressed by a recent unconfirmed
|
||||
/// routed send, and
|
||||
/// - the topology yields a complete path.
|
||||
static func route(
|
||||
for packet: BitchatPacket,
|
||||
to recipient: PeerID,
|
||||
localPeerIDData: Data,
|
||||
isRecipientConnected: (PeerID) -> Bool,
|
||||
shouldAttemptRoute: (PeerID) -> Bool,
|
||||
computeRoute: (PeerID) -> [Data]?
|
||||
) -> [Data]? {
|
||||
guard packet.senderID == localPeerIDData else { return nil }
|
||||
guard let recipientData = packet.recipientID,
|
||||
recipientData.count == 8,
|
||||
!recipientData.allSatisfy({ $0 == 0xFF }) else { return nil }
|
||||
guard packet.ttl > 1 else { return nil }
|
||||
guard !isRecipientConnected(recipient) else { return nil }
|
||||
guard shouldAttemptRoute(recipient) else { return nil }
|
||||
guard let route = computeRoute(recipient), !route.isEmpty else { return nil }
|
||||
return route
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
//
|
||||
// BoardAlertsModel.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// Turns newly arriving board posts into local, scope-matched chat alerts.
|
||||
/// Everything here is derived from posts the mesh already synced — no extra
|
||||
/// wire traffic, nothing another peer can't already see.
|
||||
///
|
||||
/// - Urgent, recent pins get one system line in the matching chat (geo pin →
|
||||
/// that geohash's timeline, mesh pin → mesh chat), collapsed when several
|
||||
/// arrive together.
|
||||
/// - Every other new pin just marks the header's pin icon until the notices
|
||||
/// sheet is opened.
|
||||
@MainActor
|
||||
final class BoardAlertsModel: ObservableObject {
|
||||
struct Dependencies {
|
||||
/// Own posts never alert; the author already knows.
|
||||
var isOwnPost: @MainActor (BoardPostPacket) -> Bool
|
||||
/// Appends a local system line to a scope's chat timeline
|
||||
/// (geohash, or "" for mesh chat).
|
||||
var emitSystemLine: @MainActor (_ content: String, _ geohash: String) -> Void
|
||||
var now: () -> Date = Date.init
|
||||
/// Schedules the collapsed flush of pending urgent alerts; tests
|
||||
/// inject a synchronous hook.
|
||||
var scheduleFlush: (_ flush: @escaping @MainActor () -> Void) -> Void = { flush in
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: UInt64(BoardAlertsModel.collapseDelaySeconds * 1_000_000_000))
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Posts older than this at arrival are backfilled history carried in by
|
||||
/// a peer, not something happening now; they badge but never line the chat.
|
||||
static let inlineRecencyWindow: TimeInterval = 30 * 60
|
||||
/// Urgent arrivals within this window collapse into one line.
|
||||
static let collapseDelaySeconds: TimeInterval = 4
|
||||
private static let alertContentMaxChars = 120
|
||||
|
||||
/// Unseen new pins by postID (hex) → geohash scope, cleared when the
|
||||
/// notices sheet opens.
|
||||
@Published private(set) var unseenPostScopes: [String: String] = [:]
|
||||
|
||||
/// PostIDs already handled this session, so store eviction/re-sync churn
|
||||
/// can't re-alert. Bounded by session wire volume (32-byte strings).
|
||||
private var handledPostIDs = Set<String>()
|
||||
private var pendingUrgent: [String: [BoardPostPacket]] = [:]
|
||||
private var flushScheduled = false
|
||||
private let dependencies: Dependencies
|
||||
private var cancellable: AnyCancellable?
|
||||
private var wipeCancellable: AnyCancellable?
|
||||
|
||||
private enum Strings {
|
||||
static func urgentSingle(author: String, content: String) -> String {
|
||||
String(
|
||||
format: String(localized: "notices.alert.urgent_single", defaultValue: "📌 urgent notice from @%@: %@", comment: "Local chat line when one urgent notice is pinned nearby"),
|
||||
locale: .current,
|
||||
author, content
|
||||
)
|
||||
}
|
||||
|
||||
static func urgentCollapsed(_ count: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "notices.alert.urgent_collapsed", defaultValue: "📌 %lld new urgent notices — tap the pin to view", comment: "Local chat line when several urgent notices arrive together"),
|
||||
locale: .current,
|
||||
count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
arrivals: AnyPublisher<BoardPostPacket, Never>,
|
||||
wipes: AnyPublisher<Void, Never> = Empty(completeImmediately: false).eraseToAnyPublisher(),
|
||||
dependencies: Dependencies
|
||||
) {
|
||||
self.dependencies = dependencies
|
||||
cancellable = arrivals
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] post in
|
||||
self?.handleArrival(post)
|
||||
}
|
||||
wipeCancellable = wipes
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] in
|
||||
self?.reset()
|
||||
}
|
||||
}
|
||||
|
||||
func unseenCount(forGeohash geohash: String) -> Int {
|
||||
unseenPostScopes.values.reduce(0) { $0 + ($1 == geohash ? 1 : 0) }
|
||||
}
|
||||
|
||||
/// Marks pins in the given scopes as seen — only the scopes the notices
|
||||
/// sheet actually shows, so unseen pins for other geohash channels keep
|
||||
/// their badge until visited.
|
||||
func markSeen(forScopes scopes: Set<String>) {
|
||||
guard unseenPostScopes.contains(where: { scopes.contains($0.value) }) else { return }
|
||||
unseenPostScopes = unseenPostScopes.filter { !scopes.contains($0.value) }
|
||||
}
|
||||
|
||||
/// Panic wipe: drop everything derived from pre-wipe posts, including
|
||||
/// urgent lines still waiting on the collapse flush.
|
||||
func reset() {
|
||||
pendingUrgent.removeAll()
|
||||
handledPostIDs.removeAll()
|
||||
guard !unseenPostScopes.isEmpty else { return }
|
||||
unseenPostScopes.removeAll()
|
||||
}
|
||||
|
||||
func handleArrival(_ post: BoardPostPacket) {
|
||||
let postID = post.postID.hexEncodedString()
|
||||
guard !handledPostIDs.contains(postID) else { return }
|
||||
handledPostIDs.insert(postID)
|
||||
guard !dependencies.isOwnPost(post) else { return }
|
||||
|
||||
unseenPostScopes[postID] = post.geohash
|
||||
|
||||
let createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000)
|
||||
guard post.isUrgent,
|
||||
dependencies.now().timeIntervalSince(createdAt) <= Self.inlineRecencyWindow else {
|
||||
return
|
||||
}
|
||||
pendingUrgent[post.geohash, default: []].append(post)
|
||||
if !flushScheduled {
|
||||
flushScheduled = true
|
||||
dependencies.scheduleFlush { [weak self] in
|
||||
self?.flushPendingUrgent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func flushPendingUrgent() {
|
||||
flushScheduled = false
|
||||
let pending = pendingUrgent
|
||||
pendingUrgent.removeAll()
|
||||
for (geohash, posts) in pending {
|
||||
guard let first = posts.first else { continue }
|
||||
let line: String
|
||||
if posts.count == 1 {
|
||||
let author = first.authorNickname.trimmedOrNilIfEmpty ?? "anon"
|
||||
line = Strings.urgentSingle(author: author, content: Self.truncated(first.content))
|
||||
} else {
|
||||
line = Strings.urgentCollapsed(posts.count)
|
||||
}
|
||||
dependencies.emitSystemLine(line, geohash)
|
||||
}
|
||||
}
|
||||
|
||||
private static func truncated(_ content: String) -> String {
|
||||
guard content.count > alertContentMaxChars else { return content }
|
||||
return content.prefix(alertContentMaxChars) + "…"
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
//
|
||||
// BoardManager.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// UI-facing coordinator for the bulletin board: builds and signs posts and
|
||||
/// tombstones with the device's Noise signing key, hands them to the mesh
|
||||
/// transport, and mirrors the store's live posts for SwiftUI.
|
||||
@MainActor
|
||||
final class BoardManager: ObservableObject {
|
||||
/// Live posts across all boards, newest state from the store.
|
||||
@Published private(set) var posts: [BoardPostPacket] = []
|
||||
|
||||
private let transport: Transport
|
||||
/// Publishes a bridged kind-1 note (expiring with the board post via
|
||||
/// NIP-40) and returns its Nostr event id, or nil when bridging failed or
|
||||
/// was skipped.
|
||||
private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String, _ expiresAtMs: UInt64, _ urgent: Bool) -> String?
|
||||
/// Requests NIP-09 deletion of a previously bridged note.
|
||||
private let deleteFromNostr: (_ eventID: String, _ geohash: String) -> Void
|
||||
/// Bridged Nostr event ids by postID, for merged deletes. In-memory only:
|
||||
/// after a relaunch a delete still tombstones the board copy, but the
|
||||
/// Nostr copy is left to expire with relay retention.
|
||||
private var bridgedEventIDs: [Data: String] = [:]
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
init(
|
||||
transport: Transport,
|
||||
store: BoardStore = .shared,
|
||||
publishToNostr: ((String, String, String, UInt64, Bool) -> String?)? = nil,
|
||||
deleteFromNostr: ((String, String) -> Void)? = nil
|
||||
) {
|
||||
self.transport = transport
|
||||
self.publishToNostr = publishToNostr ?? Self.livePublishToNostr
|
||||
self.deleteFromNostr = deleteFromNostr ?? Self.liveDeleteFromNostr
|
||||
cancellable = store.$postsSnapshot
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] snapshot in
|
||||
self?.posts = snapshot
|
||||
}
|
||||
}
|
||||
|
||||
/// Posts for one board context, urgent first, then newest first.
|
||||
func posts(forGeohash geohash: String) -> [BoardPostPacket] {
|
||||
posts
|
||||
.filter { $0.geohash == geohash }
|
||||
.sorted {
|
||||
if $0.isUrgent != $1.isUrgent { return $0.isUrgent }
|
||||
return $0.createdAt > $1.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
func isOwnPost(_ post: BoardPostPacket) -> Bool {
|
||||
let key = transport.noiseSigningPublicKeyData()
|
||||
return !key.isEmpty && key == post.authorSigningKey
|
||||
}
|
||||
|
||||
/// Creates, signs, and broadcasts a board post. Returns false when the
|
||||
/// content is empty/oversized or signing fails.
|
||||
@discardableResult
|
||||
func createPost(
|
||||
content: String,
|
||||
geohash: String,
|
||||
urgent: Bool,
|
||||
expiryDays: Int,
|
||||
nickname: String
|
||||
) -> Bool {
|
||||
guard let trimmed = content.trimmedOrNilIfEmpty,
|
||||
trimmed.utf8.count <= BoardWireConstants.contentMaxBytes else {
|
||||
return false
|
||||
}
|
||||
let signingKey = transport.noiseSigningPublicKeyData()
|
||||
guard signingKey.count == BoardWireConstants.signingKeyLength else { return false }
|
||||
|
||||
var cleanNickname = nickname
|
||||
while cleanNickname.utf8.count > BoardWireConstants.nicknameMaxBytes {
|
||||
cleanNickname.removeLast()
|
||||
}
|
||||
let createdAt = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let lifetimeMs = min(
|
||||
UInt64(max(1, expiryDays)) * 24 * 60 * 60 * 1000,
|
||||
BoardWireConstants.maxLifetimeMs
|
||||
)
|
||||
let expiresAt = createdAt + lifetimeMs
|
||||
let flags: UInt8 = urgent ? BoardPostPacket.urgentFlag : 0
|
||||
var postID = Data(count: BoardWireConstants.postIDLength)
|
||||
let status = postID.withUnsafeMutableBytes { buffer -> Int32 in
|
||||
guard let base = buffer.baseAddress else { return -1 }
|
||||
return SecRandomCopyBytes(kSecRandomDefault, buffer.count, base)
|
||||
}
|
||||
guard status == errSecSuccess else { return false }
|
||||
|
||||
let signingBytes = BoardPostPacket.signingBytes(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: trimmed,
|
||||
authorSigningKey: signingKey,
|
||||
authorNickname: cleanNickname,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: flags
|
||||
)
|
||||
guard let signature = transport.noiseSignData(signingBytes) else {
|
||||
SecureLogger.error("Board: failed to sign post", category: .session)
|
||||
return false
|
||||
}
|
||||
let post = BoardPostPacket(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: trimmed,
|
||||
authorSigningKey: signingKey,
|
||||
authorNickname: cleanNickname,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: flags,
|
||||
signature: signature
|
||||
)
|
||||
transport.sendBoardPayload(BoardWire.post(post).encode())
|
||||
|
||||
// Nostr bridge: geohash posts also go out as kind-1 location notes so
|
||||
// online users see them. Remember the event id for merged deletes.
|
||||
if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt, urgent) {
|
||||
bridgedEventIDs[postID] = eventID
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Signs and broadcasts a tombstone for one of our own posts.
|
||||
@discardableResult
|
||||
func deletePost(_ post: BoardPostPacket) -> Bool {
|
||||
guard isOwnPost(post) else { return false }
|
||||
let deletedAt = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let signingBytes = BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt)
|
||||
guard let signature = transport.noiseSignData(signingBytes) else {
|
||||
SecureLogger.error("Board: failed to sign tombstone", category: .session)
|
||||
return false
|
||||
}
|
||||
let tombstone = BoardTombstonePacket(
|
||||
postID: post.postID,
|
||||
authorSigningKey: post.authorSigningKey,
|
||||
deletedAt: deletedAt,
|
||||
signature: signature
|
||||
)
|
||||
transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode())
|
||||
|
||||
// Merged delete: also retract the bridged Nostr copy when we still
|
||||
// know its event id.
|
||||
if !post.geohash.isEmpty, let eventID = bridgedEventIDs.removeValue(forKey: post.postID) {
|
||||
deleteFromNostr(eventID, post.geohash)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private static func livePublishToNostr(content: String, geohash: String, nickname: String, expiresAtMs: UInt64, urgent: Bool) -> String? {
|
||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
||||
guard !relays.isEmpty else {
|
||||
SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session)
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
|
||||
let event = try NostrProtocol.createGeohashTextNote(
|
||||
content: content,
|
||||
geohash: geohash,
|
||||
senderIdentity: identity,
|
||||
nickname: nickname,
|
||||
expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000),
|
||||
urgent: urgent
|
||||
)
|
||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||
return event.id
|
||||
} catch {
|
||||
SecureLogger.error("Board: failed to bridge post to Nostr: \(error)", category: .session)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func liveDeleteFromNostr(eventID: String, geohash: String) {
|
||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
||||
guard !relays.isEmpty else { return }
|
||||
do {
|
||||
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
|
||||
let deletion = try NostrProtocol.createDeleteEvent(ofEventID: eventID, senderIdentity: identity)
|
||||
NostrRelayManager.shared.sendEvent(deletion, to: relays)
|
||||
} catch {
|
||||
SecureLogger.error("Board: failed to delete bridged Nostr note: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
//
|
||||
// BoardStore.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
|
||||
|
||||
/// Outcome of feeding a board packet into the store, so the transport can
|
||||
/// decide whether the packet is still worth relaying.
|
||||
enum BoardIngestResult {
|
||||
/// New post or tombstone accepted (or a quota rejected it locally while
|
||||
/// it remains valid for other devices).
|
||||
case accepted
|
||||
/// Already known; nothing changed.
|
||||
case duplicate
|
||||
/// Invalid, expired, or deleted; do not relay.
|
||||
case rejected
|
||||
}
|
||||
|
||||
/// Persistent storage for bulletin-board posts and their tombstones.
|
||||
///
|
||||
/// Posts are signed public notices designed to outlive chat: they stay on
|
||||
/// disk until their author-chosen expiry (max 7 days) and re-enter gossip
|
||||
/// sync after a restart. Tombstones are retained until the deleted post's
|
||||
/// original expiry so the delete keeps outrunning stale copies of the post.
|
||||
///
|
||||
/// The on-disk format is the raw signed packets themselves (like
|
||||
/// `GossipMessageArchive`); state is rebuilt by re-verifying and re-ingesting
|
||||
/// them on launch. Wiped on panic.
|
||||
final class BoardStore {
|
||||
enum Limits {
|
||||
static let maxPosts = 200
|
||||
static let maxPostsPerAuthor = 5
|
||||
/// Retention for a tombstone whose post we never saw: we cannot know
|
||||
/// the original expiry, so cap at the max post lifetime.
|
||||
static let orphanTombstoneLifetimeMs = BoardWireConstants.maxLifetimeMs
|
||||
/// Orphan tombstones name posts nobody here has seen, so their volume
|
||||
/// is entirely sender-controlled; cap them like posts.
|
||||
static let maxOrphanTombstones = 100
|
||||
static let maxOrphanTombstonesPerAuthor = 5
|
||||
/// Allowance for clock skew between peers when judging received
|
||||
/// timestamps against local time.
|
||||
static let clockSkewMs: UInt64 = 60 * 60 * 1000
|
||||
}
|
||||
|
||||
private struct StoredPost {
|
||||
let post: BoardPostPacket
|
||||
let packet: BitchatPacket
|
||||
let rawPacket: Data
|
||||
}
|
||||
|
||||
private struct StoredTombstone {
|
||||
let tombstone: BoardTombstonePacket
|
||||
let packet: BitchatPacket
|
||||
let rawPacket: Data
|
||||
let retainUntil: UInt64
|
||||
/// True when no matching post was known at ingest time; only these
|
||||
/// count against the orphan caps.
|
||||
let isOrphan: Bool
|
||||
}
|
||||
|
||||
/// On-disk entry: the raw signed packet, plus the retention deadline for
|
||||
/// tombstones (derived from the deleted post's original expiry, which is
|
||||
/// no longer recoverable once the post is gone).
|
||||
private struct PersistedEntry: Codable {
|
||||
let packet: Data
|
||||
let retainUntil: UInt64?
|
||||
}
|
||||
|
||||
static let shared = BoardStore()
|
||||
|
||||
/// Live posts, published on the main thread for the board UI.
|
||||
@Published private(set) var postsSnapshot: [BoardPostPacket] = []
|
||||
|
||||
/// Fires on the main thread for each post newly accepted from the wire
|
||||
/// (radio, sync, or local echo) — not for disk restores. Drives the
|
||||
/// local new-pin chat alerts; duplicates never fire twice because the
|
||||
/// store rejects them.
|
||||
let postArrivals = PassthroughSubject<BoardPostPacket, Never>()
|
||||
|
||||
/// Fires on the main thread after a panic wipe so derived state (pending
|
||||
/// alerts, unseen badges) is dropped along with the posts themselves.
|
||||
let didWipe = PassthroughSubject<Void, Never>()
|
||||
|
||||
private var posts: [StoredPost] = []
|
||||
private var tombstones: [StoredTombstone] = []
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.board.store")
|
||||
private let fileURL: URL?
|
||||
private let now: () -> Date
|
||||
|
||||
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
|
||||
/// when `persistsToDisk` is false.
|
||||
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
|
||||
self.now = now
|
||||
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
// MARK: - Ingest
|
||||
|
||||
/// Ingest a board packet whose payload decodes to `wire`. The caller must
|
||||
/// have verified the wire signature already (`BoardWire.verifySignature`).
|
||||
@discardableResult
|
||||
func ingest(_ wire: BoardWire, packet: BitchatPacket) -> BoardIngestResult {
|
||||
guard let rawPacket = packet.toBinaryData(padding: false) else { return .rejected }
|
||||
let nowMs = currentMs()
|
||||
return queue.sync {
|
||||
let result = ingestLocked(wire, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
|
||||
if result == .accepted {
|
||||
persistLocked()
|
||||
if case .post(let post) = wire {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.postArrivals.send(post)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reads
|
||||
|
||||
/// Live posts scoped to one board (geohash, or "" for the mesh board).
|
||||
func posts(forGeohash geohash: String) -> [BoardPostPacket] {
|
||||
let nowMs = currentMs()
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(nowMs: nowMs)
|
||||
return posts.map(\.post).filter { $0.geohash == geohash }
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw signed packets (posts and live tombstones) for gossip sync rounds.
|
||||
func syncCandidates() -> [BitchatPacket] {
|
||||
let nowMs = currentMs()
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(nowMs: nowMs)
|
||||
return posts.map(\.packet) + tombstones.map(\.packet)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Maintenance
|
||||
|
||||
/// Panic wipe: drop all board data from memory and disk.
|
||||
func wipe() {
|
||||
queue.sync {
|
||||
posts.removeAll()
|
||||
tombstones.removeAll()
|
||||
if let fileURL {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
publishSnapshotLocked()
|
||||
}
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.didWipe.send()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals (call only on `queue`)
|
||||
|
||||
private func ingestLocked(
|
||||
_ wire: BoardWire,
|
||||
packet: BitchatPacket,
|
||||
rawPacket: Data,
|
||||
nowMs: UInt64,
|
||||
retainUntilOverride: UInt64? = nil
|
||||
) -> BoardIngestResult {
|
||||
pruneExpiredLocked(nowMs: nowMs)
|
||||
switch wire {
|
||||
case .post(let post):
|
||||
return ingestPostLocked(post, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
|
||||
case .tombstone(let tombstone):
|
||||
return ingestTombstoneLocked(tombstone, packet: packet, rawPacket: rawPacket, nowMs: nowMs, retainUntilOverride: retainUntilOverride)
|
||||
}
|
||||
}
|
||||
|
||||
private func ingestPostLocked(_ post: BoardPostPacket, packet: BitchatPacket, rawPacket: Data, nowMs: UInt64) -> BoardIngestResult {
|
||||
guard post.expiresAt > nowMs else { return .rejected }
|
||||
// Receive-time sanity (this is the single chokepoint for radio, sync,
|
||||
// and disk restores): the decoder only enforces the createdAt to
|
||||
// expiresAt span, so a forged future createdAt would sort ahead of
|
||||
// honest posts and hold a store slot without ever pruning.
|
||||
guard post.createdAt <= nowMs &+ Limits.clockSkewMs,
|
||||
post.expiresAt <= nowMs &+ BoardWireConstants.maxLifetimeMs &+ Limits.clockSkewMs else {
|
||||
return .rejected
|
||||
}
|
||||
if tombstones.contains(where: { $0.tombstone.postID == post.postID && $0.tombstone.authorSigningKey == post.authorSigningKey }) {
|
||||
return .rejected
|
||||
}
|
||||
guard !posts.contains(where: { $0.post.postID == post.postID }) else { return .duplicate }
|
||||
|
||||
posts.append(StoredPost(post: post, packet: packet, rawPacket: rawPacket))
|
||||
|
||||
// Per-author cap, then global cap; oldest posts are evicted first.
|
||||
let authorPosts = posts.filter { $0.post.authorSigningKey == post.authorSigningKey }
|
||||
if authorPosts.count > Limits.maxPostsPerAuthor {
|
||||
evictOldestLocked(from: authorPosts, keep: Limits.maxPostsPerAuthor)
|
||||
}
|
||||
if posts.count > Limits.maxPosts {
|
||||
evictOldestLocked(from: posts, keep: Limits.maxPosts)
|
||||
}
|
||||
publishSnapshotLocked()
|
||||
// Even when the new post itself was the eviction victim it stays
|
||||
// valid mesh-wide; peers with room should still receive it.
|
||||
return .accepted
|
||||
}
|
||||
|
||||
private func ingestTombstoneLocked(
|
||||
_ tombstone: BoardTombstonePacket,
|
||||
packet: BitchatPacket,
|
||||
rawPacket: Data,
|
||||
nowMs: UInt64,
|
||||
retainUntilOverride: UInt64? = nil
|
||||
) -> BoardIngestResult {
|
||||
guard !tombstones.contains(where: { $0.tombstone.postID == tombstone.postID }) else { return .duplicate }
|
||||
|
||||
// Cap retention by both the claimed deletion time (so a doctored file
|
||||
// cannot pin a tombstone past any legal expiry) and the receive time:
|
||||
// deletedAt is sender-chosen, so a far-future value must not retain
|
||||
// the tombstone longer than any post still able to arrive could live.
|
||||
let maxRetain = min(
|
||||
tombstone.deletedAt &+ Limits.orphanTombstoneLifetimeMs,
|
||||
nowMs &+ Limits.orphanTombstoneLifetimeMs &+ Limits.clockSkewMs
|
||||
)
|
||||
let retainUntil: UInt64
|
||||
let isOrphan: Bool
|
||||
if let index = posts.firstIndex(where: { $0.post.postID == tombstone.postID }) {
|
||||
let target = posts[index].post
|
||||
// Only the author's key can delete: the tombstone signature was
|
||||
// already verified against its embedded key, so it suffices to
|
||||
// require that key to be the post's author key.
|
||||
guard target.authorSigningKey == tombstone.authorSigningKey else { return .rejected }
|
||||
retainUntil = target.expiresAt
|
||||
isOrphan = false
|
||||
posts.remove(at: index)
|
||||
publishSnapshotLocked()
|
||||
} else if let retainUntilOverride {
|
||||
// Restored from disk: the post is long gone, so trust the
|
||||
// retention deadline recorded when the delete was first applied.
|
||||
// Orphans were already capped when first ingested off the air.
|
||||
retainUntil = min(retainUntilOverride, maxRetain)
|
||||
isOrphan = false
|
||||
} else {
|
||||
// Post unknown (tombstone raced ahead); keep it around so the
|
||||
// post is suppressed if it arrives later.
|
||||
retainUntil = maxRetain
|
||||
isOrphan = true
|
||||
}
|
||||
guard retainUntil > nowMs else { return .rejected }
|
||||
tombstones.append(StoredTombstone(tombstone: tombstone, packet: packet, rawPacket: rawPacket, retainUntil: retainUntil, isOrphan: isOrphan))
|
||||
if isOrphan {
|
||||
enforceOrphanTombstoneCapsLocked(author: tombstone.authorSigningKey)
|
||||
}
|
||||
// Like posts, a locally evicted tombstone stays valid mesh-wide.
|
||||
return .accepted
|
||||
}
|
||||
|
||||
/// Orphan tombstones reference posts we never saw, so a peer can mint
|
||||
/// unlimited valid ones for random IDs; bound them per author and
|
||||
/// globally, evicting the oldest received first (array order).
|
||||
private func enforceOrphanTombstoneCapsLocked(author: Data) {
|
||||
let authorOrphans = tombstones.filter { $0.isOrphan && $0.tombstone.authorSigningKey == author }
|
||||
if authorOrphans.count > Limits.maxOrphanTombstonesPerAuthor {
|
||||
removeTombstonesLocked(authorOrphans.prefix(authorOrphans.count - Limits.maxOrphanTombstonesPerAuthor))
|
||||
}
|
||||
let orphans = tombstones.filter(\.isOrphan)
|
||||
if orphans.count > Limits.maxOrphanTombstones {
|
||||
removeTombstonesLocked(orphans.prefix(orphans.count - Limits.maxOrphanTombstones))
|
||||
}
|
||||
}
|
||||
|
||||
private func removeTombstonesLocked(_ victims: ArraySlice<StoredTombstone>) {
|
||||
guard !victims.isEmpty else { return }
|
||||
let victimIDs = Set(victims.map { $0.tombstone.postID })
|
||||
tombstones.removeAll { victimIDs.contains($0.tombstone.postID) }
|
||||
}
|
||||
|
||||
private func evictOldestLocked(from candidates: [StoredPost], keep: Int) {
|
||||
let victims = candidates
|
||||
.sorted { $0.post.createdAt < $1.post.createdAt }
|
||||
.prefix(max(0, candidates.count - keep))
|
||||
guard !victims.isEmpty else { return }
|
||||
let victimIDs = Set(victims.map { $0.post.postID })
|
||||
posts.removeAll { victimIDs.contains($0.post.postID) }
|
||||
}
|
||||
|
||||
private func pruneExpiredLocked(nowMs: UInt64) {
|
||||
let postsBefore = posts.count
|
||||
posts.removeAll { $0.post.expiresAt <= nowMs }
|
||||
tombstones.removeAll { $0.retainUntil <= nowMs }
|
||||
if posts.count != postsBefore {
|
||||
publishSnapshotLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private func publishSnapshotLocked() {
|
||||
let snapshot = posts.map(\.post)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.postsSnapshot = snapshot
|
||||
}
|
||||
}
|
||||
|
||||
private func currentMs() -> UInt64 {
|
||||
UInt64(max(0, now().timeIntervalSince1970) * 1000)
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private func persistLocked() {
|
||||
guard let fileURL else { return }
|
||||
let payloads = posts.map { PersistedEntry(packet: $0.rawPacket, retainUntil: nil) }
|
||||
+ tombstones.map { PersistedEntry(packet: $0.rawPacket, retainUntil: $0.retainUntil) }
|
||||
do {
|
||||
if payloads.isEmpty {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
return
|
||||
}
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let data = try JSONEncoder().encode(payloads)
|
||||
var options: Data.WritingOptions = [.atomic]
|
||||
#if os(iOS)
|
||||
options.insert(.completeFileProtection)
|
||||
#endif
|
||||
try data.write(to: fileURL, options: options)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to persist board store: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFromDisk() {
|
||||
guard let fileURL,
|
||||
let data = try? Data(contentsOf: fileURL),
|
||||
let payloads = try? JSONDecoder().decode([PersistedEntry].self, from: data) else {
|
||||
return
|
||||
}
|
||||
let nowMs = currentMs()
|
||||
queue.sync {
|
||||
for entry in payloads {
|
||||
guard let packet = BitchatPacket.from(entry.packet),
|
||||
packet.type == MessageType.boardPost.rawValue,
|
||||
let wire = BoardWire.decode(from: packet.payload),
|
||||
wire.verifySignature() else { continue }
|
||||
_ = ingestLocked(wire, packet: packet, rawPacket: entry.packet, nowMs: nowMs, retainUntilOverride: entry.retainUntil)
|
||||
}
|
||||
publishSnapshotLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private static func defaultFileURL() -> URL? {
|
||||
guard let base = try? FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
) else { return nil }
|
||||
return base
|
||||
.appendingPathComponent("board", isDirectory: true)
|
||||
.appendingPathComponent("posts.json")
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
//
|
||||
// UnifiedNotices.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// One row in the unified notices sheet: a mesh board post or a Nostr
|
||||
/// location note, normalized for display.
|
||||
struct NoticeItem: Identifiable, Equatable {
|
||||
enum Source: Equatable {
|
||||
/// Signed board post carried by the mesh.
|
||||
case board(BoardPostPacket)
|
||||
/// Kind-1 location note seen on geo relays.
|
||||
case nostr(LocationNotesManager.Note)
|
||||
}
|
||||
|
||||
let id: String
|
||||
let author: String
|
||||
let content: String
|
||||
let createdAt: Date
|
||||
let isUrgent: Bool
|
||||
/// When the notice fades (board expiry or a note's NIP-40 tag, as dead
|
||||
/// drops carry). Nil means it only ages out of the relay window.
|
||||
let expiresAt: Date?
|
||||
let source: Source
|
||||
|
||||
var isBoardPost: Bool {
|
||||
if case .board = source { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
init(post: BoardPostPacket) {
|
||||
id = post.postID.hexEncodedString()
|
||||
author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon"
|
||||
content = post.content
|
||||
createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000)
|
||||
isUrgent = post.isUrgent
|
||||
expiresAt = post.expiresAt > 0
|
||||
? Date(timeIntervalSince1970: TimeInterval(post.expiresAt) / 1000)
|
||||
: nil
|
||||
source = .board(post)
|
||||
}
|
||||
|
||||
init(note: LocationNotesManager.Note) {
|
||||
id = note.id
|
||||
let display = note.displayName
|
||||
author = display.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
.first.map(String.init) ?? display
|
||||
content = note.content
|
||||
createdAt = note.createdAt
|
||||
isUrgent = note.isUrgent
|
||||
expiresAt = note.expiresAt
|
||||
source = .nostr(note)
|
||||
}
|
||||
}
|
||||
|
||||
/// Merges mesh board posts and Nostr location notes into one deduplicated
|
||||
/// list for the notices sheet's geo tab.
|
||||
enum UnifiedNotices {
|
||||
/// Board posts on geohash channels are bridged to Nostr as kind-1 notes at
|
||||
/// post time, so the same notice arrives twice. The copies share content
|
||||
/// and nickname but are signed by unlinkable keys; match them
|
||||
/// heuristically by content + author within a time window.
|
||||
static let bridgeDedupeWindow: TimeInterval = 15 * 60
|
||||
|
||||
/// Returns board posts and notes as one list, urgent posts first, then
|
||||
/// newest first. Notes that look like bridged copies of a board post are
|
||||
/// dropped — the board copy wins because it carries urgency and supports
|
||||
/// merged deletion. The geohash must match exactly: the notes
|
||||
/// subscription also surfaces neighboring cells, and a same-text note
|
||||
/// from a neighbor is not the bridged copy.
|
||||
static func merge(posts: [BoardPostPacket], notes: [LocationNotesManager.Note]) -> [NoticeItem] {
|
||||
var items = posts.map(NoticeItem.init(post:))
|
||||
for note in notes {
|
||||
let noteNickname = note.nickname?.trimmedOrNilIfEmpty ?? "anon"
|
||||
let isBridgedCopy = posts.contains { post in
|
||||
post.geohash == note.geohash
|
||||
&& post.content == note.content
|
||||
&& (post.authorNickname.trimmedOrNilIfEmpty ?? "anon") == noteNickname
|
||||
&& abs(Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000).timeIntervalSince(note.createdAt)) <= bridgeDedupeWindow
|
||||
}
|
||||
if !isBridgedCopy {
|
||||
items.append(NoticeItem(note: note))
|
||||
}
|
||||
}
|
||||
return items.sorted {
|
||||
if $0.isUrgent != $1.isUrgent { return $0.isUrgent }
|
||||
return $0.createdAt > $1.createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
//
|
||||
// CashuTokenDecoder.swift
|
||||
// bitchat
|
||||
//
|
||||
// Decodes Cashu ecash tokens (V3 `cashuA` = base64url JSON, V4 `cashuB` =
|
||||
// base64url CBOR) just far enough to summarize them for the UI: total
|
||||
// amount, unit, mint host, and memo. The app never contacts a mint — tokens
|
||||
// are bearer strings and redemption is delegated to an external wallet.
|
||||
//
|
||||
// This parses attacker-controlled message content, so every path is
|
||||
// bounds-checked, size-capped, and returns nil instead of trapping.
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum CashuTokenDecoder {
|
||||
|
||||
struct TokenInfo: Equatable {
|
||||
/// Token serialization version: "A" (JSON) or "B" (CBOR).
|
||||
let version: String
|
||||
/// Sum of all proof amounts; nil when no valid amounts were found.
|
||||
let amount: Int?
|
||||
/// Currency unit as declared by the token (commonly "sat"), if any.
|
||||
let unit: String?
|
||||
/// Host of the (first) mint URL, for display.
|
||||
let mintHost: String?
|
||||
/// Optional sender memo, sanitized for display.
|
||||
let memo: String?
|
||||
|
||||
/// "500 sat" style summary, defaulting the unit to sats per NUT-00.
|
||||
var displayAmount: String? {
|
||||
amount.map { "\($0) \(unit ?? "sat")" }
|
||||
}
|
||||
}
|
||||
|
||||
/// Upper bound on accepted token length in characters. Real tokens are a
|
||||
/// few KB; anything much bigger is abuse we shouldn't spend CPU on.
|
||||
static let maxTokenLength = 60_000
|
||||
/// Per-proof and total amount sanity caps (order of total sats in existence).
|
||||
private static let maxAmount: Int64 = 2_100_000_000_000_000
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Extracts the bare `cashuA…`/`cashuB…` token from raw text that may be
|
||||
/// a `cashu:`/`cashu://` URI and/or percent-encoded. Returns nil when the
|
||||
/// input doesn't look like a Cashu token at all.
|
||||
static func bareToken(from raw: String) -> String? {
|
||||
var token = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lower = token.lowercased()
|
||||
if lower.hasPrefix("cashu://") {
|
||||
token = String(token.dropFirst(8))
|
||||
} else if lower.hasPrefix("cashu:") {
|
||||
token = String(token.dropFirst(6))
|
||||
}
|
||||
if token.contains("%"), let decoded = token.removingPercentEncoding {
|
||||
token = decoded
|
||||
}
|
||||
guard token.count >= 12, token.count <= maxTokenLength else { return nil }
|
||||
guard token.hasPrefix("cashuA") || token.hasPrefix("cashuB") else { return nil }
|
||||
// Base64 / base64url payload charset ('.' appears in some legacy multi-part tokens)
|
||||
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_+/=."))
|
||||
guard token.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil }
|
||||
return token
|
||||
}
|
||||
|
||||
/// Decodes a token (raw or `cashu:` URI form) into a display summary.
|
||||
///
|
||||
/// In the default (permissive) mode this is for *rendering*: V3 tokens
|
||||
/// must parse as JSON, but a V4 token whose CBOR we cannot walk still
|
||||
/// returns a generic `TokenInfo` (version "B", no amount) because the
|
||||
/// payload may use encodings this minimal reader doesn't support — an
|
||||
/// unknown chip is fine for display.
|
||||
///
|
||||
/// In `strict` mode (used by the `/pay` SEND path) there is no permissive
|
||||
/// fallback: the token must cleanly decode to a known version *and* carry
|
||||
/// a positive amount, otherwise this returns nil. This stops base64 junk
|
||||
/// and truncated V4 tokens from being relayed as if they were valid money.
|
||||
static func decode(_ raw: String, strict: Bool = false) -> TokenInfo? {
|
||||
guard let token = bareToken(from: raw) else { return nil }
|
||||
let version = String(token[token.index(token.startIndex, offsetBy: 5)])
|
||||
guard let payload = base64URLDecode(String(token.dropFirst(6))), !payload.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let info: TokenInfo?
|
||||
switch version {
|
||||
case "A":
|
||||
info = decodeV3(payload)
|
||||
case "B":
|
||||
if let walked = decodeV4(payload) {
|
||||
info = walked
|
||||
} else if strict {
|
||||
// Couldn't cleanly walk the CBOR — refuse to send it.
|
||||
return nil
|
||||
} else {
|
||||
info = TokenInfo(version: "B", amount: nil, unit: nil, mintHost: nil, memo: nil)
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
guard let info else { return nil }
|
||||
if strict {
|
||||
// A sendable token must resolve to a positive, sane amount.
|
||||
guard let amount = info.amount, amount > 0 else { return nil }
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// MARK: - Base64url
|
||||
|
||||
private static func base64URLDecode(_ input: String) -> Data? {
|
||||
var s = input
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
// Normalize padding (wallets emit both padded and unpadded forms)
|
||||
s = s.replacingOccurrences(of: "=", with: "")
|
||||
let remainder = s.count % 4
|
||||
if remainder == 1 { return nil }
|
||||
if remainder > 0 { s += String(repeating: "=", count: 4 - remainder) }
|
||||
return Data(base64Encoded: s)
|
||||
}
|
||||
|
||||
// MARK: - V3 (JSON)
|
||||
|
||||
private static func decodeV3(_ payload: Data) -> TokenInfo? {
|
||||
guard let obj = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any],
|
||||
let entries = obj["token"] as? [[String: Any]],
|
||||
!entries.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
var total: Int64 = 0
|
||||
var sawAmount = false
|
||||
var mintHost: String?
|
||||
for entry in entries {
|
||||
if mintHost == nil, let mint = entry["mint"] as? String {
|
||||
mintHost = sanitizedHost(from: mint)
|
||||
}
|
||||
for proof in (entry["proofs"] as? [[String: Any]]) ?? [] {
|
||||
guard let number = proof["amount"] as? NSNumber else { continue }
|
||||
let value = number.int64Value
|
||||
guard value > 0, value <= maxAmount else { continue }
|
||||
total += value
|
||||
guard total <= maxAmount else { return nil }
|
||||
sawAmount = true
|
||||
}
|
||||
}
|
||||
return TokenInfo(
|
||||
version: "A",
|
||||
amount: sawAmount ? Int(total) : nil,
|
||||
unit: sanitizedUnit(obj["unit"] as? String),
|
||||
mintHost: mintHost,
|
||||
memo: sanitizedMemo(obj["memo"] as? String)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - V4 (CBOR)
|
||||
|
||||
/// Minimal walk of the NUT-00 TokenV4 CBOR map:
|
||||
/// { "m": mint, "u": unit, "d": memo, "t": [ { "i": bytes, "p": [ { "a": amount, … } ] } ] }
|
||||
private static func decodeV4(_ payload: Data) -> TokenInfo? {
|
||||
var reader = CBORReader(data: payload)
|
||||
guard case .map(let pairs)? = reader.parseValue(depth: 0) else { return nil }
|
||||
var mintHost: String?
|
||||
var unit: String?
|
||||
var memo: String?
|
||||
var total: Int64 = 0
|
||||
var sawAmount = false
|
||||
for (key, value) in pairs {
|
||||
guard case .text(let name) = key else { continue }
|
||||
switch (name, value) {
|
||||
case ("m", .text(let mint)):
|
||||
mintHost = sanitizedHost(from: mint)
|
||||
case ("u", .text(let u)):
|
||||
unit = sanitizedUnit(u)
|
||||
case ("d", .text(let d)):
|
||||
memo = sanitizedMemo(d)
|
||||
case ("t", .array(let groups)):
|
||||
for case .map(let group) in groups {
|
||||
for case (.text("p"), .array(let proofs)) in group {
|
||||
for case .map(let proof) in proofs {
|
||||
for case (.text("a"), .unsigned(let amount)) in proof {
|
||||
guard amount > 0, amount <= UInt64(maxAmount) else { continue }
|
||||
total += Int64(amount)
|
||||
guard total <= maxAmount else { return nil }
|
||||
sawAmount = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return TokenInfo(
|
||||
version: "B",
|
||||
amount: sawAmount ? Int(total) : nil,
|
||||
unit: unit,
|
||||
mintHost: mintHost,
|
||||
memo: memo
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Display Sanitization (values are attacker-controlled)
|
||||
|
||||
private static func sanitizedHost(from mint: String) -> String? {
|
||||
guard mint.count <= 512, let host = URL(string: mint)?.host, !host.isEmpty else { return nil }
|
||||
return String(host.lowercased().prefix(48))
|
||||
}
|
||||
|
||||
private static func sanitizedUnit(_ unit: String?) -> String? {
|
||||
guard let unit, !unit.isEmpty, unit.count <= 12,
|
||||
unit.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) }) else {
|
||||
return nil
|
||||
}
|
||||
return unit
|
||||
}
|
||||
|
||||
private static func sanitizedMemo(_ memo: String?) -> String? {
|
||||
guard let memo, memo.count <= 512 else { return nil }
|
||||
let stripped = CharacterSet.controlCharacters.union(.newlines)
|
||||
var cleaned = ""
|
||||
cleaned.unicodeScalars.append(contentsOf: memo.unicodeScalars.filter { !stripped.contains($0) })
|
||||
cleaned = cleaned.trimmingCharacters(in: .whitespaces)
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
return String(cleaned.prefix(80))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Minimal CBOR Reader
|
||||
|
||||
/// Just enough definite-length CBOR to traverse a TokenV4 map. Bounded in
|
||||
/// depth, item count, and byte length; indefinite-length items and anything
|
||||
/// else exotic make the parse fail (the caller degrades to a generic chip).
|
||||
private struct CBORReader {
|
||||
indirect enum Value {
|
||||
case unsigned(UInt64)
|
||||
case text(String)
|
||||
case array([Value])
|
||||
case map([(Value, Value)])
|
||||
/// Parsed-and-skipped content we don't need (byte strings, negatives, floats…)
|
||||
case opaque
|
||||
}
|
||||
|
||||
private let bytes: [UInt8]
|
||||
private var index = 0
|
||||
/// Total item budget so hostile nesting can't run away.
|
||||
private var itemBudget = 50_000
|
||||
private static let maxDepth = 16
|
||||
private static let maxContainerCount: UInt64 = 10_000
|
||||
|
||||
init(data: Data) {
|
||||
bytes = [UInt8](data)
|
||||
}
|
||||
|
||||
mutating func parseValue(depth: Int) -> Value? {
|
||||
guard depth < Self.maxDepth, itemBudget > 0 else { return nil }
|
||||
itemBudget -= 1
|
||||
guard let (major, argument) = readHead() else { return nil }
|
||||
switch major {
|
||||
case 0: // unsigned int
|
||||
return .unsigned(argument)
|
||||
case 1: // negative int (argument already consumed)
|
||||
return .opaque
|
||||
case 2: // byte string
|
||||
return readBytes(count: argument) != nil ? .opaque : nil
|
||||
case 3: // text string
|
||||
guard let raw = readBytes(count: argument) else { return nil }
|
||||
return String(bytes: raw, encoding: .utf8).map(Value.text) ?? .opaque
|
||||
case 4: // array
|
||||
guard argument <= Self.maxContainerCount else { return nil }
|
||||
var items: [Value] = []
|
||||
items.reserveCapacity(Int(min(argument, 64)))
|
||||
for _ in 0..<argument {
|
||||
guard let item = parseValue(depth: depth + 1) else { return nil }
|
||||
items.append(item)
|
||||
}
|
||||
return .array(items)
|
||||
case 5: // map
|
||||
guard argument <= Self.maxContainerCount else { return nil }
|
||||
var pairs: [(Value, Value)] = []
|
||||
pairs.reserveCapacity(Int(min(argument, 64)))
|
||||
for _ in 0..<argument {
|
||||
guard let key = parseValue(depth: depth + 1),
|
||||
let value = parseValue(depth: depth + 1) else { return nil }
|
||||
pairs.append((key, value))
|
||||
}
|
||||
return .map(pairs)
|
||||
case 6: // tag: skip the tag number, parse the tagged value
|
||||
return parseValue(depth: depth + 1)
|
||||
case 7: // simple values / floats (payload consumed by readHead)
|
||||
return .opaque
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a CBOR head byte plus its argument. Rejects indefinite lengths.
|
||||
private mutating func readHead() -> (major: UInt8, argument: UInt64)? {
|
||||
guard index < bytes.count else { return nil }
|
||||
let head = bytes[index]
|
||||
index += 1
|
||||
let major = head >> 5
|
||||
let info = head & 0x1F
|
||||
switch info {
|
||||
case 0...23:
|
||||
return (major, UInt64(info))
|
||||
case 24:
|
||||
return readUInt(width: 1).map { (major, $0) }
|
||||
case 25:
|
||||
return readUInt(width: 2).map { (major, $0) }
|
||||
case 26:
|
||||
return readUInt(width: 4).map { (major, $0) }
|
||||
case 27:
|
||||
return readUInt(width: 8).map { (major, $0) }
|
||||
default: // 28-30 reserved, 31 indefinite
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func readUInt(width: Int) -> UInt64? {
|
||||
guard bytes.count - index >= width else { return nil }
|
||||
var value: UInt64 = 0
|
||||
for _ in 0..<width {
|
||||
value = (value << 8) | UInt64(bytes[index])
|
||||
index += 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private mutating func readBytes(count: UInt64) -> [UInt8]? {
|
||||
guard count <= UInt64(bytes.count - index) else { return nil }
|
||||
let length = Int(count)
|
||||
let slice = Array(bytes[index..<(index + length)])
|
||||
index += length
|
||||
return slice
|
||||
}
|
||||
}
|
||||
@@ -22,17 +22,6 @@ struct CommandGeoParticipant {
|
||||
let displayName: String
|
||||
}
|
||||
|
||||
/// The conversation a command was typed into, captured when the command is
|
||||
/// issued so deferred output (e.g. an async /ping result, which can arrive
|
||||
/// many seconds later) lands there even if the user switches chats first.
|
||||
enum CommandOutputDestination: Equatable {
|
||||
/// The #mesh public timeline. Commands that defer output (/ping) are
|
||||
/// mesh-only, so a non-DM origin is always the mesh timeline.
|
||||
case meshTimeline
|
||||
/// The private chat that was open when the command was typed.
|
||||
case privateChat(PeerID)
|
||||
}
|
||||
|
||||
/// Protocol defining what CommandProcessor needs from its context.
|
||||
/// This breaks the circular dependency between CommandProcessor and ChatViewModel.
|
||||
@MainActor
|
||||
@@ -56,33 +45,15 @@ protocol CommandContextProvider: AnyObject {
|
||||
/// Empties the peer's chat (single-writer store intent for `/clear`).
|
||||
func clearPrivateChat(_ peerID: PeerID)
|
||||
func sendPublicRaw(_ content: String)
|
||||
/// Sends a normal public message (with local echo) to the active channel.
|
||||
func sendPublicMessage(_ content: String)
|
||||
|
||||
// MARK: - System Messages
|
||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
|
||||
func addPublicSystemMessage(_ content: String)
|
||||
/// The conversation the user is typing into right now. Commands that
|
||||
/// finish asynchronously capture this BEFORE starting async work, so a
|
||||
/// chat switch cannot misroute their deferred output.
|
||||
func currentCommandDestination() -> CommandOutputDestination
|
||||
/// Routes deferred command output (e.g. an async /ping result) into the
|
||||
/// conversation captured when the command was issued.
|
||||
func addCommandOutput(_ content: String, to destination: CommandOutputDestination)
|
||||
|
||||
// MARK: - Favorites
|
||||
/// Toggles the favorite via the unified peer flow, which persists by the
|
||||
/// real noise key and notifies the peer over mesh or Nostr.
|
||||
func toggleFavorite(peerID: PeerID)
|
||||
|
||||
// MARK: - Groups
|
||||
// Group logic lives in `ChatGroupCoordinator`; these forward the parsed
|
||||
// /group subcommands.
|
||||
func groupCreate(named name: String) -> CommandResult
|
||||
func groupInvite(nickname: String) -> CommandResult
|
||||
func groupRemove(nickname: String) -> CommandResult
|
||||
func groupLeave() -> CommandResult
|
||||
func groupList() -> CommandResult
|
||||
}
|
||||
|
||||
/// Processes chat commands in a focused, efficient way
|
||||
@@ -129,25 +100,12 @@ final class CommandProcessor {
|
||||
return handleBlock(args)
|
||||
case "/unblock":
|
||||
return handleUnblock(args)
|
||||
case "/group":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "groups are only for mesh peers in #mesh") }
|
||||
return handleGroup(args)
|
||||
case "/fav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
return handleFavorite(args, add: true)
|
||||
case "/unfav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
return handleFavorite(args, add: false)
|
||||
case "/ping":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "ping only works for mesh peers in #mesh") }
|
||||
return handlePing(args)
|
||||
case "/trace":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "trace only works for mesh peers in #mesh") }
|
||||
return handleTrace(args)
|
||||
case "/pay":
|
||||
return handlePay(args)
|
||||
case "/drop":
|
||||
return handleDrop(args)
|
||||
case "/help":
|
||||
return .success(message: Self.helpText)
|
||||
default:
|
||||
@@ -167,42 +125,9 @@ final class CommandProcessor {
|
||||
/slap @name — slap with a large trout
|
||||
/block @name · /unblock @name
|
||||
/fav @name · /unfav @name — favorites (mesh only)
|
||||
/group create <name> — start an encrypted group
|
||||
/group invite @name · /group remove @name — manage members (creator)
|
||||
/group leave · /group list — leave or list your groups
|
||||
/ping @name — measure round-trip time (mesh only)
|
||||
/trace @name — estimated mesh path (mesh only)
|
||||
/pay <token> — send a cashu ecash token in this chat
|
||||
/drop <message> — pin a note to this place for 24h (needs location)
|
||||
/help — this list
|
||||
"""
|
||||
|
||||
/// /drop <text> — a dead drop: pins a note to the current building-level
|
||||
/// geohash with a 24h NIP-40 expiry. Anyone who passes through here and
|
||||
/// looks at notices (or hits the empty-timeline "notes left here" hint)
|
||||
/// reads it.
|
||||
private func handleDrop(_ args: String) -> CommandResult {
|
||||
guard LocationNotesSettings.enabled else {
|
||||
return .error(message: "location notes are off — enable them in the info screen")
|
||||
}
|
||||
guard let content = args.trimmedOrNilIfEmpty else {
|
||||
return .error(message: "usage: /drop <message>")
|
||||
}
|
||||
let location = LocationChannelManager.shared
|
||||
guard location.permissionState == .authorized else {
|
||||
return .error(message: "leaving a note needs location — enable it in the info screen")
|
||||
}
|
||||
guard let geohash = location.availableChannels.first(where: { $0.level == .building })?.geohash else {
|
||||
location.refreshChannels()
|
||||
return .error(message: "still finding this place — try again in a moment")
|
||||
}
|
||||
guard let nickname = contextProvider?.nickname,
|
||||
LocationNotesManager.postDrop(content: content, nickname: nickname, geohash: geohash) else {
|
||||
return .error(message: "no geo relays reachable — note not left")
|
||||
}
|
||||
return .success(message: "📍 note left here — it fades in 24h")
|
||||
}
|
||||
|
||||
// MARK: - Command Handlers
|
||||
|
||||
private func handleMessage(_ args: String) -> CommandResult {
|
||||
@@ -406,138 +331,6 @@ final class CommandProcessor {
|
||||
return .error(message: "cannot unblock \(nickname): not found")
|
||||
}
|
||||
|
||||
private static let groupUsage = "usage: /group create <name> · invite @name · remove @name · leave · list"
|
||||
|
||||
private func handleGroup(_ args: String) -> CommandResult {
|
||||
let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
|
||||
guard let subcommand = parts.first else {
|
||||
return .error(message: Self.groupUsage)
|
||||
}
|
||||
let rest = parts.count > 1 ? String(parts[1]) : ""
|
||||
guard let provider = contextProvider else { return .handled }
|
||||
|
||||
switch subcommand {
|
||||
case "create":
|
||||
return provider.groupCreate(named: rest)
|
||||
case "invite":
|
||||
return provider.groupInvite(nickname: rest)
|
||||
case "remove":
|
||||
return provider.groupRemove(nickname: rest)
|
||||
case "leave":
|
||||
return provider.groupLeave()
|
||||
case "list":
|
||||
return provider.groupList()
|
||||
default:
|
||||
return .error(message: Self.groupUsage)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mesh Diagnostics
|
||||
|
||||
private enum MeshPeerResolution {
|
||||
case resolved(peerID: PeerID, nickname: String)
|
||||
case failed(CommandResult)
|
||||
}
|
||||
|
||||
/// Resolves a mesh peer for /ping and /trace. Geohash identities are
|
||||
/// rejected — diagnostics measure the BLE mesh, not Nostr.
|
||||
private func resolveMeshPeer(_ args: String, command: String) -> MeshPeerResolution {
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
return .failed(.error(message: "usage: /\(command) <nickname>"))
|
||||
}
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
!peerID.isGeoDM, !peerID.isGeoChat else {
|
||||
return .failed(.error(message: "cannot \(command) \(nickname): not found on mesh"))
|
||||
}
|
||||
return .resolved(peerID: peerID, nickname: nickname)
|
||||
}
|
||||
|
||||
private func handlePing(_ args: String) -> CommandResult {
|
||||
let target: (peerID: PeerID, nickname: String)
|
||||
switch resolveMeshPeer(args, command: "ping") {
|
||||
case .resolved(let peerID, let nickname): target = (peerID, nickname)
|
||||
case .failed(let result): return result
|
||||
}
|
||||
|
||||
let nickname = target.nickname
|
||||
let currentProvider = contextProvider
|
||||
// Capture the origin conversation now: the pong can arrive up to
|
||||
// meshPingTimeoutSeconds later, and reading the selected chat at
|
||||
// callback time would misroute the result after a chat switch.
|
||||
let destination = contextProvider?.currentCommandDestination() ?? .meshTimeline
|
||||
meshService?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in
|
||||
let provider = currentProvider
|
||||
guard let result else {
|
||||
provider?.addCommandOutput("no reply from \(nickname)", to: destination)
|
||||
return
|
||||
}
|
||||
let hopText: String = result.hops.map { hops in
|
||||
hops == 1 ? " · direct (1 hop)" : " · \(hops) hops"
|
||||
} ?? ""
|
||||
provider?.addCommandOutput("pong from \(nickname): \(result.rttMs) ms\(hopText)", to: destination)
|
||||
}
|
||||
return .success(message: "pinging \(nickname)…")
|
||||
}
|
||||
|
||||
private func handleTrace(_ args: String) -> CommandResult {
|
||||
let target: (peerID: PeerID, nickname: String)
|
||||
switch resolveMeshPeer(args, command: "trace") {
|
||||
case .resolved(let peerID, let nickname): target = (peerID, nickname)
|
||||
case .failed(let result): return result
|
||||
}
|
||||
|
||||
guard let mesh = meshService,
|
||||
let intermediates = mesh.computeMeshPath(to: target.peerID) else {
|
||||
return .success(message: "no known path to \(target.nickname)")
|
||||
}
|
||||
// Graph-derived from gossiped neighbor claims, not route-recorded —
|
||||
// present it as an estimate.
|
||||
let hopNames = intermediates.map { hop in
|
||||
mesh.peerNickname(peerID: hop) ?? "\(hop.id.prefix(8))…"
|
||||
}
|
||||
let chain = (["you"] + hopNames + [target.nickname]).joined(separator: " → ")
|
||||
let hops = intermediates.count + 1
|
||||
return .success(message: "estimated path: \(chain) (\(hops) hop\(hops == 1 ? "" : "s"))")
|
||||
}
|
||||
|
||||
/// `/pay <cashu-token>` — validates the token decodes, then sends it as
|
||||
/// the message body in the current chat. Cashu tokens are bearer
|
||||
/// instruments (whoever redeems first gets the funds), so posting one to
|
||||
/// a public channel requires an explicit `/pay <token> public` confirm.
|
||||
/// The app never contacts a mint; it only relays the string.
|
||||
private func handlePay(_ args: String) -> CommandResult {
|
||||
var parts = args.trimmed.split(separator: " ").map(String.init)
|
||||
guard !parts.isEmpty else {
|
||||
return .success(message: "usage: /pay <token> — paste a cashu token: /pay cashuA…")
|
||||
}
|
||||
|
||||
let confirmedPublic = parts.count > 1 && parts.last?.lowercased() == "public"
|
||||
if confirmedPublic { parts.removeLast() }
|
||||
|
||||
guard parts.count == 1, let token = CashuTokenDecoder.bareToken(from: parts[0]) else {
|
||||
return .error(message: "that doesn't look like a cashu token — expected cashuA… or cashuB…")
|
||||
}
|
||||
guard let info = CashuTokenDecoder.decode(token, strict: true) else {
|
||||
return .error(message: "invalid cashu token — it doesn't decode to a known token with an amount, not sending it")
|
||||
}
|
||||
|
||||
let summary = info.displayAmount ?? "a cashu token"
|
||||
|
||||
if let peerID = contextProvider?.selectedPrivateChatPeer {
|
||||
contextProvider?.sendPrivateMessage(token, to: peerID)
|
||||
return .success(message: "sent \(summary) — cashu is a bearer token; whoever redeems it first gets the funds")
|
||||
}
|
||||
|
||||
guard confirmedPublic else {
|
||||
return .error(message: "this is a public channel — anyone reading it can redeem the token. send anyway: /pay <token> public")
|
||||
}
|
||||
|
||||
contextProvider?.sendPublicMessage(token)
|
||||
return .success(message: "sent \(summary) to the public channel — anyone here can redeem it")
|
||||
}
|
||||
|
||||
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
|
||||
@@ -42,13 +42,9 @@ final class CourierStore {
|
||||
var sprayedTo: Set<Data>
|
||||
/// Last speculative multi-hop handover toward a relayed announce.
|
||||
var lastRemoteHandoverAt: Date?
|
||||
/// Last publish of this envelope as a bridge courier drop on relays.
|
||||
var lastBridgePublishAt: Date?
|
||||
/// Prekey-sealed (envelope v2) discriminator; nil for static-sealed v1.
|
||||
let prekeyID: UInt32?
|
||||
|
||||
var envelope: CourierEnvelope {
|
||||
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID)
|
||||
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
|
||||
}
|
||||
|
||||
init(
|
||||
@@ -60,9 +56,7 @@ final class CourierStore {
|
||||
tier: CourierDepositTier,
|
||||
copies: UInt8,
|
||||
sprayedTo: Set<Data> = [],
|
||||
lastRemoteHandoverAt: Date? = nil,
|
||||
lastBridgePublishAt: Date? = nil,
|
||||
prekeyID: UInt32? = nil
|
||||
lastRemoteHandoverAt: Date? = nil
|
||||
) {
|
||||
self.recipientTag = recipientTag
|
||||
self.expiry = expiry
|
||||
@@ -73,8 +67,6 @@ final class CourierStore {
|
||||
self.copies = copies
|
||||
self.sprayedTo = sprayedTo
|
||||
self.lastRemoteHandoverAt = lastRemoteHandoverAt
|
||||
self.lastBridgePublishAt = lastBridgePublishAt
|
||||
self.prekeyID = prekeyID
|
||||
}
|
||||
|
||||
// Files written before tiers/spray lack the newer fields; treat that
|
||||
@@ -90,8 +82,6 @@ final class CourierStore {
|
||||
copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1
|
||||
sprayedTo = try container.decodeIfPresent(Set<Data>.self, forKey: .sprayedTo) ?? []
|
||||
lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt)
|
||||
lastBridgePublishAt = try container.decodeIfPresent(Date.self, forKey: .lastBridgePublishAt)
|
||||
prekeyID = try container.decodeIfPresent(UInt32.self, forKey: .prekeyID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,8 +186,7 @@ final class CourierStore {
|
||||
depositorNoiseKey: depositorNoiseKey,
|
||||
storedAt: date,
|
||||
tier: tier,
|
||||
copies: envelope.copies,
|
||||
prekeyID: envelope.prekeyID
|
||||
copies: envelope.copies
|
||||
))
|
||||
persistLocked()
|
||||
return true
|
||||
@@ -247,30 +236,6 @@ final class CourierStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Envelopes to park on relays as bridge courier drops. Non-destructive
|
||||
/// like remote handover — the relay copy is speculative, so the carried
|
||||
/// copy stays until direct handover or expiry. The per-envelope cooldown
|
||||
/// keeps relay churn from republishing the same mail; publishing relays
|
||||
/// dedup identical events by ID anyway.
|
||||
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
|
||||
let date = now()
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
var matched: [CourierEnvelope] = []
|
||||
for index in envelopes.indices {
|
||||
if let last = envelopes[index].lastBridgePublishAt,
|
||||
date.timeIntervalSince(last) < cooldown {
|
||||
continue
|
||||
}
|
||||
envelopes[index].lastBridgePublishAt = date
|
||||
// The relay copy carries no spray budget.
|
||||
matched.append(envelopes[index].envelope.withCopies(1))
|
||||
}
|
||||
if !matched.isEmpty { persistLocked() }
|
||||
return matched
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Spray-and-wait (on encountering another courier)
|
||||
|
||||
/// Envelopes to re-deposit with a courier we just encountered, each with
|
||||
@@ -301,6 +266,14 @@ final class CourierStore {
|
||||
|
||||
// MARK: - Maintenance
|
||||
|
||||
func pruneExpired() {
|
||||
let date = now()
|
||||
queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
persistLocked()
|
||||
}
|
||||
}
|
||||
|
||||
/// Panic wipe: drop all carried mail from memory and disk.
|
||||
func wipe() {
|
||||
queue.sync {
|
||||
|
||||
@@ -58,6 +58,12 @@ final class StoreAndForwardMetrics {
|
||||
SecureLogger.debug("📊 S&F \(event.rawValue) → \(total)", category: .session)
|
||||
}
|
||||
|
||||
func snapshot() -> [String: Int] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return counts
|
||||
}
|
||||
|
||||
/// Included in the panic wipe alongside the stores it describes.
|
||||
func reset() {
|
||||
lock.lock()
|
||||
|
||||
@@ -34,7 +34,23 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
|
||||
static let shared = FavoritesPersistenceService()
|
||||
|
||||
init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) {
|
||||
/// Default keychain for the `shared` singleton. Under test this is an
|
||||
/// in-memory keychain so touching `shared` never blocks on securityd
|
||||
/// (`SecItemCopyMatching` can hang in test environments) and never reads
|
||||
/// or writes the developer's real keychain. Production behavior is
|
||||
/// unchanged. Tests that need their own instance keep injecting a mock
|
||||
/// via `init(keychain:)`.
|
||||
private nonisolated static func makeDefaultKeychain() -> KeychainManagerProtocol {
|
||||
// PreviewKeychainManager lives in _PreviewHelpers, a development
|
||||
// asset excluded from archive builds — release code must not
|
||||
// reference it. Tests always run Debug, so the guard is lossless.
|
||||
#if DEBUG
|
||||
if TestEnvironment.isRunningTests { return PreviewKeychainManager() }
|
||||
#endif
|
||||
return KeychainManager()
|
||||
}
|
||||
|
||||
init(keychain: KeychainManagerProtocol = FavoritesPersistenceService.makeDefaultKeychain()) {
|
||||
self.keychain = keychain
|
||||
loadFavorites()
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
//
|
||||
// BoundedIDSet.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
/// Insertion-ordered string set with a fixed capacity; the oldest entry is
|
||||
/// evicted when full. Shared by the gateway and bridge loop-prevention
|
||||
/// caches.
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
//
|
||||
// BridgeCourierService.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 Foundation
|
||||
import Security
|
||||
|
||||
/// Courier delivery over the internet bridge: sealed courier envelopes are
|
||||
/// parked on relays as kind-1401 "drops" tagged with their rotating
|
||||
/// recipient tag, so delivery stops requiring a physical courier to bump
|
||||
/// into the recipient.
|
||||
///
|
||||
/// Three duties, all gated on the bridge toggle:
|
||||
/// - Sender: when the message router seals mail for an unreachable peer, a
|
||||
/// copy is published as a drop (queued until relays connect). The drop is
|
||||
/// signed with a fresh throwaway key per publish — the envelope
|
||||
/// authenticates its sender internally via Noise-X, and a stable publisher
|
||||
/// key would leak courier traffic patterns to relays.
|
||||
/// - Recipient: subscribes for its own candidate tags (adjacent UTC days)
|
||||
/// and opens matching drops directly.
|
||||
/// - Gateway (bridge + gateway toggles): additionally watches the tags of
|
||||
/// verified local mesh peers and hands matching drops to them as directed
|
||||
/// courier packets, so mesh-only recipients are served too.
|
||||
///
|
||||
/// Privacy: a drop reveals to relays only that "someone" is messaging "some
|
||||
/// 16-byte day-rotating tag". Only parties who already know the recipient's
|
||||
/// Noise static key can compute the tag; the payload is an opaque Noise-X
|
||||
/// seal. Duplicate deliveries (drop + physical courier + direct link) are
|
||||
/// absorbed downstream by message-ID dedup.
|
||||
@MainActor
|
||||
final class BridgeCourierService: ObservableObject {
|
||||
enum Limits {
|
||||
/// Drops waiting for relay connectivity (bounded, drop-oldest).
|
||||
static let maxPendingDrops = 20
|
||||
/// Republish cooldown for gateway-held envelopes.
|
||||
static let heldEnvelopePublishCooldown: TimeInterval = 30 * 60
|
||||
/// Local peers a gateway watches drops for (x3 candidate tags each).
|
||||
static let maxWatchedPeers = 16
|
||||
/// Tag-set refresh cadence (also covers UTC day rollover).
|
||||
static let refreshIntervalSeconds: TimeInterval = 30 * 60
|
||||
/// Minimum spacing for announce-driven refreshes.
|
||||
static let announceRefreshDebounceSeconds: TimeInterval = 60
|
||||
/// Encoded envelope cap for a drop (16 KiB ciphertext + TLV slack).
|
||||
static let maxDropEnvelopeBytes = 20 * 1024
|
||||
static let maxTrackedIDs = 512
|
||||
}
|
||||
|
||||
static let shared = BridgeCourierService()
|
||||
|
||||
// MARK: Wiring (set once by the bootstrapper; fakes in tests)
|
||||
|
||||
var bridgeEnabled: (@MainActor () -> Bool)?
|
||||
var relaysConnected: (@MainActor () -> Bool)?
|
||||
/// Publishes a signed drop event to the default (DM) relays.
|
||||
var publishEvent: (@MainActor (NostrEvent) -> Void)?
|
||||
/// (Re)opens the drop subscription for the given hex tags.
|
||||
var openSubscription: (@MainActor ([String]) -> Void)?
|
||||
var closeSubscription: (@MainActor () -> Void)?
|
||||
/// Our own Noise static public key.
|
||||
var myNoiseKey: (@MainActor () -> Data?)?
|
||||
/// Verified reachable local peers with known Noise keys.
|
||||
var localVerifiedPeers: (@MainActor () -> [(peerID: PeerID, noiseKey: Data)])?
|
||||
/// Seals content into a carry-only envelope for a recipient key.
|
||||
var sealEnvelope: (@MainActor (String, String, Data) -> CourierEnvelope?)?
|
||||
/// Opens a drop addressed to us (tag verified inside).
|
||||
var openEnvelope: (@MainActor (CourierEnvelope) -> Void)?
|
||||
/// Hands a drop to a matching local peer as a directed courier packet.
|
||||
var deliverToPeer: (@MainActor (CourierEnvelope, PeerID) -> Void)?
|
||||
/// Held envelopes eligible for (re)publish, honoring the cooldown.
|
||||
var heldEnvelopes: (@MainActor (TimeInterval) -> [CourierEnvelope])?
|
||||
/// Timer injection for tests; nil arms a real `Task`.
|
||||
var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)?
|
||||
|
||||
// MARK: State
|
||||
|
||||
private(set) var myTagsHex: Set<String> = []
|
||||
private(set) var watchedPeerTags: [(peerID: PeerID, tagsHex: Set<String>)] = []
|
||||
private(set) var pendingDrops: [(envelope: CourierEnvelope, dedupKey: String?)] = []
|
||||
/// Message IDs already published as drops (sender-side dedup).
|
||||
private var publishedDropKeys: BoundedIDSet
|
||||
/// Drop event IDs already handled (multi-relay dedup).
|
||||
private var seenDropEventIDs: BoundedIDSet
|
||||
private var subscriptionOpen = false
|
||||
private var lastSubscribedTags: Set<String> = []
|
||||
private var refreshTimerArmed = false
|
||||
private var lastAnnounceRefresh = Date.distantPast
|
||||
|
||||
private let now: () -> Date
|
||||
|
||||
init(now: @escaping () -> Date = Date.init) {
|
||||
self.now = now
|
||||
self.publishedDropKeys = BoundedIDSet(capacity: Limits.maxTrackedIDs)
|
||||
self.seenDropEventIDs = BoundedIDSet(capacity: Limits.maxTrackedIDs)
|
||||
}
|
||||
|
||||
// MARK: - Sender role
|
||||
|
||||
/// Parallel-deposit a sealed copy of an outbound private message as a
|
||||
/// relay drop. Called by the message router alongside physical courier
|
||||
/// deposits; idempotent per message ID. Returns true when a fresh drop
|
||||
/// was sealed (published now or queued for the next relay connection) —
|
||||
/// the router marks the message "carried" so the sender sees progress.
|
||||
@discardableResult
|
||||
func depositDrop(content: String, messageID: String, recipientNoiseKey: Data) -> Bool {
|
||||
guard bridgeEnabled?() ?? false else { return false }
|
||||
guard !publishedDropKeys.contains(messageID) else { return false }
|
||||
guard let envelope = sealEnvelope?(content, messageID, recipientNoiseKey) else { return false }
|
||||
publishedDropKeys.insert(messageID)
|
||||
publishDrop(envelope)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Publishes held envelopes (mail we carry for others) as drops,
|
||||
/// honoring the per-envelope cooldown.
|
||||
func publishHeldEnvelopes() {
|
||||
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else { return }
|
||||
for envelope in heldEnvelopes?(Limits.heldEnvelopePublishCooldown) ?? [] {
|
||||
publishDrop(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
private func publishDrop(_ envelope: CourierEnvelope) {
|
||||
guard let encoded = envelope.encode(),
|
||||
encoded.count <= Limits.maxDropEnvelopeBytes,
|
||||
!envelope.isExpired else { return }
|
||||
guard relaysConnected?() ?? false else {
|
||||
pendingDrops.append((envelope, nil))
|
||||
if pendingDrops.count > Limits.maxPendingDrops {
|
||||
pendingDrops.removeFirst(pendingDrops.count - Limits.maxPendingDrops)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard let identity = Self.makeThrowawayIdentity(),
|
||||
let event = try? NostrProtocol.createCourierDropEvent(
|
||||
envelope: encoded,
|
||||
recipientTagHex: envelope.recipientTag.hexEncodedString(),
|
||||
expiresAt: Date(timeIntervalSince1970: TimeInterval(envelope.expiry) / 1000),
|
||||
senderIdentity: identity
|
||||
) else {
|
||||
SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption)
|
||||
return
|
||||
}
|
||||
publishEvent?(event)
|
||||
SecureLogger.debug("📦🌉 Published courier drop for tag \(envelope.recipientTag.hexEncodedString().prefix(8))…", category: .session)
|
||||
}
|
||||
|
||||
/// Drops queued while relays were unreachable publish on reconnect.
|
||||
func flushPendingDrops() {
|
||||
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false, !pendingDrops.isEmpty else { return }
|
||||
let queued = pendingDrops
|
||||
pendingDrops.removeAll()
|
||||
for item in queued {
|
||||
publishDrop(item.envelope)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subscription (recipient + gateway watch)
|
||||
|
||||
/// Recomputes the watched tag set and (re)opens the subscription.
|
||||
/// Call on toggle changes, relay connectivity changes, and periodically
|
||||
/// (tags rotate daily); idempotent.
|
||||
func refresh() {
|
||||
armRefreshTimerIfNeeded()
|
||||
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else {
|
||||
if subscriptionOpen {
|
||||
closeSubscription?()
|
||||
subscriptionOpen = false
|
||||
}
|
||||
return
|
||||
}
|
||||
let date = now()
|
||||
if let myKey = myNoiseKey?() {
|
||||
myTagsHex = Set(CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: date).map { $0.hexEncodedString() })
|
||||
} else {
|
||||
myTagsHex = []
|
||||
}
|
||||
// While bridging with internet, every device watches drops for its
|
||||
// verified local peers — the single-switch analogue of gateway duty.
|
||||
let peers = (localVerifiedPeers?() ?? []).prefix(Limits.maxWatchedPeers)
|
||||
watchedPeerTags = peers.map { peer in
|
||||
(peer.peerID, Set(CourierEnvelope.candidateTags(noiseStaticKey: peer.noiseKey, around: date).map { $0.hexEncodedString() }))
|
||||
}
|
||||
let allTags = myTagsHex.union(watchedPeerTags.flatMap(\.tagsHex))
|
||||
guard !allTags.isEmpty else {
|
||||
if subscriptionOpen {
|
||||
closeSubscription?()
|
||||
subscriptionOpen = false
|
||||
lastSubscribedTags = []
|
||||
}
|
||||
return
|
||||
}
|
||||
// Resubscribe only when the watched set actually changed — refresh
|
||||
// fires on every verified announce (field logs showed the drop
|
||||
// subscription rebuilt every ~60s for an unchanged tag set).
|
||||
if !subscriptionOpen || allTags != lastSubscribedTags {
|
||||
openSubscription?(allTags.sorted())
|
||||
subscriptionOpen = true
|
||||
lastSubscribedTags = allTags
|
||||
}
|
||||
flushPendingDrops()
|
||||
publishHeldEnvelopes()
|
||||
}
|
||||
|
||||
/// Announce-driven refresh, debounced — a newly verified peer should be
|
||||
/// watched promptly, but announce storms must not thrash subscriptions.
|
||||
func refreshAfterVerifiedAnnounce() {
|
||||
guard bridgeEnabled?() ?? false else { return }
|
||||
guard now().timeIntervalSince(lastAnnounceRefresh) >= Limits.announceRefreshDebounceSeconds else { return }
|
||||
lastAnnounceRefresh = now()
|
||||
refresh()
|
||||
}
|
||||
|
||||
private func armRefreshTimerIfNeeded() {
|
||||
guard bridgeEnabled?() ?? false, !refreshTimerArmed else { return }
|
||||
refreshTimerArmed = true
|
||||
let fire: @MainActor () -> Void = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.refreshTimerArmed = false
|
||||
self.refresh()
|
||||
}
|
||||
if let scheduleTimer {
|
||||
scheduleTimer(Limits.refreshIntervalSeconds, fire)
|
||||
} else {
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: UInt64(Limits.refreshIntervalSeconds * 1_000_000_000))
|
||||
fire()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Inbound drops
|
||||
|
||||
/// Entry point for every drop event the subscription delivers (the relay
|
||||
/// manager has already verified the event signature).
|
||||
func handleDropEvent(_ event: NostrEvent) {
|
||||
guard bridgeEnabled?() ?? false else { return }
|
||||
guard event.kind == NostrProtocol.EventKind.courierDrop.rawValue else { return }
|
||||
guard seenDropEventIDs.insert(event.id) else { return }
|
||||
guard let data = Data(base64Encoded: event.content),
|
||||
data.count <= Limits.maxDropEnvelopeBytes,
|
||||
let envelope = CourierEnvelope.decode(data),
|
||||
!envelope.isExpired else {
|
||||
return
|
||||
}
|
||||
let tagHex = envelope.recipientTag.hexEncodedString()
|
||||
// The envelope's own tag must match the event's filterable tag —
|
||||
// otherwise a mislabeled drop could ride a subscription it doesn't
|
||||
// belong to.
|
||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0] == "x" && $0[1] == tagHex }) else { return }
|
||||
|
||||
if myTagsHex.contains(tagHex) {
|
||||
SecureLogger.info("📦🌉 Courier drop for us arrived via bridge", category: .session)
|
||||
openEnvelope?(envelope)
|
||||
return
|
||||
}
|
||||
if let match = watchedPeerTags.first(where: { $0.tagsHex.contains(tagHex) }) {
|
||||
SecureLogger.info("📦🌉 Courier drop fetched for local peer \(match.peerID.id.prefix(8))…", category: .session)
|
||||
deliverToPeer?(envelope, match.peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// A fresh random Nostr identity for signing one drop.
|
||||
static func makeThrowawayIdentity() -> NostrIdentity? {
|
||||
for _ in 0..<10 {
|
||||
var bytes = Data(count: 32)
|
||||
let status = bytes.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
guard status == errSecSuccess else { return nil }
|
||||
if let identity = try? NostrIdentity(privateKeyData: bytes) {
|
||||
return identity
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,689 +0,0 @@
|
||||
//
|
||||
// BridgeService.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 the mesh bridge: an opt-in stitcher of disjoint BLE
|
||||
/// mesh islands that share a place. While the toggle is on, this device's
|
||||
/// public mesh messages are additionally signed (with a derived, unlinkable
|
||||
/// per-cell Nostr identity) as rendezvous events for the local geohash cell
|
||||
/// and published to the cell's deterministic geo relays — directly when we
|
||||
/// have internet, or deposited with a bridge gateway peer over a directed
|
||||
/// `toBridge` carrier when we are mesh-only. Inbound rendezvous events from
|
||||
/// other islands render into the mesh timeline marked as bridged.
|
||||
///
|
||||
/// A device with BOTH this toggle and the gateway toggle on serves the
|
||||
/// island: it accepts `toBridge` deposits, publishes them, and rebroadcasts
|
||||
/// remote rendezvous events onto the mesh as `fromBridge` carriers so
|
||||
/// mesh-only peers see across the bridge too.
|
||||
///
|
||||
/// Consent model:
|
||||
/// - Nothing crosses a bridge unless its author signed it for the bridge:
|
||||
/// gateways carry only finished, Schnorr-signed rendezvous events, so a
|
||||
/// neighbor's gateway cannot exfiltrate radio-only traffic. The per-message
|
||||
/// "nearby only" flag simply skips composing a rendezvous copy.
|
||||
/// - Receiving over radio (`fromBridge` carriers) is always on — it is
|
||||
/// passive and leaks nothing. Subscribing over the internet (which reveals
|
||||
/// the coarse cell to relays) and publishing both require the toggle.
|
||||
///
|
||||
/// Loop-prevention rules (adapted from `GatewayService`, unit-tested):
|
||||
/// 1. An event learned from a `fromBridge` mesh broadcast is never published
|
||||
/// and never rebroadcast (`meshBroadcastEventIDs`) — a second bridge
|
||||
/// gateway on the same island cannot echo mesh-carried traffic.
|
||||
/// 2. An event this device published (own messages or uplinked deposits,
|
||||
/// `publishedEventIDs`) is never downlink-rebroadcast: it originated on
|
||||
/// this island, so our own relay subscription redelivering it must not
|
||||
/// double BLE airtime.
|
||||
/// 3. A subscription event is rebroadcast at most once
|
||||
/// (`rebroadcastEventIDs`, marked after send), and never when the island
|
||||
/// already holds the radio copy (`isMessageSeenLocally` on the event's
|
||||
/// mesh message ID) — remote islands' traffic is the only thing worth
|
||||
/// airtime.
|
||||
/// Receivers additionally dedup by timeline message ID: a bridged copy
|
||||
/// carries the original mesh message ID in its `m` tag, so the store's
|
||||
/// insert-by-ID absorbs radio/bridge duplicates in either arrival order.
|
||||
///
|
||||
/// All dependencies are closure-injected (repo convention) so the policy
|
||||
/// layer is unit-testable without relays, radios, or CoreLocation.
|
||||
@MainActor
|
||||
final class BridgeService: ObservableObject {
|
||||
enum Limits {
|
||||
/// Uplink deposits held while relays are unreachable.
|
||||
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,
|
||||
/// and bridge traffic shares the radio with everything else.
|
||||
static let downlinkEventsPerMinute = 20
|
||||
static let maxPendingDownlinks = 30
|
||||
/// Accepted clock skew for a rendezvous event.
|
||||
static let maxEventAgeSeconds: TimeInterval = 15 * 60
|
||||
/// Bounded loop-prevention ID caches (oldest evicted).
|
||||
static let maxTrackedEventIDs = 512
|
||||
/// Presence heartbeat cadence while the bridge is active.
|
||||
static let presenceIntervalSeconds: TimeInterval = 4 * 60
|
||||
/// A rendezvous participant counts toward "via bridge" for this long
|
||||
/// after their last event.
|
||||
static let participantFreshnessSeconds: TimeInterval = 10 * 60
|
||||
/// Content cap, matching the public-message pipeline's own limit.
|
||||
static let maxContentBytes = 16_000
|
||||
/// Geohash-cell precision of the rendezvous (neighborhood, ~1.2 km).
|
||||
static let cellPrecision = 6
|
||||
}
|
||||
|
||||
struct QueuedUplink {
|
||||
let depositor: PeerID
|
||||
let cell: String
|
||||
let event: NostrEvent
|
||||
}
|
||||
|
||||
/// A validated rendezvous message ready for the timeline.
|
||||
struct InboundBridgeMessage {
|
||||
let messageID: String
|
||||
let senderNickname: String
|
||||
let senderPubkey: String
|
||||
let content: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
/// A person currently visible across the bridge (fresh, not attributed
|
||||
/// to the local island), for the people sheet.
|
||||
struct BridgedParticipant: Identifiable, Equatable {
|
||||
let pubkey: String
|
||||
let nickname: String?
|
||||
let lastSeen: Date
|
||||
var id: String { pubkey }
|
||||
/// Geohash-chat convention: nickname#last-4-of-pubkey, so two remote
|
||||
/// "anon"s stay distinguishable.
|
||||
var displayName: String {
|
||||
(nickname?.trimmedOrNilIfEmpty ?? "anon") + "#" + String(pubkey.suffix(4))
|
||||
}
|
||||
}
|
||||
|
||||
static let shared = BridgeService()
|
||||
|
||||
/// The user toggle. While true this device publishes its own public mesh
|
||||
/// messages to the rendezvous and subscribes to it when online.
|
||||
@Published private(set) var isEnabled: Bool
|
||||
/// Distinct remote rendezvous participants seen within the freshness
|
||||
/// window. Approximate by design: local participants are subtracted by
|
||||
/// matching their events' mesh message IDs against the local timeline,
|
||||
/// which cannot attribute silent (presence-only) local peers.
|
||||
@Published private(set) var bridgedPeerCount: Int = 0
|
||||
/// The people behind the count, newest activity first.
|
||||
@Published private(set) var bridgedParticipants: [BridgedParticipant] = []
|
||||
/// The rendezvous cell currently in use, when the bridge is active.
|
||||
@Published private(set) var activeCell: String?
|
||||
/// Per-session compose flag: while true, outgoing messages stay on the
|
||||
/// radio — no rendezvous copy is composed, so no gateway can carry them.
|
||||
@Published var nearbyOnly: Bool = false
|
||||
|
||||
// MARK: Wiring (set once by the bootstrapper; fakes in tests)
|
||||
|
||||
/// Publishes a signed event to the geo relays for a cell.
|
||||
var publishToRelays: (@MainActor (NostrEvent, String) -> Void)?
|
||||
/// Opens the rendezvous subscription for (cell + neighbors); events are
|
||||
/// fed back via `handleRendezvousEvent`.
|
||||
var openSubscription: (@MainActor ([String]) -> Void)?
|
||||
/// Closes the rendezvous subscription.
|
||||
var closeSubscription: (@MainActor () -> Void)?
|
||||
/// Whether any Nostr relay connection is currently working.
|
||||
var relaysConnected: (@MainActor () -> Bool)?
|
||||
/// The local neighborhood cell from CoreLocation, if permitted.
|
||||
var locationCell: (@MainActor () -> String?)?
|
||||
/// Asks the location layer for a fresh one-shot fix. The bridge must
|
||||
/// pump location itself: channel data otherwise only flows while some
|
||||
/// other feature (channels sheet, location notes) happens to be active —
|
||||
/// a field failure mode where the bridge silently never got a cell.
|
||||
var requestLocationFix: (@MainActor () -> Void)?
|
||||
/// A rendezvous cell advertised by a reachable mesh bridge peer's
|
||||
/// announce — lets a mesh-only, location-less device still compose
|
||||
/// correctly tagged events.
|
||||
var meshAdvertisedCell: (@MainActor () -> String?)?
|
||||
/// Sends an encoded `toBridge` carrier directed to a bridge peer.
|
||||
var sendToBridgePeer: (@MainActor (Data, PeerID) -> Bool)?
|
||||
/// Reachable mesh peers advertising the `.bridge` capability.
|
||||
var availableBridgePeers: (@MainActor () -> [PeerID])?
|
||||
/// Broadcasts an encoded `fromBridge` carrier on the mesh.
|
||||
var broadcastToMesh: (@MainActor (Data) -> Void)?
|
||||
/// Delivers a validated inbound bridge message to the mesh timeline.
|
||||
var injectInbound: (@MainActor (InboundBridgeMessage) -> Void)?
|
||||
/// True when the mesh timeline already holds this message ID (the radio
|
||||
/// copy) — used to skip pointless downlink airtime.
|
||||
var isMessageSeenLocally: (@MainActor (String) -> Bool)?
|
||||
/// Derives the unlinkable per-cell rendezvous identity.
|
||||
var deriveIdentity: (@MainActor (String) throws -> NostrIdentity)?
|
||||
/// Local nickname for the `n` tag.
|
||||
var myNickname: (@MainActor () -> String)?
|
||||
/// Fired on toggle changes (advertise/withdraw `.bridge` + re-announce).
|
||||
var onEnabledChanged: (@MainActor (Bool) -> Void)?
|
||||
/// Fired when the active rendezvous cell changes (including to nil) so
|
||||
/// the announce advertisement stays current.
|
||||
var onActiveCellChanged: (@MainActor (String?) -> Void)?
|
||||
/// Schedules a closure after a delay; nil arms a real `Task`. Injected so
|
||||
/// timers are deterministic in tests.
|
||||
var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)?
|
||||
|
||||
// MARK: State
|
||||
|
||||
/// Loop rule 1: event IDs seen in `fromBridge` mesh broadcasts.
|
||||
private var meshBroadcastEventIDs: BoundedIDSet
|
||||
/// Loop rule 2: event IDs this device published (own or deposited).
|
||||
private var publishedEventIDs: BoundedIDSet
|
||||
/// Loop rule 3: event IDs this device already rebroadcast.
|
||||
private var rebroadcastEventIDs: BoundedIDSet
|
||||
/// Timeline message IDs already injected (either arrival path).
|
||||
private var injectedMessageIDs: BoundedIDSet
|
||||
|
||||
/// Cells the rendezvous subscription covers (own + neighbor ring).
|
||||
private(set) var subscribedCells: Set<String> = []
|
||||
private(set) var queuedUplinks: [QueuedUplink] = []
|
||||
private var uplinkDepositTimes: [PeerID: [Date]] = [:]
|
||||
private var downlinkSendTimes: [Date] = []
|
||||
private var pendingDownlinks: [(event: NostrEvent, cell: String)] = []
|
||||
private var downlinkDrainScheduled = false
|
||||
private var presenceTimerArmed = false
|
||||
private var lastPresenceAt = Date.distantPast
|
||||
|
||||
/// pubkey -> (lastSeen, attributed-to-local-island, last known nickname).
|
||||
private var participants: [String: (lastSeen: Date, isLocal: Bool, nickname: String?)] = [:]
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let now: () -> Date
|
||||
private static let enabledKey = "bridge.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)
|
||||
self.injectedMessageIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
|
||||
}
|
||||
|
||||
// MARK: - Toggle & lifecycle
|
||||
|
||||
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()
|
||||
participants.removeAll()
|
||||
bridgedPeerCount = 0
|
||||
bridgedParticipants = []
|
||||
}
|
||||
SecureLogger.info("🌉 Bridge mode \(enabled ? "enabled" : "disabled")", category: .session)
|
||||
refreshRendezvous()
|
||||
onEnabledChanged?(enabled)
|
||||
}
|
||||
|
||||
/// Recomputes the active cell and (re)opens or closes the subscription.
|
||||
/// Call on toggle changes, location updates, and relay connectivity
|
||||
/// changes; idempotent.
|
||||
func refreshRendezvous() {
|
||||
let cell = isEnabled ? currentCell() : nil
|
||||
// No cell yet: ask for a fix — the availableChannels change re-enters
|
||||
// here once it lands.
|
||||
if isEnabled, cell == nil {
|
||||
requestLocationFix?()
|
||||
}
|
||||
guard cell != activeCell else {
|
||||
// The maintenance timer must run even cell-less: it is what
|
||||
// retries the location fix (launch races the permission
|
||||
// callback, so the first request can silently no-op).
|
||||
if isEnabled { armPresenceTimerIfNeeded() }
|
||||
return
|
||||
}
|
||||
if activeCell != nil {
|
||||
closeSubscription?()
|
||||
subscribedCells = []
|
||||
}
|
||||
activeCell = cell
|
||||
onActiveCellChanged?(cell)
|
||||
guard let cell else {
|
||||
if isEnabled { armPresenceTimerIfNeeded() }
|
||||
return
|
||||
}
|
||||
// Own cell + neighbors: islands straddling a cell edge still meet.
|
||||
// Publishes go to the own cell only; symmetric because both sides
|
||||
// subscribe to each other's cell via the neighbor ring.
|
||||
let cells = [cell] + Geohash.neighbors(of: cell)
|
||||
subscribedCells = Set(cells)
|
||||
openSubscription?(cells)
|
||||
SecureLogger.info("🌉 Bridge: rendezvous open for cell \(cell)", category: .session)
|
||||
publishPresence()
|
||||
armPresenceTimerIfNeeded()
|
||||
}
|
||||
|
||||
/// The rendezvous cell: our own location when we have it, else the cell
|
||||
/// a reachable bridge gateway advertises in its announce.
|
||||
private func currentCell() -> String? {
|
||||
if let own = locationCell?(), !own.isEmpty {
|
||||
return String(own.prefix(Limits.cellPrecision))
|
||||
}
|
||||
if let advertised = meshAdvertisedCell?(), GatewayService.isValidGeohash(advertised) {
|
||||
return String(advertised.prefix(Limits.cellPrecision))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// One switch does the right thing: while bridging, a device with
|
||||
// internet automatically serves its island (accepts deposits, carries
|
||||
// remote messages onto the radio). The marginal cost over bridging
|
||||
// yourself is small — the relay connections and subscription already
|
||||
// exist for you — and a separate "serve others" lever proved to be a
|
||||
// silent trap for mesh-only neighbors.
|
||||
|
||||
// MARK: - Outgoing (sender role)
|
||||
|
||||
/// Composes and ships the bridged copy of an outgoing public mesh
|
||||
/// message. Call after the radio send; no-op when the bridge is off,
|
||||
/// no cell is known, or the message was flagged nearby-only upstream.
|
||||
func bridgeOutgoing(content: String, messageID: String) {
|
||||
guard isEnabled, !nearbyOnly, let cell = activeCell ?? currentCell() else { return }
|
||||
guard content.utf8.count <= Limits.maxContentBytes else { return }
|
||||
guard let identity = try? deriveIdentity?(cell),
|
||||
let event = try? NostrProtocol.createBridgeMeshEvent(
|
||||
content: content,
|
||||
cell: cell,
|
||||
senderIdentity: identity,
|
||||
nickname: myNickname?(),
|
||||
meshMessageID: messageID
|
||||
) else {
|
||||
SecureLogger.error("🌉 Bridge: failed to compose rendezvous event", category: .session)
|
||||
return
|
||||
}
|
||||
publishedEventIDs.insert(event.id)
|
||||
injectedMessageIDs.insert(messageID) // our own timeline already has it
|
||||
if relaysConnected?() ?? false {
|
||||
publishToRelays?(event, cell)
|
||||
} else if let carrier = NostrCarrierPacket(direction: .toBridge, geohash: cell, event: event),
|
||||
let payload = carrier.encode(),
|
||||
let gateway = availableBridgePeers?().first {
|
||||
if sendToBridgePeer?(payload, gateway) ?? false {
|
||||
SecureLogger.debug("🌉 Bridge: uplinked own event via gateway \(gateway.id.prefix(8))…", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes a presence heartbeat so silent participants still register
|
||||
/// across the bridge. Throttled: several triggers (enable, cell change,
|
||||
/// relay reconnect) can coincide, and same-second heartbeats are
|
||||
/// byte-identical events anyway.
|
||||
func publishPresence() {
|
||||
guard isEnabled, let cell = activeCell, relaysConnected?() ?? false else { return }
|
||||
guard now().timeIntervalSince(lastPresenceAt) >= 30 else { return }
|
||||
lastPresenceAt = now()
|
||||
guard let identity = try? deriveIdentity?(cell),
|
||||
let event = try? NostrProtocol.createBridgePresenceEvent(cell: cell, senderIdentity: identity) else { return }
|
||||
publishedEventIDs.insert(event.id)
|
||||
publishToRelays?(event, cell)
|
||||
}
|
||||
|
||||
/// Maintenance heartbeat while bridging: presence, participant pruning,
|
||||
/// and a location retry. Runs with or without a cell — the cell-less
|
||||
/// case is exactly when the location retry matters.
|
||||
private func armPresenceTimerIfNeeded() {
|
||||
guard isEnabled, !presenceTimerArmed else { return }
|
||||
presenceTimerArmed = true
|
||||
let fire: @MainActor () -> Void = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.presenceTimerArmed = false
|
||||
self.publishPresence()
|
||||
self.pruneParticipants()
|
||||
// Location refresh: migrates cells on a moving device and
|
||||
// recovers a launch that raced the permission callback.
|
||||
if self.activeCell == nil {
|
||||
self.refreshRendezvous()
|
||||
} else {
|
||||
self.requestLocationFix?()
|
||||
}
|
||||
self.armPresenceTimerIfNeeded()
|
||||
}
|
||||
if let scheduleTimer {
|
||||
scheduleTimer(Limits.presenceIntervalSeconds, fire)
|
||||
} else {
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: UInt64(Limits.presenceIntervalSeconds * 1_000_000_000))
|
||||
fire()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subscription ingress (internet role)
|
||||
|
||||
/// Entry point for every event the rendezvous subscription delivers.
|
||||
/// Handles presence accounting, timeline injection, and — when acting as
|
||||
/// the island's gateway — downlink rebroadcast.
|
||||
func handleRendezvousEvent(_ event: NostrEvent) {
|
||||
guard isEnabled else { return }
|
||||
// The subscription spans our cell + neighbors; trust only the
|
||||
// event's own signed `r` tag, and only within that ring.
|
||||
guard let cell = event.tags.first(where: { $0.count >= 2 && $0[0] == "r" })?[1],
|
||||
subscribedCells.contains(cell) else {
|
||||
return
|
||||
}
|
||||
guard let kind = classify(event, cell: cell) else { return }
|
||||
// Events we published come back from our own subscription; they are
|
||||
// presence-neutral (we never count ourselves) and never re-injected
|
||||
// or rebroadcast. Two layers: the published-ID cache (this session)
|
||||
// and pubkey self-recognition — the rendezvous identity is derived
|
||||
// deterministically, so even after a relaunch wipes the cache our
|
||||
// own relay-backfilled events are recognized (field bug: own
|
||||
// pre-restart messages re-rendered as bridged).
|
||||
guard !publishedEventIDs.contains(event.id) else { return }
|
||||
if isOwnRendezvousEvent(event, cell: cell) {
|
||||
publishedEventIDs.insert(event.id) // never downlink it either
|
||||
return
|
||||
}
|
||||
guard event.isValidSignature() else { return }
|
||||
|
||||
switch kind {
|
||||
case .presence:
|
||||
recordParticipant(event.pubkey, isLocal: false, nickname: nil)
|
||||
case .message(let message):
|
||||
let isLocalRadioCopy = isMessageSeenLocally?(message.messageID) ?? false
|
||||
if isLocalRadioCopy {
|
||||
SecureLogger.debug("🌉 Bridge: radio copy of \(message.messageID.prefix(8))… already present; sender counted as local", category: .session)
|
||||
}
|
||||
recordParticipant(event.pubkey, isLocal: isLocalRadioCopy, nickname: message.senderNickname)
|
||||
inject(message)
|
||||
// Serving duty: carry remote islands' messages onto the radio for
|
||||
// mesh-only peers. Local-origin events are skipped — the island
|
||||
// already heard them (loop rule 3). The drain is jitter-delayed:
|
||||
// with every online bridger serving, the holdoff lets gateways
|
||||
// hear each other's broadcasts and skip duplicates.
|
||||
if !isLocalRadioCopy,
|
||||
!meshBroadcastEventIDs.contains(event.id),
|
||||
!rebroadcastEventIDs.contains(event.id),
|
||||
!pendingDownlinks.contains(where: { $0.event.id == event.id }) {
|
||||
pendingDownlinks.append((event, cell))
|
||||
if pendingDownlinks.count > Limits.maxPendingDownlinks {
|
||||
pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks)
|
||||
}
|
||||
scheduleDownlinkDrainIfNeeded(jitter: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mesh carrier ingress (both roles)
|
||||
|
||||
/// Entry point for received `nostrCarrier` packets with bridge
|
||||
/// directions. `directedToUs` is true for `toBridge` deposits addressed
|
||||
/// to this device; false for `fromBridge` broadcasts.
|
||||
func handleMeshCarrier(_ carrier: NostrCarrierPacket, from peerID: PeerID, directedToUs: Bool) {
|
||||
switch carrier.direction {
|
||||
case .toBridge:
|
||||
guard directedToUs else { return }
|
||||
handleUplinkDeposit(carrier, from: peerID)
|
||||
case .fromBridge:
|
||||
guard !directedToUs else { return }
|
||||
handleDownlinkBroadcast(carrier)
|
||||
case .toGateway, .fromGateway:
|
||||
return // GatewayService territory; routed there by the caller.
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Uplink (gateway role: mesh peer -> internet)
|
||||
|
||||
private func handleUplinkDeposit(_ carrier: NostrCarrierPacket, from depositor: PeerID) {
|
||||
guard isEnabled else { return }
|
||||
// Cheap structural gates before any crypto, mirroring GatewayService.
|
||||
guard let event = structurallyValidEvent(from: carrier) else {
|
||||
SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security)
|
||||
return
|
||||
}
|
||||
guard !meshBroadcastEventIDs.contains(event.id),
|
||||
!publishedEventIDs.contains(event.id),
|
||||
!queuedUplinks.contains(where: { $0.event.id == event.id }) else {
|
||||
return
|
||||
}
|
||||
guard allowUplinkDeposit(from: depositor) else {
|
||||
SecureLogger.debug("🌉 Bridge: rate-limited deposit from \(depositor.id.prefix(8))…", category: .session)
|
||||
return
|
||||
}
|
||||
guard event.isValidSignature() else {
|
||||
SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security)
|
||||
return
|
||||
}
|
||||
if relaysConnected?() ?? false {
|
||||
publish(event, cell: carrier.geohash)
|
||||
} else {
|
||||
enqueueUplink(QueuedUplink(depositor: depositor, cell: carrier.geohash, event: event))
|
||||
}
|
||||
// No local injection: the depositor's radio broadcast already carried
|
||||
// the message to this island, including us.
|
||||
}
|
||||
|
||||
/// Publish everything queued while relays were unreachable.
|
||||
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, cell: item.cell)
|
||||
}
|
||||
}
|
||||
|
||||
private func publish(_ event: NostrEvent, cell: String) {
|
||||
publishedEventIDs.insert(event.id)
|
||||
publishToRelays?(event, cell)
|
||||
SecureLogger.info("🌉 Bridge: published carried event \(event.id.prefix(8))… for cell \(cell)", category: .session)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func enqueueUplink(_ item: QueuedUplink) -> Bool {
|
||||
let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count
|
||||
guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else { 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
|
||||
if uplinkDepositTimes.count > Limits.maxTrackedEventIDs {
|
||||
uplinkDepositTimes = uplinkDepositTimes.filter { $0.value.contains { $0 >= cutoff } }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Downlink (gateway role: internet -> mesh)
|
||||
|
||||
private func drainPendingDownlinks() {
|
||||
let cutoff = now().addingTimeInterval(-60)
|
||||
downlinkSendTimes.removeAll { $0 < cutoff }
|
||||
while !pendingDownlinks.isEmpty,
|
||||
downlinkSendTimes.count < Limits.downlinkEventsPerMinute {
|
||||
let (event, cell) = pendingDownlinks.removeFirst()
|
||||
guard isFresh(event) else { continue }
|
||||
// Suppression recheck at send time: another gateway may have
|
||||
// broadcast this event during our jitter holdoff.
|
||||
guard !meshBroadcastEventIDs.contains(event.id),
|
||||
!rebroadcastEventIDs.contains(event.id) else { continue }
|
||||
guard let carrier = NostrCarrierPacket(direction: .fromBridge, geohash: cell, event: event),
|
||||
let payload = carrier.encode() else { continue }
|
||||
broadcastToMesh?(payload)
|
||||
SecureLogger.debug("🌉 Bridge: downlinked remote event \(event.id.prefix(8))… onto the mesh", category: .session)
|
||||
// Mark-after-send (loop rule 3): a queue-overflow drop stays
|
||||
// retryable on relay redelivery.
|
||||
rebroadcastEventIDs.insert(event.id)
|
||||
downlinkSendTimes.append(now())
|
||||
}
|
||||
scheduleDownlinkDrainIfNeeded()
|
||||
}
|
||||
|
||||
private func scheduleDownlinkDrainIfNeeded(jitter: Bool = false) {
|
||||
guard !pendingDownlinks.isEmpty, !downlinkDrainScheduled else { return }
|
||||
let delay: TimeInterval
|
||||
if jitter {
|
||||
// Multi-gateway suppression window: enough spread for another
|
||||
// gateway's broadcast to land and mark the event mesh-carried.
|
||||
delay = Double.random(in: 0.2...1.5)
|
||||
} else {
|
||||
let oldest = downlinkSendTimes.min() ?? now()
|
||||
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 scheduleTimer {
|
||||
scheduleTimer(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 radio)
|
||||
|
||||
private func handleDownlinkBroadcast(_ carrier: NostrCarrierPacket) {
|
||||
// Reception is deliberately NOT gated on the toggle: it is passive
|
||||
// radio, and two phones side by side should not disagree about what
|
||||
// the channel said. Publishing/subscribing remain opt-in.
|
||||
guard let event = structurallyValidEvent(from: carrier),
|
||||
!publishedEventIDs.contains(event.id),
|
||||
!isOwnRendezvousEvent(event, cell: carrier.geohash),
|
||||
event.isValidSignature() else {
|
||||
return
|
||||
}
|
||||
// Mark after verification (a forged copy must not poison the cache),
|
||||
// and use the marking as multi-path dedup.
|
||||
guard meshBroadcastEventIDs.insert(event.id) else { return }
|
||||
guard case .message(let message)? = classify(event, cell: carrier.geohash) else {
|
||||
return
|
||||
}
|
||||
recordParticipant(event.pubkey, isLocal: false, nickname: message.senderNickname)
|
||||
inject(message)
|
||||
}
|
||||
|
||||
// MARK: - Injection & participants
|
||||
|
||||
private func inject(_ message: InboundBridgeMessage) {
|
||||
guard injectedMessageIDs.insert(message.messageID) else { return }
|
||||
guard !(isMessageSeenLocally?(message.messageID) ?? false) else { return }
|
||||
SecureLogger.info("🌉 Bridge: injected bridged message \(message.messageID.prefix(8))… from \(message.senderNickname)", category: .session)
|
||||
injectInbound?(message)
|
||||
}
|
||||
|
||||
private func recordParticipant(_ pubkey: String, isLocal: Bool, nickname: String?) {
|
||||
let previous = participants[pubkey]
|
||||
// Local attribution is sticky: one radio-confirmed message marks the
|
||||
// pubkey as an islander for as long as they stay fresh. Presence
|
||||
// events carry no nickname, so a known name is never forgotten.
|
||||
participants[pubkey] = (
|
||||
lastSeen: now(),
|
||||
isLocal: (previous?.isLocal ?? false) || isLocal,
|
||||
nickname: nickname?.trimmedOrNilIfEmpty ?? previous?.nickname
|
||||
)
|
||||
recomputeBridgedCount()
|
||||
}
|
||||
|
||||
private func pruneParticipants() {
|
||||
let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds)
|
||||
participants = participants.filter { $0.value.lastSeen >= cutoff }
|
||||
recomputeBridgedCount()
|
||||
}
|
||||
|
||||
private func recomputeBridgedCount() {
|
||||
let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds)
|
||||
let visible = participants
|
||||
.filter { $0.value.lastSeen >= cutoff && !$0.value.isLocal }
|
||||
.map { BridgedParticipant(pubkey: $0.key, nickname: $0.value.nickname, lastSeen: $0.value.lastSeen) }
|
||||
.sorted { $0.lastSeen > $1.lastSeen }
|
||||
if visible.count != bridgedPeerCount {
|
||||
bridgedPeerCount = visible.count
|
||||
}
|
||||
if visible != bridgedParticipants {
|
||||
bridgedParticipants = visible
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
private enum RendezvousKind {
|
||||
case message(InboundBridgeMessage)
|
||||
case presence
|
||||
}
|
||||
|
||||
/// Classifies a structurally acceptable rendezvous event; nil rejects.
|
||||
private func classify(_ event: NostrEvent, cell: String) -> RendezvousKind? {
|
||||
guard isFresh(event),
|
||||
event.tags.contains(where: { $0.count >= 2 && $0[0] == "r" && $0[1] == cell }),
|
||||
GatewayService.isValidGeohash(cell) else {
|
||||
return nil
|
||||
}
|
||||
switch event.kind {
|
||||
case NostrProtocol.EventKind.geohashPresence.rawValue:
|
||||
return .presence
|
||||
case NostrProtocol.EventKind.ephemeralEvent.rawValue:
|
||||
let content = event.content
|
||||
guard !content.trimmed.isEmpty, content.utf8.count <= Limits.maxContentBytes else { return nil }
|
||||
let nickname = event.tags.first(where: { $0.count >= 2 && $0[0] == "n" })?[1]
|
||||
let meshID = event.tags.first(where: { $0.count >= 2 && $0[0] == "m" })?[1]
|
||||
let messageID = (meshID?.trimmedOrNilIfEmpty) ?? event.id
|
||||
return .message(InboundBridgeMessage(
|
||||
messageID: messageID,
|
||||
senderNickname: nickname?.trimmedOrNilIfEmpty ?? "anon#\(event.pubkey.prefix(4))",
|
||||
senderPubkey: event.pubkey,
|
||||
content: content,
|
||||
timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse + size + cell + kind + `r` tag + freshness, with NO signature
|
||||
/// verification — callers dedup/rate-limit first, Schnorr-verify last.
|
||||
private func structurallyValidEvent(from carrier: NostrCarrierPacket) -> NostrEvent? {
|
||||
guard carrier.eventJSON.count <= NostrCarrierPacket.maxEventJSONBytes,
|
||||
GatewayService.isValidGeohash(carrier.geohash),
|
||||
let event = carrier.event(),
|
||||
classify(event, cell: carrier.geohash) != nil else {
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
private func isFresh(_ event: NostrEvent) -> Bool {
|
||||
abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds
|
||||
}
|
||||
|
||||
/// True when the event was signed by this device's own derived
|
||||
/// rendezvous identity for the cell. Survives relaunches (unlike the
|
||||
/// published-ID cache) because the derivation is deterministic; the
|
||||
/// underlying identity cache makes this cheap.
|
||||
private func isOwnRendezvousEvent(_ event: NostrEvent, cell: String) -> Bool {
|
||||
guard let identity = try? deriveIdentity?(cell) else { return false }
|
||||
return identity.publicKeyHex.lowercased() == event.pubkey.lowercased()
|
||||
}
|
||||
}
|
||||