diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift index 3f9cacd7..a920c128 100644 --- a/bitchat/App/AppChromeModel.swift +++ b/bitchat/App/AppChromeModel.swift @@ -18,6 +18,9 @@ final class AppChromeModel: ObservableObject { private let chatViewModel: ChatViewModel private var cancellables = Set() + /// 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 @@ -59,6 +62,28 @@ final class AppChromeModel: ObservableObject { isAppInfoPresented = 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 } diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index a706cfac..fd8f0077 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -349,12 +349,12 @@ private extension AppRuntime { TorManager.shared.isAutoStartAllowed() { chatViewModel.torStatusAnnounced = true chatViewModel.addGeohashOnlySystemMessage( - String(localized: "system.tor.starting", comment: "System message when Tor is starting") + String(localized: "system.tor.starting", defaultValue: "starting tor...", comment: "System message when Tor is starting") ) } else if !TorManager.shared.torEnforced && !chatViewModel.torStatusAnnounced { chatViewModel.torStatusAnnounced = true chatViewModel.addGeohashOnlySystemMessage( - String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development") + String(localized: "system.tor.dev_bypass", defaultValue: "development build: Tor bypass enabled.", comment: "System message when Tor bypass is enabled in development") ) } } diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index 461449cc..f8633e0d 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -193,7 +193,9 @@ final class ConversationUIModel: ObservableObject { private func refreshComputedState() { if let selectedPeerID = privateConversationModel.selectedPeerID { - canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat) + // 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) return } diff --git a/bitchat/App/LocationChannelsModel.swift b/bitchat/App/LocationChannelsModel.swift index c820455a..d7431d6d 100644 --- a/bitchat/App/LocationChannelsModel.swift +++ b/bitchat/App/LocationChannelsModel.swift @@ -12,20 +12,26 @@ final class LocationChannelsModel: ObservableObject { @Published private(set) var bookmarkNames: [String: String] @Published private(set) var locationNames: [GeohashChannelLevel: String] @Published private(set) var userTorEnabled: Bool + @Published private(set) var gatewayEnabled: Bool private let manager: LocationChannelManager private let network: NetworkActivationService + private let gateway: GatewayService private var cancellables = Set() init( manager: LocationChannelManager? = nil, - network: NetworkActivationService? = nil + network: NetworkActivationService? = nil, + gateway: GatewayService? = nil ) { let manager = manager ?? .shared let network = network ?? .shared + let gateway = gateway ?? .shared self.manager = manager self.network = network + self.gateway = gateway + self.gatewayEnabled = gateway.isEnabled self.permissionState = manager.permissionState self.availableChannels = manager.availableChannels self.selectedChannel = manager.selectedChannel @@ -96,6 +102,10 @@ final class LocationChannelsModel: ObservableObject { network.setUserTorEnabled(enabled) } + func setGatewayEnabled(_ enabled: Bool) { + gateway.setEnabled(enabled) + } + func refreshMeshChannelsIfNeeded() { guard case .mesh = selectedChannel, permissionState == .authorized, @@ -160,6 +170,10 @@ final class LocationChannelsModel: ObservableObject { network.$userTorEnabled .receive(on: DispatchQueue.main) .assign(to: &$userTorEnabled) + + gateway.$isEnabled + .receive(on: DispatchQueue.main) + .assign(to: &$gatewayEnabled) } private func level(forLength length: Int) -> GeohashChannelLevel { diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index 38f20a1c..b96c2e72 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -14,6 +14,9 @@ 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 } } @@ -26,11 +29,22 @@ 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 @@ -129,6 +143,13 @@ 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 @@ -183,13 +204,12 @@ final class PeerListModel: ObservableObject { let myPeerID = chatViewModel.meshService.myPeerID let meshRows = allPeers.map { peer in let isMe = peer.peerID == myPeerID - let verifiedBadge: Bool - if !isMe && !peer.isConnected, - let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) { - verifiedBadge = peerIdentityStore.isVerified(fingerprint) - } else { - verifiedBadge = false - } + 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) return MeshPeerRow( peerID: peer.peerID, @@ -202,7 +222,8 @@ final class PeerListModel: ObservableObject { isReachable: peer.isReachable, isMutualFavorite: peer.isMutualFavorite, encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID), - showsVerifiedBadgeWhenOffline: verifiedBadge + showsVerifiedBadgeWhenOffline: verifiedBadge, + showsVouchedBadge: vouchedBadge ) } @@ -217,22 +238,40 @@ 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() }) diff --git a/bitchat/App/PrivateConversationModels.swift b/bitchat/App/PrivateConversationModels.swift index 2a465948..e8232b32 100644 --- a/bitchat/App/PrivateConversationModels.swift +++ b/bitchat/App/PrivateConversationModels.swift @@ -108,7 +108,13 @@ struct PrivateConversationHeaderState: Equatable { let encryptionStatus: EncryptionStatus? var supportsFavoriteToggle: Bool { - !conversationPeerID.isGeoDM + !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 } } @@ -206,6 +212,13 @@ 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 @@ -229,6 +242,26 @@ 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", defaultValue: "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) @@ -295,7 +328,7 @@ final class PrivateConversationModel: ObservableObject { } } - return String(localized: "common.unknown", comment: "Fallback label for unknown peer") + return String(localized: "common.unknown", defaultValue: "unknown", comment: "Fallback label for unknown peer") } private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability { diff --git a/bitchat/App/VerificationModel.swift b/bitchat/App/VerificationModel.swift index bf31f05f..a265d709 100644 --- a/bitchat/App/VerificationModel.swift +++ b/bitchat/App/VerificationModel.swift @@ -9,6 +9,14 @@ struct FingerprintPresentationState: Equatable { 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 @@ -82,6 +90,24 @@ 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, @@ -89,7 +115,9 @@ final class VerificationModel: ObservableObject { encryptionStatus: encryptionStatus, theirFingerprint: theirFingerprint, myFingerprint: chatViewModel.getMyFingerprint(), - isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false + isVerified: isVerified, + voucherCount: vouchers.count, + voucherNames: voucherNames ) } @@ -122,6 +150,17 @@ 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 { @@ -147,6 +186,6 @@ final class VerificationModel: ObservableObject { } } - return String(localized: "common.unknown", comment: "Label for an unknown peer") + return String(localized: "common.unknown", defaultValue: "unknown", comment: "Label for an unknown peer") } } diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index e606506f..1e81e87d 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -8,6 +8,9 @@ import SwiftUI import UserNotifications +#if DEBUG +import BitLogger +#endif @main struct BitchatApp: App { @@ -43,6 +46,11 @@ struct BitchatApp: App { .onAppear { appDelegate.runtime = runtime runtime.start() + #if DEBUG + // Arm the opt-in UDP log sink from persisted config (no-op + // until a collector host is set in the App Info sheet). + LogNetworkSink.shared.reloadConfiguration() + #endif } .onOpenURL { url in runtime.handleOpenURL(url) diff --git a/bitchat/Identity/IdentityModels.swift b/bitchat/Identity/IdentityModels.swift index ad83962f..921a071a 100644 --- a/bitchat/Identity/IdentityModels.swift +++ b/bitchat/Identity/IdentityModels.swift @@ -126,13 +126,37 @@ 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. @@ -154,7 +178,22 @@ struct IdentityCache: Codable { // Blocked Nostr pubkeys (lowercased hex) for geohash chats var blockedNostrPubkeys: Set = [] - + + // 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 } diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index fe63f871..021ea776 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -133,6 +133,17 @@ protocol SecureIdentityStateManagerProtocol { func setVerified(fingerprint: String, verified: Bool) func isVerified(fingerprint: String) -> Bool func getVerifiedFingerprints() -> Set + + // 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 effectiveTrustLevel(for fingerprint: String) -> TrustLevel + 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. @@ -550,16 +561,20 @@ 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() } } @@ -576,6 +591,159 @@ 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] { queue.sync { cache.nicknameIndex } } diff --git a/bitchat/Info.plist b/bitchat/Info.plist index f949ecee..1a6d4fbb 100644 --- a/bitchat/Info.plist +++ b/bitchat/Info.plist @@ -33,12 +33,18 @@ $(CURRENT_PROJECT_VERSION) LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) + NSBonjourServices + + _bitchat-bulk._tcp + NSBluetoothAlwaysUsageDescription bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users. NSBluetoothPeripheralUsageDescription bitchat uses Bluetooth to discover and connect with other bitchat users nearby. NSCameraUsageDescription bitchat uses the camera to scan QR codes to verify peers. + NSLocalNetworkUsageDescription + bitchat uses peer-to-peer Wi-Fi to transfer large photos and voice notes directly between nearby devices. NSLocationWhenInUseUsageDescription bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared. NSMicrophoneUsageDescription diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 7301e613..f7979b8d 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -5142,7 +5142,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "symbols" + "value" : "SYMBOLS" } } } @@ -5171,6 +5171,54 @@ } } }, + "app_info.network.title" : { + "comment" : "Section header for network diagnostics in the app info sheet", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "NETWORK" + } + } + } + }, + "app_info.network.topology.description" : { + "comment" : "Row description for the mesh topology map", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "map of peers and links learned from mesh announces" + } + } + } + }, + "app_info.network.topology.hint" : { + "comment" : "Accessibility hint for the mesh topology row", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens the mesh topology map" + } + } + } + }, + "app_info.network.topology.title" : { + "comment" : "Row title opening the mesh topology map", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh topology" + } + } + } + }, "app_info.privacy.ephemeral.description" : { "extractionState" : "manual", "localizations" : { @@ -9348,6 +9396,29 @@ } } }, + "content.accessibility.gateway_active" : { + "comment" : "Accessibility label for the internet gateway indicator", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Internet gateway active, sharing your connection with the mesh" + } + } + } + }, + "content.accessibility.group_chat" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group chat" + } + } + } + }, "content.accessibility.jump_to_latest" : { "comment" : "Accessibility label for the jump to latest messages button", "extractionState" : "manual", @@ -15055,6 +15126,17 @@ } } }, + "content.commands.group" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "create or manage private groups" + } + } + } + }, "content.commands.help" : { "comment" : "Description of the /help command in the suggestions panel", "extractionState" : "manual", @@ -15425,6 +15507,30 @@ } } }, + "content.commands.pay" : { + "comment" : "Autocomplete description for the /pay command that sends a Cashu ecash token", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "send a cashu ecash token in this chat" + } + } + } + }, + "content.commands.ping" : { + "comment" : "Description of the /ping command in the suggestions panel", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "measure round-trip time to a mesh peer" + } + } + } + }, "content.commands.slap" : { "extractionState" : "manual", "localizations" : { @@ -15604,6 +15710,18 @@ } } }, + "content.commands.trace" : { + "comment" : "Description of the /trace command in the suggestions panel", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "estimate the mesh path to a peer" + } + } + } + }, "content.commands.unblock" : { "extractionState" : "manual", "localizations" : { @@ -17872,6 +17990,18 @@ } } }, + "content.header.gateway_active" : { + "comment" : "Tooltip for the internet gateway indicator", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sharing your internet connection with nearby mesh peers" + } + } + } + }, "content.header.people" : { "extractionState" : "manual", "localizations" : { @@ -18230,6 +18360,17 @@ } } }, + "content.input.group_placeholder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "create|invite|leave|list" + } + } + } + }, "content.input.placeholder.location" : { "comment" : "Composer placeholder for a public geohash channel, naming it", "extractionState" : "manual", @@ -18469,6 +18610,18 @@ } } }, + "content.input.token_placeholder" : { + "comment" : "Placeholder shown after /pay in the command suggestion panel", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "token" + } + } + } + }, "content.jump.new_count" : { "comment" : "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill", "extractionState" : "manual", @@ -19734,6 +19887,18 @@ } } }, + "content.payment.copy_token" : { + "comment" : "Context menu action copying a Cashu token to the pasteboard", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "copy token" + } + } + } + }, "content.payment.lightning" : { "extractionState" : "manual", "localizations" : { @@ -19913,6 +20078,41 @@ } } }, + "content.payment.redeem_wallet" : { + "comment" : "Context menu action opening a Cashu token in an ecash wallet app", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "redeem in wallet" + } + } + } + }, + "content.payment.redeem_web" : { + "comment" : "Context menu action opening a Cashu token in the web redemption page", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "redeem on web" + } + } + } + }, + "content.private.caption_group" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "encrypted group · members only" + } + } + } + }, "encryption.accessibility.establishing" : { "extractionState" : "manual", "localizations" : { @@ -22419,6 +22619,18 @@ } } }, + "fingerprint.badge.vouched" : { + "comment" : "Badge shown when a peer is vouched for by people the user verified but not directly verified", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ VOUCHED" + } + } + } + }, "fingerprint.handshake_pending" : { "extractionState" : "manual", "localizations" : { @@ -22956,6 +23168,40 @@ } } }, + "fingerprint.message.vouched_by" : { + "comment" : "How many people the user verified have vouched for this peer", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "vouched for by %#@people@ you verified" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d person" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d people" + } + } + } + } + } + } + } + } + }, "fingerprint.their_label" : { "extractionState" : "manual", "localizations" : { @@ -24424,6 +24670,50 @@ } } }, + "groups.accessibility.open_group_hint" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opens the group chat" + } + } + } + }, + "groups.member_count %@" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "(%@)" + } + } + } + }, + "groups.section.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "groups" + } + } + } + }, + "groups.state.creator" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creator" + } + } + } + }, "location_channels.accessibility.add_bookmark" : { "comment" : "Accessibility action name for bookmarking a channel", "extractionState" : "manual", @@ -25713,6 +26003,30 @@ } } }, + "location_channels.gateway.subtitle" : { + "comment" : "Explanation under the internet gateway toggle", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "share your internet with nearby mesh peers so their geohash messages reach the network" + } + } + } + }, + "location_channels.gateway.title" : { + "comment" : "Title for the internet gateway toggle in the location channels sheet", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "internet gateway" + } + } + } + }, "location_channels.loading_nearby" : { "extractionState" : "manual", "localizations" : { @@ -31924,6 +32238,18 @@ } } }, + "mesh_peers.state.vouched" : { + "comment" : "State label for a peer vouched for by someone the user verified", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "vouched" + } + } + } + }, "mesh_peers.tooltip.new_messages" : { "extractionState" : "manual", "localizations" : { @@ -32103,6 +32429,18 @@ } } }, + "mesh_peers.tooltip.vouched" : { + "comment" : "Tooltip for the vouched (unfilled seal) badge next to a peer", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "vouched for by someone you verified" + } + } + } + }, "recording %@" : { "comment" : "Voice note recording duration indicator", "localizations" : { @@ -33535,6 +33873,18 @@ } } }, + "system.gateway.sent_via_mesh" : { + "comment" : "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sent via mesh gateway" + } + } + } + }, "system.geohash.blocked" : { "extractionState" : "manual", "localizations" : { @@ -33893,6 +34243,303 @@ } } }, + "system.group.already_member" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ is already a member" + } + } + } + }, + "system.group.cannot_remove_creator" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "the creator cannot be removed" + } + } + } + }, + "system.group.create_failed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not create the group" + } + } + } + }, + "system.group.created" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "created group '%@' — use /group invite @name to add people" + } + } + } + }, + "system.group.creator_only" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "only the group creator can do that" + } + } + } + }, + "system.group.full" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "group is full (max %@ members)" + } + } + } + }, + "system.group.identity_unavailable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "your identity keys are not ready yet" + } + } + } + }, + "system.group.invite_failed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not build the group invite" + } + } + } + }, + "system.group.invited" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "invited %1$@ to '%2$@'" + } + } + } + }, + "system.group.joined" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you were added to group '%1$@' by %2$@ — it now appears in your people list" + } + } + } + }, + "system.group.left" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "left group '%@'" + } + } + } + }, + "system.group.list_header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "your groups:" + } + } + } + }, + "system.group.member_not_found" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' is not a member of this group" + } + } + } + }, + "system.group.name_too_long" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "group names are limited to 40 characters" + } + } + } + }, + "system.group.none" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you are not in any groups — /group create to start one" + } + } + } + }, + "system.group.not_in_group" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "open a group chat first" + } + } + } + }, + "system.group.peer_identity_unknown" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cannot verify %@'s identity yet — wait for their announce" + } + } + } + }, + "system.group.peer_not_connected" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ must be connected over mesh to be invited" + } + } + } + }, + "system.group.peer_not_found" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' not found" + } + } + } + }, + "system.group.removed_from" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you were removed from group '%@'" + } + } + } + }, + "system.group.removed_member" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "removed %@ and rotated the group key" + } + } + } + }, + "system.group.rotate_failed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not rotate the group key" + } + } + } + }, + "system.group.send_failed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not encrypt the message" + } + } + } + }, + "system.group.unknown" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you are no longer in this group" + } + } + } + }, + "system.group.usage_create" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /group create " + } + } + } + }, + "system.group.usage_invite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /group invite @name" + } + } + } + }, + "system.group.usage_remove" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /group remove @name" + } + } + } + }, "system.location.not_in_channel" : { "extractionState" : "manual", "localizations" : { @@ -35194,6 +35841,66 @@ } } }, + "topology.caption" : { + "comment" : "Caption under the mesh topology map", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "estimated from gossiped neighbor lists (up to 10 per peer) — your device is highlighted" + } + } + } + }, + "topology.empty" : { + "comment" : "Empty state of the mesh topology map", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no mesh links yet — the map fills in as peer announces arrive" + } + } + } + }, + "topology.refresh" : { + "comment" : "Accessibility label of the topology refresh button", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "refresh topology" + } + } + } + }, + "topology.summary" : { + "comment" : "Topology map summary: number of peers and links", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$ld peers · %2$ld links" + } + } + } + }, + "topology.title" : { + "comment" : "Title of the mesh topology map sheet", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh topology" + } + } + } + }, "verification.my_qr.accessibility_label" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/Models/CommandInfo.swift b/bitchat/Models/CommandInfo.swift index 1f8245be..b656bad3 100644 --- a/bitchat/Models/CommandInfo.swift +++ b/bitchat/Models/CommandInfo.swift @@ -16,14 +16,18 @@ 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 var id: String { rawValue } @@ -31,8 +35,12 @@ enum CommandInfo: String, Identifiable { var placeholder: String? { switch self { - case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite: - return "<" + String(localized: "content.input.nickname_placeholder") + ">" + case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace: + return "<" + String(localized: "content.input.nickname_placeholder", defaultValue: "nickname") + ">" + case .group: + return "<" + String(localized: "content.input.group_placeholder", defaultValue: "create|invite|leave|list") + ">" + case .pay: + return "<" + String(localized: "content.input.token_placeholder", defaultValue: "token") + ">" case .clear, .help, .who: return nil } @@ -40,26 +48,37 @@ enum CommandInfo: String, Identifiable { var description: String { switch self { - case .block: String(localized: "content.commands.block") - case .clear: String(localized: "content.commands.clear") - case .help: String(localized: "content.commands.help") - case .hug: String(localized: "content.commands.hug") - case .message: String(localized: "content.commands.message") - 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 .block: String(localized: "content.commands.block", defaultValue: "block or list blocked peers") + case .clear: String(localized: "content.commands.clear", defaultValue: "clear chat messages") + case .group: String(localized: "content.commands.group", defaultValue: "create or manage private groups") + case .help: String(localized: "content.commands.help", defaultValue: "show available commands") + case .hug: String(localized: "content.commands.hug", defaultValue: "send someone a warm hug") + case .message: String(localized: "content.commands.message", defaultValue: "send private message") + case .pay: String(localized: "content.commands.pay", defaultValue: "send a cashu ecash token in this chat") + case .slap: String(localized: "content.commands.slap", defaultValue: "slap someone with a trout") + case .unblock: String(localized: "content.commands.unblock", defaultValue: "unblock a peer") + case .who: String(localized: "content.commands.who", defaultValue: "see who's online") + case .favorite: String(localized: "content.commands.favorite", defaultValue: "add to favorites") + case .unfavorite: String(localized: "content.commands.unfavorite", defaultValue: "remove from favorites") + case .ping: String(localized: "content.commands.ping", defaultValue: "measure round-trip time to a mesh peer") + case .trace: String(localized: "content.commands.trace", defaultValue: "estimate the mesh path to a peer") } } static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] { - 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 baseCommands + var commands: [CommandInfo] = [.block, .unblock, .clear, .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) } - return baseCommands + [.favorite, .unfavorite] + // The processor rejects favorites, groups and mesh diagnostics in + // geohash contexts, so only suggest them where they actually work: mesh. + if isGeoPublic || isGeoDM { + return commands + } + return commands + [.favorite, .unfavorite, .group, .ping, .trace] } } diff --git a/bitchat/Models/RequestSyncPacket.swift b/bitchat/Models/RequestSyncPacket.swift index b34dd925..0c6cb3cd 100644 --- a/bitchat/Models/RequestSyncPacket.swift +++ b/bitchat/Models/RequestSyncPacket.swift @@ -1,19 +1,21 @@ +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 (bitfield) — SyncTypeFlags of covered message types -// - 0x05: sinceTimestamp (uint64, big-endian) — oldest ts the filter covers -// - 0x06: fragmentIdFilter (utf8) — reserved -// -// TODO(v2): fragmentIdFilter (0x06) is parsed and re-serialized but never -// populated or honored — it's the reserved surface for incremental fragment -// sync (request the missing fragments of one file by ID instead of diffing the -// whole fragment set). Either wire it into buildGcsPayload/_handleRequestSync -// or drop the field; don't leave it as silent dead protocol surface. +// - 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 @@ -21,6 +23,29 @@ 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? { + guard let filter else { return nil } + var ids: Set = [] + 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 @@ -97,7 +122,9 @@ struct RequestSyncPacket { sinceTimestamp = ts } case 0x06: - if let fid = String(data: v, encoding: .utf8) { + // 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) { fragmentIdFilter = fid } default: diff --git a/bitchat/Nostr/NostrPoW.swift b/bitchat/Nostr/NostrPoW.swift new file mode 100644 index 00000000..006bba6d --- /dev/null +++ b/bitchat/Nostr/NostrPoW.swift @@ -0,0 +1,215 @@ +import BitFoundation +import CryptoKit +import Foundation + +/// NIP-13 proof-of-work for Nostr events. +/// +/// Outgoing kind-20000 geohash messages mine a `["nonce", "", ""]` +/// 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: 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)? { + 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..= 0, lastDiff - firstDiff + 1 == nonceLength else { return nil } + return (zeros, firstDiff..<(firstDiff + nonceLength)) + } +} diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index d59a5200..83578e2b 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -170,6 +170,63 @@ 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]) @@ -177,15 +234,7 @@ struct NostrProtocol { if teleported { tags.append(["t", "teleport"]) } - 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) + return tags } /// Create a geohash presence heartbeat (kind 20001) diff --git a/bitchat/Protocols/BitchatFilePacket.swift b/bitchat/Protocols/BitchatFilePacket.swift index 6002744d..0965bee5 100644 --- a/bitchat/Protocols/BitchatFilePacket.swift +++ b/bitchat/Protocols/BitchatFilePacket.swift @@ -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(_ 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), diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 4ebe6509..e9603598 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -18,7 +18,7 @@ /// - Efficient binary message encoding /// - Message fragmentation for large payloads /// - TTL-based routing for mesh networks -/// - Privacy features like padding and timing obfuscation +/// - Privacy features: message padding and randomized relay jitter /// - Integration points for end-to-end encryption /// /// ## Protocol Design @@ -38,18 +38,20 @@ /// 7. **Decoding**: Binary data parsed back to message objects /// /// ## Security Considerations -/// - Message padding obscures actual content length -/// - Timing obfuscation prevents traffic analysis +/// - 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 /// - Integration with Noise Protocol for E2E encryption /// - No persistent identifiers in protocol headers /// /// ## Message Types /// - **Announce/Leave**: Peer presence notifications -/// - **Message**: User chat messages (broadcast or directed) +/// - **Message**: Public chat messages /// - **Fragment**: Multi-part message handling -/// - **Delivery/Read**: Message acknowledgments -/// - **Noise**: Encrypted channel establishment -/// - **Version**: Protocol version negotiation +/// - **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 /// /// ## Future Extensions /// The protocol is designed to be extensible: @@ -72,17 +74,30 @@ enum NoisePayloadType: UInt8 { case privateMessage = 0x01 // Private chat message case readReceipt = 0x02 // Message was read case delivered = 0x03 // Message was delivered + // 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 + // Private groups + case groupInvite = 0x06 // Creator-signed group state (invite) + case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update) // 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 .bulkTransferOffer: return "bulkTransferOffer" + case .bulkTransferResponse: return "bulkTransferResponse" + case .groupInvite: return "groupInvite" + case .groupKeyUpdate: return "groupKeyUpdate" case .verifyChallenge: return "verifyChallenge" case .verifyResponse: return "verifyResponse" + case .vouch: return "vouch" } } } @@ -114,6 +129,9 @@ 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) + // Bluetooth state updates for user notifications func didUpdateBluetoothState(_ state: CBManagerState) func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) @@ -133,6 +151,10 @@ extension BitchatDelegate { // Default empty implementation } + func didReceiveGroupMessage(payload: Data, timestamp: Date) { + // Default empty implementation + } + func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { // Default empty implementation } diff --git a/bitchat/Protocols/BoardPackets.swift b/bitchat/Protocols/BoardPackets.swift new file mode 100644 index 00000000..d265a1c1 --- /dev/null +++ b/bitchat/Protocols/BoardPackets.swift @@ -0,0 +1,348 @@ +// +// BoardPackets.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +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) + } +} diff --git a/bitchat/Protocols/LocationChannel.swift b/bitchat/Protocols/LocationChannel.swift index cbe1c55f..e2864b0d 100644 --- a/bitchat/Protocols/LocationChannel.swift +++ b/bitchat/Protocols/LocationChannel.swift @@ -24,17 +24,17 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable { var displayName: String { switch self { case .building: - return String(localized: "location_levels.building", comment: "Name for building-level location channel") + return String(localized: "location_levels.building", defaultValue: "building", comment: "Name for building-level location channel") case .block: - return String(localized: "location_levels.block", comment: "Name for block-level location channel") + return String(localized: "location_levels.block", defaultValue: "block", comment: "Name for block-level location channel") case .neighborhood: - return String(localized: "location_levels.neighborhood", comment: "Name for neighborhood-level location channel") + return String(localized: "location_levels.neighborhood", defaultValue: "neighborhood", comment: "Name for neighborhood-level location channel") case .city: - return String(localized: "location_levels.city", comment: "Name for city-level location channel") + return String(localized: "location_levels.city", defaultValue: "city", comment: "Name for city-level location channel") case .province: - return String(localized: "location_levels.province", comment: "Name for province-level location channel") + return String(localized: "location_levels.province", defaultValue: "province", comment: "Name for province-level location channel") case .region: - return String(localized: "location_levels.region", comment: "Name for region-level location channel") + return String(localized: "location_levels.region", defaultValue: "region", comment: "Name for region-level location channel") } } } diff --git a/bitchat/Protocols/NostrCarrierPacket.swift b/bitchat/Protocols/NostrCarrierPacket.swift new file mode 100644 index 00000000..e65f2b66 --- /dev/null +++ b/bitchat/Protocols/NostrCarrierPacket.swift @@ -0,0 +1,136 @@ +// +// NostrCarrierPacket.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation + +/// Wire payload for `MessageType.nostrCarrier` (0x28): a complete, signed +/// Nostr event ferried over the mesh between a mesh-only peer and an +/// internet gateway peer. +/// +/// - `toGateway` rides a DIRECTED packet (recipientID = the gateway peer): +/// a mesh-only sender asks the gateway to publish its locally signed +/// geohash event to Nostr relays. +/// - `fromGateway` rides a BROADCAST packet (default TTL): the gateway +/// rebroadcasts inbound relay events so mesh-only peers see the channel. +/// +/// The carried event is public geohash chat — already plaintext on Nostr — +/// so the carrier adds no encryption. It IS signed by the originator's +/// per-geohash identity, so neither the gateway nor any mesh relay can forge +/// or alter it undetected: gateways and receivers verify the Schnorr +/// signature before acting on it. +/// +/// TLV encoding with 2-byte big-endian lengths (the event JSON exceeds the +/// 1-byte TLV range used by smaller packets). Unknown TLV types are skipped +/// for forward compatibility. +struct NostrCarrierPacket: Equatable { + enum Direction: UInt8 { + case toGateway = 0x01 + case fromGateway = 0x02 + } + + let direction: Direction + let geohash: String + /// Complete signed Nostr event JSON (id, pubkey, created_at, kind, tags, + /// content, sig). + let eventJSON: Data + + /// BLE airtime cap for a carried event. + static let maxEventJSONBytes = 16 * 1024 + static let maxGeohashLength = 12 + + private enum TLVType: UInt8 { + case direction = 0x01 + case geohash = 0x02 + case eventJSON = 0x03 + } + + init?(direction: Direction, geohash: String, eventJSON: Data) { + let geohashBytes = Data(geohash.utf8) + guard !geohashBytes.isEmpty, + geohashBytes.count <= Self.maxGeohashLength, + !eventJSON.isEmpty, + eventJSON.count <= Self.maxEventJSONBytes else { + return nil + } + self.direction = direction + self.geohash = geohash + self.eventJSON = eventJSON + } + + init?(direction: Direction, geohash: String, event: NostrEvent) { + guard let json = try? event.jsonString(), !json.isEmpty else { return nil } + self.init(direction: direction, geohash: geohash, eventJSON: Data(json.utf8)) + } + + /// Decodes the carried event. Callers MUST still verify + /// `event.isValidSignature()` before publishing or displaying it. + func event() -> NostrEvent? { + guard let dict = try? JSONSerialization.jsonObject(with: eventJSON) as? [String: Any] else { + return nil + } + return try? NostrEvent(from: dict) + } + + func encode() -> Data? { + var data = Data() + data.reserveCapacity(eventJSON.count + geohash.utf8.count + 12) + + func appendTLV(_ type: TLVType, _ value: Data) { + data.append(type.rawValue) + data.append(UInt8((value.count >> 8) & 0xFF)) + data.append(UInt8(value.count & 0xFF)) + data.append(value) + } + + appendTLV(.direction, Data([direction.rawValue])) + appendTLV(.geohash, Data(geohash.utf8)) + appendTLV(.eventJSON, eventJSON) + return data + } + + static func decode(_ data: Data) -> NostrCarrierPacket? { + // Defensive slice re-base (Data slices keep parent indices). + let data = Data(data) + var offset = 0 + var direction: Direction? + var geohash: String? + var eventJSON: Data? + + while offset + 3 <= data.count { + let typeRaw = data[offset] + let length = (Int(data[offset + 1]) << 8) | Int(data[offset + 2]) + offset += 3 + guard offset + length <= data.count else { return nil } + let value = data.subdata(in: offset.. Data? { @@ -48,6 +65,15 @@ struct AnnouncementPacket { } } + // TLV for capabilities (optional) + if let capabilities = capabilities { + let capabilityBytes = capabilities.encoded() + guard capabilityBytes.count <= 255 else { return nil } + data.append(TLVType.capabilities.rawValue) + data.append(UInt8(capabilityBytes.count)) + data.append(capabilityBytes) + } + return data } @@ -57,6 +83,7 @@ struct AnnouncementPacket { var noisePublicKey: Data? var signingPublicKey: Data? var directNeighbors: [Data]? + var capabilities: PeerCapabilities? while offset + 2 <= data.count { let typeRaw = data[offset] @@ -87,6 +114,8 @@ struct AnnouncementPacket { } directNeighbors = neighbors } + case .capabilities: + capabilities = PeerCapabilities(encoded: Data(value)) } } else { // Unknown TLV; skip (tolerant decoder for forward compatibility) @@ -99,7 +128,8 @@ struct AnnouncementPacket { nickname: nickname, noisePublicKey: noisePublicKey, signingPublicKey: signingPublicKey, - directNeighbors: directNeighbors + directNeighbors: directNeighbors, + capabilities: capabilities ) } } diff --git a/bitchat/Protocols/PeerCapabilities+Local.swift b/bitchat/Protocols/PeerCapabilities+Local.swift new file mode 100644 index 00000000..62eb6cea --- /dev/null +++ b/bitchat/Protocols/PeerCapabilities+Local.swift @@ -0,0 +1,11 @@ +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 = { + var caps: PeerCapabilities = [.prekeys, .vouch, .groups] + if TransportConfig.wifiBulkEnabled { caps.insert(.wifiBulk) } + return caps + }() +} diff --git a/bitchat/Protocols/VouchAttestation.swift b/bitchat/Protocols/VouchAttestation.swift new file mode 100644 index 00000000..a765a11b --- /dev/null +++ b/bitchat/Protocols/VouchAttestation.swift @@ -0,0 +1,225 @@ +// +// VouchAttestation.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +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.. 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.. Result { - 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 { + 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)) } diff --git a/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift b/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift index 4d41b2d3..83311584 100644 --- a/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift +++ b/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift @@ -64,6 +64,9 @@ struct BLEFragmentAssemblyBuffer { let type: UInt8 let total: Int let timestamp: Date + let isBroadcast: Bool + var lastFragmentAt: Date + var lastResyncRequestAt: Date? } private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:] @@ -105,7 +108,15 @@ 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 { @@ -138,10 +149,59 @@ struct BLEFragmentAssemblyBuffer { } fragmentsByKey[header.key] = [:] - metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now) + metadataByKey[header.key] = Metadata( + type: header.originalType, + total: header.total, + timestamp: now, + isBroadcast: header.isBroadcastFragment, + lastFragmentAt: 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. diff --git a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift index 18b144d2..7059aae1 100644 --- a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift +++ b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift @@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy { switch MessageType(rawValue: packetType) { case .noiseEncrypted, .noiseHandshake: return true - case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope: + case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .prekeyBundle, .groupMessage, .nostrCarrier, .ping, .pong: return false } } diff --git a/bitchat/Services/BLE/BLEPeerRegistry.swift b/bitchat/Services/BLE/BLEPeerRegistry.swift index 29776748..41ef8098 100644 --- a/bitchat/Services/BLE/BLEPeerRegistry.swift +++ b/bitchat/Services/BLE/BLEPeerRegistry.swift @@ -9,6 +9,7 @@ struct BLEPeerInfo: Equatable { var signingPublicKey: Data? var isVerifiedNickname: Bool var lastSeen: Date + var capabilities: PeerCapabilities = [] } struct BLEPeerAnnounceUpdate: Equatable { @@ -107,6 +108,15 @@ struct BLEPeerRegistry { peers[peerID]?.noisePublicKey?.sha256Fingerprint() } + func capabilities(for peerID: PeerID) -> PeerCapabilities { + peers[peerID.toShort()]?.capabilities ?? [] + } + + /// Peers whose last verified announce advertised the given capability. + func peers(advertising capability: PeerCapabilities) -> [PeerID] { + peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID) + } + func displayNicknames(selfNickname: String) -> [PeerID: String] { let connected = peers.filter { $0.value.isConnected } let tuples = connected.map { ($0.key, $0.value.nickname, true) } @@ -157,7 +167,8 @@ struct BLEPeerRegistry { noisePublicKey: Data, signingPublicKey: Data?, isConnected: Bool, - now: Date + now: Date, + capabilities: PeerCapabilities = [] ) -> BLEPeerAnnounceUpdate { let existing = peers[peerID] let update = BLEPeerAnnounceUpdate( @@ -173,7 +184,8 @@ struct BLEPeerRegistry { noisePublicKey: noisePublicKey, signingPublicKey: signingPublicKey, isVerifiedNickname: true, - lastSeen: now + lastSeen: now, + capabilities: capabilities ) return update diff --git a/bitchat/Services/BLE/BLEReceivePipeline.swift b/bitchat/Services/BLE/BLEReceivePipeline.swift index 71aacbaf..6385ce07 100644 --- a/bitchat/Services/BLE/BLEReceivePipeline.swift +++ b/bitchat/Services/BLE/BLEReceivePipeline.swift @@ -51,13 +51,25 @@ struct BLEReceivePipeline { // Courier envelopes are directed opaque ciphertext like DMs; a // remote handover toward a relayed announce rides this same // deterministic relay treatment instead of the broadcast clamp. + // Directed nostrCarrier uplinks (mesh-only peer -> gateway) need + // the same multi-hop treatment to reach a non-adjacent gateway. + // Ping/pong diagnostics also ride it: 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. isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue - || packet.type == MessageType.courierEnvelope.rawValue) && packet.recipientID != nil, + || packet.type == MessageType.courierEnvelope.rawValue + || packet.type == MessageType.nostrCarrier.rawValue + || packet.type == MessageType.ping.rawValue + || packet.type == MessageType.pong.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), degree: degree, highDegreeThreshold: highDegreeThreshold ) diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 865a89be..da46238c 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -53,6 +53,8 @@ final class BLEService: NSObject { // reject. Injectable for tests; main-actor policy because favorites live // on the main actor. var courierStore: CourierStore = .shared + // Bulletin-board posts this device carries; injectable for tests. + var boardStore: BoardStore = .shared var courierDepositPolicy: @MainActor (Data, Bool) -> CourierDepositTier? = { depositorNoiseKey, isVerifiedPeer in if FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) { return .favorite } return isVerifiedPeer ? .verified : nil @@ -60,6 +62,27 @@ final class BLEService: NSObject { // Local-only store-and-forward counters; nil in unit tests. var sfMetrics: StoreAndForwardMetrics? + // Verified one-time prekey bundles gossiped by other peers, used to seal + // courier mail forward-secretly. Injectable for tests. + var prekeyBundleStore: PrekeyBundleStore = .shared + // Throttle for re-broadcasting our own (unchanged) bundle; guarded by + // collectionsQueue barriers. + private var lastPrekeyBundleSentAt: Date? + // Prekey bundles that arrived before their owner's verified announce bound + // a signing key. The receive queue is concurrent, so a bundle can race + // ahead of the announce it depends on; we retain the latest such bundle per + // owner (bounded) and re-attempt attribution when the announce lands. + // Guarded by collectionsQueue barriers. + private var pendingPrekeyBundles: [PeerID: BitchatPacket] = [:] + private static let pendingPrekeyBundleCap = 64 + // Gateway mode: sink for received nostrCarrier packets (set by app + // wiring, called on the main actor after transport-level checks) and the + // runtime-toggled capability bits ORed into `PeerCapabilities.localSupported` + // for every announce. `directedToUs` distinguishes an uplink deposit + // addressed to this device from a downlink broadcast. + var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)? + private var runtimeCapabilities: PeerCapabilities = [] // collectionsQueue + #if DEBUG // Test-only tap on the outbound pipeline so multi-node tests can ferry // packets between in-process service instances. @@ -67,7 +90,29 @@ final class BLEService: NSObject { #endif private var selfBroadcastTracker = BLESelfBroadcastTracker() private let meshTopology = MeshTopologyTracker() - + + // Mesh diagnostics: outstanding /ping probes keyed by nonce, plus the + // inbound ping budget — keyed by the ingress link (the directly connected + // peer that delivered the packet), since the unsigned claimed sender is + // spoofable — so a directed unencrypted probe cannot be turned into an + // amplification primitive. Both are owned by collectionsQueue barriers + // like the other mutable collections. + private struct PendingMeshPing { + let peerID: PeerID + let sentAt: Date + let completion: @MainActor (MeshPingResult?) -> Void + let timeout: DispatchWorkItem + } + private var pendingMeshPings: [Data: PendingMeshPing] = [:] + private var meshPingResponseLimiter = SyncResponseRateLimiter( + maxResponses: TransportConfig.meshPingInboundMaxPerLink, + window: TransportConfig.meshPingInboundWindowSeconds + ) + + // Route health for originated source routes; guarded by collectionsQueue. + private var sourceRouteFailures = BLESourceRouteFailureCache() + + // 5. Fragment Reassembly (necessary for messages > MTU) private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer() private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler() @@ -145,6 +190,8 @@ final class BLEService: NSObject { private lazy var fragmentHandler = BLEFragmentHandler(environment: makeFragmentHandlerEnvironment()) // File-transfer orchestration (queue hops stay in the environment closures) private lazy var fileTransferHandler = BLEFileTransferHandler(environment: makeFileTransferHandlerEnvironment()) + // Wi-Fi bulk data plane: BLE/Noise negotiates, AWDL carries large media + private lazy var wifiBulkService = WifiBulkTransferService(environment: makeWifiBulkEnvironment()) // MARK: - Gossip Sync private var gossipSyncManager: GossipSyncManager? @@ -269,6 +316,12 @@ final class BLEService: NSObject { // Initialize gossip sync manager restartGossipManager() + + // Force single-threaded instantiation: unlike the packet handlers + // (touched only from messageQueue), the Wi-Fi bulk service is reached + // from the main actor too (cancelTransfer/stopServices), and lazy + // vars are not thread-safe. + _ = wifiBulkService } private func restartGossipManager() { @@ -290,13 +343,24 @@ final class BLEService: NSObject { fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds, messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds, responseRateLimitMaxResponses: TransportConfig.syncResponseRateLimitMaxResponses, - responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds + responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds, + prekeyBundleCapacity: TransportConfig.syncPrekeyBundleCapacity, + prekeyBundleSyncIntervalSeconds: TransportConfig.syncPrekeyBundleIntervalSeconds, + prekeyBundleMaxAgeSeconds: TransportConfig.syncPrekeyBundleMaxAgeSeconds ) // Only real Bluetooth sessions archive to disk; unit tests stay hermetic. let archive = meshBackgroundEnabled ? GossipMessageArchive() : nil let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager, archive: archive) manager.delegate = self + // Board posts sync from the board store (their retention owner) so + // deleted/expired posts drop out of rounds immediately. Real sessions + // only, matching the archive: unit tests stay hermetic. + if meshBackgroundEnabled { + manager.boardPacketsProvider = { [weak self] in + self?.boardStore.syncCandidates() ?? [] + } + } // Only start the periodic sync timers when real Bluetooth exists. In unit // tests there is no mesh to sync with, and the periodic sign/broadcast // churn just keeps the process busy and aggravates flaky exit hangs. @@ -333,6 +397,8 @@ final class BLEService: NSObject { ingressLinks.removeAll() recentTrafficTracker.removeAll() scheduledRelays.cancelAll() + // Let the post-panic identity publish its fresh bundle promptly. + lastPrekeyBundleSentAt = nil return transfers } @@ -504,6 +570,9 @@ final class BLEService: NSObject { } func stopServices() { + // Tear down any Wi-Fi bulk listeners/connections deterministically + wifiBulkService.stop() + // Send leave message synchronously to ensure delivery var leavePacket = BitchatPacket( type: MessageType.leave.rawValue, @@ -576,6 +645,7 @@ final class BLEService: NSObject { let entries = outboundFragmentTransfers.removeAll().map { ($0.id, $0.workItems) } peerRegistry.removeAll() fragmentAssemblyBuffer.removeAll() + sourceRouteFailures = BLESourceRouteFailureCache() // Also clear pending message queues to avoid stale state across sessions pendingNoiseSessionQueues.removeAll() pendingDirectedRelays.removeAll() @@ -619,6 +689,38 @@ final class BLEService: NSObject { } } + /// Capabilities the peer advertised in its last verified announce. + /// Empty for peers that predate the capabilities TLV. + func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { + collectionsQueue.sync { peerRegistry.capabilities(for: peerID) } + } + + /// Enables or disables a runtime-advertised capability bit (e.g. the + /// internet-gateway toggle) and re-announces so peers learn promptly. + /// Build-time bits stay in `PeerCapabilities.localSupported`. + func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool) { + let changed: Bool = collectionsQueue.sync(flags: .barrier) { + let before = runtimeCapabilities + if enabled { + runtimeCapabilities.insert(capability) + } else { + runtimeCapabilities.remove(capability) + } + return runtimeCapabilities != before + } + guard changed else { return } + sendAnnounce(forceSend: true) + } + + /// Reachable peers currently advertising the `.gateway` capability. + func reachableGatewayPeers() -> [PeerID] { + let now = Date() + return collectionsQueue.sync { + peerRegistry.peers(advertising: .gateway) + .filter { peerRegistry.isReachable($0, now: now) } + } + } + func getPeerNicknames() -> [PeerID: String] { return collectionsQueue.sync { peerRegistry.displayNicknames(selfNickname: myNickname) @@ -690,6 +792,9 @@ final class BLEService: NSObject { // MARK: Messaging func cancelTransfer(_ transferId: String) { + // A transfer may be riding the Wi-Fi bulk channel instead of the BLE + // fragment scheduler; cancelling both is safe (each no-ops on miss). + wifiBulkService.cancelTransfer(transferId: transferId) collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } @@ -765,10 +870,72 @@ final class BLEService: NSObject { func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) { messageQueue.async { [weak self] in guard let self = self else { return } - guard let payload = filePacket.encode() else { + // Encode with the Wi-Fi bulk ceiling; whether the payload also + // fits the BLE caps decides what a fallback may do. + guard let payload = filePacket.encode(limit: FileTransferLimits.maxWifiBulkPayloadBytes) else { SecureLogger.error("❌ Failed to encode file packet for private send", category: .session) return } + let fitsBLECaps = filePacket.encode() != nil + + // Normalize to the short routing ID (SHA256-derived 16-hex) once. + // Stable/verified private chats address the peer by its full + // 64-hex Noise key, but the Noise session and the negotiation + // packet are keyed by the short ID — so the capability/session + // eligibility checks (and the Wi-Fi offer) must all use it, or a + // 64-hex key makes `hasEstablishedSession` return false and Wi-Fi + // is never selected even when the peer advertises `.wifiBulk`. + let routingID = peerID.toShort() + + let candidate = self.wifiBulkSendCandidate(payloadBytes: payload.count, to: routingID) + if WifiBulkPolicy.shouldOffer(candidate) { + self.wifiBulkService.sendFile(payload: payload, to: routingID, transferId: transferId) { [weak self] in + self?.sendFilePrivateViaBLE(payload: payload, to: routingID, transferId: transferId, fitsBLECaps: fitsBLECaps) + } + return + } + guard fitsBLECaps else { + // Wi-Fi-only payload with no eligible Wi-Fi path. + SecureLogger.error("❌ File exceeds BLE caps and Wi-Fi bulk is unavailable", category: .session) + self.surfaceUndeliverableTransfer(transferId) + return + } + self.sendFilePrivateViaBLE(payload: payload, to: routingID, transferId: transferId, fitsBLECaps: true) + } + } + + /// Builds the Wi-Fi bulk eligibility candidate for a private send. + /// + /// Normalizes `peerID` to its short routing ID first: stable/verified + /// private chats address the peer by its full 64-hex Noise key, but the + /// Noise session, advertised capabilities, and connection state are all + /// keyed by the short (SHA256-derived 16-hex) ID. Resolving them with the + /// 64-hex key would miss the session (`hasEstablishedSession` returns + /// false), so Wi-Fi would never be offered even to a directly-connected + /// `.wifiBulk` peer. + private func wifiBulkSendCandidate(payloadBytes: Int, to peerID: PeerID) -> WifiBulkPolicy.SendCandidate { + let routingID = peerID.toShort() + return WifiBulkPolicy.SendCandidate( + payloadBytes: payloadBytes, + peerCapabilities: peerCapabilities(routingID), + isDirectlyConnected: isPeerConnected(routingID), + hasEstablishedNoiseSession: noiseService.hasEstablishedSession(with: routingID) + ) + } + + /// BLE fragmentation path for private file transfers; also the fallback + /// target when a Wi-Fi bulk attempt declines, times out, or errors. + /// May be invoked from the Wi-Fi bulk queue. + private func sendFilePrivateViaBLE(payload: Data, to peerID: PeerID, transferId: String, fitsBLECaps: Bool) { + messageQueue.async { [weak self] in + guard let self = self else { return } + guard fitsBLECaps else { + // A >1 MiB payload was negotiated for Wi-Fi and the channel + // failed: BLE cannot carry it, surface the normal failure path. + SecureLogger.error("❌ Wi-Fi bulk failed and payload exceeds BLE caps (\(payload.count) bytes)", category: .session) + self.surfaceUndeliverableTransfer(transferId) + return + } // Normalize to short form (SHA256-derived 16-hex) for wire protocol compatibility // This ensures 64-hex Noise keys are converted to the canonical routing format let targetID = peerID.toShort() @@ -797,6 +964,13 @@ final class BLEService: NSObject { } } + /// Drives the progress bus through started→cancelled so the UI clears a + /// transfer that no transport can carry. + private func surfaceUndeliverableTransfer(_ transferId: String) { + TransferProgressManager.shared.start(id: transferId, totalFragments: 1) + TransferProgressManager.shared.cancel(id: transferId) + } + func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { let payload = BLENoisePayloadFactory.readReceipt(originalMessageID: receipt.originalMessageID) @@ -1182,6 +1356,57 @@ final class BLEService: NSObject { ) } + /// Builds the Wi-Fi bulk service environment. Noise encryption and packet + /// dispatch stay on the message queue; incoming payloads re-enter the + /// normal file-transfer pipeline as if a fully assembled packet arrived. + private func makeWifiBulkEnvironment() -> WifiBulkTransferServiceEnvironment { + WifiBulkTransferServiceEnvironment( + sendNoisePayload: { [weak self] typedPayload, peerID in + guard let self = self, self.noiseService.hasEstablishedSession(with: peerID) else { return false } + self.messageQueue.async { [weak self] in + guard let self = self else { return } + do { + self.broadcastPacket(try self.makeEncryptedNoisePacket(typedPayload, to: peerID)) + } catch { + SecureLogger.error("❌ Failed to send Wi-Fi bulk negotiation payload: \(error)", category: .session) + } + } + return true + }, + isPeerConnected: { [weak self] peerID in + self?.isPeerConnected(peerID) ?? false + }, + deliverReceivedFile: { [weak self] payload, peerID, payloadLimit in + self?.messageQueue.async { [weak self] in + guard let self = self else { return } + let packet = BitchatPacket( + type: MessageType.fileTransfer.rawValue, + senderID: Data(hexString: peerID.toShort().id) ?? Data(), + recipientID: self.myPeerIDData, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: 0, + version: 2 + ) + self.fileTransferHandler.handle(packet, from: peerID, payloadLimit: payloadLimit) + } + }, + progressStart: { transferId, totalChunks in + TransferProgressManager.shared.start(id: transferId, totalFragments: totalChunks) + }, + progressChunkSent: { transferId in + TransferProgressManager.shared.recordFragmentSent(id: transferId) + }, + progressReset: { transferId in + TransferProgressManager.shared.reset(id: transferId) + }, + progressCancel: { transferId in + TransferProgressManager.shared.cancel(id: transferId) + } + ) + } + func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { SecureLogger.debug("🔔 sendFavoriteNotification peer=\(peerID.id.prefix(8))… isFavorite=\(isFavorite)", category: .session) @@ -1253,15 +1478,16 @@ final class BLEService: NSObject { let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification let signingPub = noiseService.getSigningPublicKeyData() // For signature verification - let connectedPeerIDs: [Data] = collectionsQueue.sync { - peerRegistry.connectedRoutingData + let (connectedPeerIDs, advertisedCapabilities): ([Data], PeerCapabilities) = collectionsQueue.sync { + (peerRegistry.connectedRoutingData, PeerCapabilities.localSupported.union(runtimeCapabilities)) } - + let announcement = AnnouncementPacket( nickname: myNickname, noisePublicKey: noisePub, signingPublicKey: signingPub, - directNeighbors: connectedPeerIDs + directNeighbors: connectedPeerIDs, + capabilities: advertisedCapabilities ) guard let payload = announcement.encode() else { @@ -1296,10 +1522,56 @@ final class BLEService: NSObject { } // Ensure our own announce is included in sync state gossipSyncManager?.onPublicPacketSeen(signedPacket) + + // Keep our prekey bundle riding alongside presence (throttled; the + // send is a no-op when the bundle was refreshed recently). + sendPrekeyBundle() } // MARK: QR Verification over Noise + // MARK: Private Groups + + /// Sends creator-signed group state (invite) 1:1 over the Noise session, + /// queueing behind a handshake when none is established yet. + func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) { + sendNoisePayload(NoisePayload(type: .groupInvite, data: statePayload).encode(), to: peerID) + } + + /// Sends creator-signed group state (key rotation / roster update) 1:1 + /// over the Noise session. + func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) { + sendNoisePayload(NoisePayload(type: .groupKeyUpdate, data: statePayload).encode(), to: peerID) + } + + /// Broadcasts a sealed group message (MessageType 0x25) like a public + /// message: fire-and-flood with gossip-sync backfill. The outer packet is + /// intentionally unsigned — receivers authenticate the sender's Ed25519 + /// signature inside the ciphertext, which still verifies for backfilled + /// copies long after the sender's announce has expired. + func broadcastGroupMessage(_ envelope: Data) { + guard !envelope.isEmpty else { return } + messageQueue.async { [weak self] in + guard let self else { return } + let packet = BitchatPacket( + type: MessageType.groupMessage.rawValue, + senderID: Data(hexString: self.myPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: envelope, + signature: nil, + ttl: self.messageTTL + ) + // Pre-mark our own broadcast as processed to avoid handling a + // relayed self copy. + let dedupID = BLESelfBroadcastTracker.dedupID(for: packet) + self.messageDeduplicator.markProcessed(dedupID) + self.broadcastPacket(packet) + // Track our own broadcast for gossip sync + self.gossipSyncManager?.onPublicPacketSeen(packet) + } + } + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA) sendNoisePayload(payload, to: peerID) @@ -1309,6 +1581,18 @@ final class BLEService: NSObject { guard let payload = VerificationService.shared.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonceA) else { return } sendNoisePayload(payload, to: peerID) } + + // MARK: Vouching over Noise + + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) { + sendNoisePayload(NoisePayload(type: .vouch, data: payload).encode(), to: peerID) + } + + func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) { + // Appends to the encryption service's handler array, so this never + // displaces the callbacks installed by installNoiseSessionCallbacks. + noiseService.addOnPeerAuthenticatedHandler(handler) + } } // MARK: - GossipSyncManager Delegate @@ -1688,6 +1972,10 @@ extension BLEService { handleReceivedPacket(packet, from: fromPeerID) } + func _test_hasGossipPrekeyBundle(for peerID: PeerID) -> Bool { + gossipSyncManager?._hasPrekeyBundle(for: peerID) ?? false + } + func _test_acceptsIngress(packet: BitchatPacket, boundPeerID: PeerID?) -> Bool { let claimedSenderID = PeerID(hexData: packet.senderID) guard case .success = BLEIngressLinkRegistry.packetContext( @@ -1715,6 +2003,34 @@ extension BLEService { cachedServiceUUIDs: cachedServiceUUIDs ) } + + /// The internal Noise service, so tests can drive a real handshake and + /// establish a session keyed by a chosen (short) routing ID. + var _test_noiseService: NoiseEncryptionService { noiseService } + + /// Registers a directly-connected peer with the given advertised + /// capabilities, keyed by its short routing ID (as production does). + func _test_registerConnectedPeer(_ peerID: PeerID, capabilities: PeerCapabilities) { + let shortID = peerID.toShort() + collectionsQueue.sync(flags: .barrier) { + peerRegistry.upsert(BLEPeerInfo( + peerID: shortID, + nickname: "TestPeer_\(shortID.id.prefix(4))", + isConnected: true, + noisePublicKey: peerID.noiseKey, + signingPublicKey: nil, + isVerifiedNickname: true, + lastSeen: Date(), + capabilities: capabilities + )) + } + } + + /// Runs the production Wi-Fi bulk eligibility resolution (including peer-ID + /// normalization) for the given payload size and recipient. + func _test_wifiBulkSendCandidate(payloadBytes: Int, to peerID: PeerID) -> WifiBulkPolicy.SendCandidate { + wifiBulkSendCandidate(payloadBytes: payloadBytes, to: peerID) + } } #endif @@ -2375,12 +2691,42 @@ extension BLEService { } private func computeRoute(to peerID: PeerID) -> [Data]? { - meshTopology.computeRoute(from: myPeerIDData, to: routingData(for: peerID)) + // Version-gated: every hop and the recipient must have been observed + // speaking v2, since a v1-only node drops v2 frames on decode. + meshTopology.computeRoute( + from: myPeerIDData, + to: routingData(for: peerID), + maxHops: TransportConfig.bleSourceRouteMaxIntermediateHops, + requiringVersion: 2 + ) } private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket { - guard let route = computeRoute(to: recipient), route.count >= 1 else { + let now = Date() + let decision = BLESourceRouteOriginationPolicy.decide( + for: packet, + to: recipient, + localPeerIDData: myPeerIDData, + isRecipientConnected: { self.isPeerConnected($0) }, + shouldAttemptRoute: { peer in + self.collectionsQueue.sync(flags: .barrier) { + self.sourceRouteFailures.shouldAttemptRoute(to: peer, now: now) + } + }, + computeRoute: { self.computeRoute(to: $0) } + ) + let route: [Data] + switch decision { + case .flood(let reason): + // Only log the interesting directed cases; a plain broadcast keeps + // flood by design and would spam the origination path. + if reason != .broadcast { + SecureLogger.info("[ROUTE] flood to \(recipient.id.prefix(8))… (\(reason.rawValue))", category: .mesh) + } return packet + case .route(let hops): + SecureLogger.info("[ROUTE] source-route to \(recipient.id.prefix(8))… via \(hops.count) hop(s)", category: .mesh) + route = hops } // Create new packet with route applied and version upgraded to 2 let routedPacket = BitchatPacket( @@ -2399,6 +2745,9 @@ extension BLEService { SecureLogger.error("❌ Failed to re-sign packet with route", category: .security) return packet // Return original packet if signing fails } + collectionsQueue.sync(flags: .barrier) { + sourceRouteFailures.noteRoutedSend(to: recipient, now: now) + } return signedPacket } @@ -2406,6 +2755,155 @@ extension BLEService { PeerID(routingData: data) } + // MARK: - Mesh Diagnostics (/ping, /trace, topology map) + + /// Sends a directed unencrypted ping probe (8-byte nonce + origin TTL). + /// The completion fires exactly once on the main actor: with RTT/hops + /// when the matching pong returns, or nil after the timeout window. + func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) { + messageQueue.async { [weak self] in + guard let self, + let recipientData = peerID.toShort().routingData, + let payload = MeshPingPayload( + nonce: Data((0.. PendingMeshPing? in + guard pendingMeshPings[pong.nonce]?.peerID == peerID else { return nil } + return pendingMeshPings.removeValue(forKey: pong.nonce) + } + guard let pending else { return } + pending.timeout.cancel() + let rttMs = Int((Date().timeIntervalSince(pending.sentAt) * 1000).rounded()) + let result = MeshPingResult( + rttMs: max(0, rttMs), + hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl) + ) + SecureLogger.info("[PING] pong from \(peerID.id.prefix(8))… rtt=\(result.rttMs)ms hops=\(result.hops)", category: .mesh) + Task { @MainActor in pending.completion(result) } + } + + /// Estimated intermediate hops toward `peerID`, BFS over gossiped + /// bidirectionally-confirmed neighbor claims ([] = direct, nil = none). + func computeMeshPath(to peerID: PeerID) -> [PeerID]? { + refreshLocalTopology() + if let route = computeRoute(to: peerID) { + let path = route.compactMap { PeerID(routingData: $0) } + SecureLogger.info("[TRACE] path to \(peerID.id.prefix(8))… via \(path.count) hop(s)", category: .mesh) + return path + } + // Confirmed claims can lag a brand-new link (the peer's next announce + // hasn't arrived yet); a live direct connection is still a known path. + let direct = isPeerConnected(peerID) + SecureLogger.info("[TRACE] path to \(peerID.id.prefix(8))… \(direct ? "direct (0 hops)" : "no known path")", category: .mesh) + return direct ? [] : nil + } + + /// Mesh graph for the topology map. Edges are advisory: announces cap + /// neighbor lists at 10, so an edge claimed by either endpoint counts. + func currentMeshTopology() -> MeshTopologySnapshot? { + refreshLocalTopology() + let claims = meshTopology.adjacencySnapshot() + var nodes = Set() + var edges = Set() + for (source, neighbors) in claims { + guard let sourcePeer = PeerID(routingData: source) else { continue } + nodes.insert(sourcePeer) + for neighborData in neighbors { + guard let neighborPeer = PeerID(routingData: neighborData), + neighborPeer != sourcePeer else { continue } + nodes.insert(neighborPeer) + edges.insert(MeshTopologyEdge(sourcePeer, neighborPeer)) + } + } + nodes.insert(myPeerID) + return MeshTopologySnapshot( + localPeerID: myPeerID, + nodes: nodes.sorted(), + edges: edges.sorted { ($0.a, $0.b) < ($1.a, $1.b) } + ) + } + private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool { let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData) let plan = BLERouteForwardingPolicy.plan( @@ -2502,10 +3000,13 @@ extension BLEService { // MARK: Courier Store-and-Forward - /// Seal `content` to the recipient's static key (one-way Noise X) and hand - /// the envelope to the given couriers for physical delivery. Returns false - /// when no courier is connected, the payload cannot be built, or sealing - /// fails; link writes are queued asynchronously after the envelope is ready. + /// Seal `content` for the recipient and hand the envelope to the given + /// couriers for physical delivery. When a verified one-time prekey bundle + /// is cached for the recipient, sealing targets one of its prekeys + /// (forward secret, envelope v2); otherwise it falls back to their static + /// key (one-way Noise X, v1) exactly as before. Returns false when no + /// courier is connected, the payload cannot be built, or sealing fails; + /// link writes are queued asynchronously after the envelope is ready. func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { let connected = couriers.filter { isPeerConnected($0) } guard !connected.isEmpty, @@ -2516,7 +3017,17 @@ extension BLEService { let payload: Data do { let now = Date() - let sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey) + let sealed: Data + let prekeyID: UInt32? + if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) { + sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey) + prekeyID = prekey.id + SecureLogger.info("[PREKEY] seal prekey (id=\(prekey.id)) id=\(messageID.prefix(8))…", category: .session) + } else { + sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey) + prekeyID = nil + SecureLogger.info("[PREKEY] seal static (no bundle) id=\(messageID.prefix(8))…", category: .session) + } let envelope = CourierEnvelope( recipientTag: CourierEnvelope.recipientTag( noiseStaticKey: recipientNoiseKey, @@ -2524,7 +3035,8 @@ extension BLEService { ), expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000), ciphertext: sealed, - copies: TransportConfig.courierInitialCopies + copies: TransportConfig.courierInitialCopies, + prekeyID: prekeyID ) guard let encoded = envelope.encode() else { return false } payload = encoded @@ -2543,6 +3055,22 @@ extension BLEService { return true } + /// The prekey to seal a courier message with, or nil to fall back to + /// static sealing. The real signal is a verified, unexpired bundle with a + /// spare prekey; the advertised `.prekeys` capability only acts as a veto + /// for peers we currently see on the mesh (a cached bundle can outlive a + /// peer's downgrade to a build that no longer holds the privates). + /// Re-deposits of the same message reuse its assigned prekey, so one + /// message consumes exactly one prekey ID regardless of courier count. + private func assignRecipientPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? { + let shortID = PeerID(publicKey: recipientNoiseKey) + let knownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil } + if knownOnMesh, !peerCapabilities(shortID).contains(.prekeys) { + return nil + } + return prekeyBundleStore.assignPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) + } + private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket { let packet = BitchatPacket( type: MessageType.courierEnvelope.rawValue, @@ -2578,7 +3106,26 @@ extension BLEService { private func openCourierEnvelope(_ envelope: CourierEnvelope) { do { - let (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext) + let typedPayload: Data + let senderStaticKey: Data + if let prekeyID = envelope.prekeyID { + // Envelope v2: sealed to one of our one-time prekeys. Opening + // consumes the prekey (48h redelivery grace), which shrinks our + // published bundle under a strictly newer generatedAt. Re-gossip + // so peers replace their cached copy and stop assigning the + // consumed ID before the grace lapses; force the broadcast when + // the batch also topped back up (low-water), otherwise let the + // rebroadcast throttle coalesce bursts. + let opened = try noiseService.openPrekeyPayload(envelope.ciphertext, prekeyID: prekeyID) + (typedPayload, senderStaticKey) = (opened.payload, opened.senderStaticKey) + if opened.consumedPrekey { + let replenished = noiseService.replenishPrekeysIfNeeded() + SecureLogger.info("[PREKEY] consume id=\(prekeyID), re-gossip bundle (replenished=\(replenished))", category: .session) + sendPrekeyBundle(force: replenished) + } + } else { + (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext) + } guard let typeRaw = typedPayload.first, let payloadType = NoisePayloadType(rawValue: typeRaw), payloadType == .privateMessage else { @@ -2709,6 +3256,232 @@ extension BLEService { } } + // MARK: One-Time Prekey Bundles + + /// Broadcasts our signed prekey bundle and tracks it for gossip sync. + /// Unforced sends (piggybacked on announces) are throttled — gossip does + /// the spreading, the broadcast just keeps our own gossip entry fresh. + /// Forced sends (bundle changed after consumption) go immediately. + private func sendPrekeyBundle(force: Bool = false) { + let now = Date() + let shouldSend: Bool = collectionsQueue.sync(flags: .barrier) { + if !force, + let last = lastPrekeyBundleSentAt, + now.timeIntervalSince(last) < TransportConfig.prekeyBundleRebroadcastSeconds { + return false + } + lastPrekeyBundleSentAt = now + return true + } + guard shouldSend else { return } + guard let bundle = noiseService.currentPrekeyBundle(), + let payload = bundle.encode() else { + SecureLogger.error("❌ Failed to build prekey bundle", category: .security) + return + } + let packet = BitchatPacket( + type: MessageType.prekeyBundle.rawValue, + senderID: myPeerIDData, + recipientID: nil, + timestamp: UInt64(now.timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: messageTTL + ) + guard let signedPacket = noiseService.signPacket(packet) else { + SecureLogger.error("❌ Failed to sign prekey bundle packet", category: .security) + return + } + if DispatchQueue.getSpecific(key: messageQueueKey) != nil { + broadcastPacket(signedPacket) + } else { + messageQueue.async { [weak self] in + self?.broadcastPacket(signedPacket) + } + } + gossipSyncManager?.onPublicPacketSeen(signedPacket) + } + + /// Ingests a gossiped prekey bundle. Attribution is layered: the outer + /// packet must originate from the bundle owner (fabricated sender IDs, used + /// to multiply cache/gossip entries, are rejected), and BOTH the inner + /// bundle signature and the outer packet signature must verify against the + /// owner's announce-bound signing key. Verifying the outer packet — whose + /// signed bytes cover senderID and timestamp — stops a valid bundle from + /// being replayed under a fresh timestamp or spoofed sender to pass + /// freshness or poison attribution. Only after that does the packet enter + /// our own gossip store, so we never help spread a bundle we couldn't + /// attribute. + private func handlePrekeyBundle(_ packet: BitchatPacket, from peerID: PeerID) { + guard let bundle = PrekeyBundle.decode(packet.payload) else { + SecureLogger.debug("🔑 Ignoring malformed prekey bundle from \(peerID.id.prefix(8))…", category: .security) + return + } + // Our own bundle is tracked at send time; a copy echoing back adds nothing. + guard bundle.noiseStaticPublicKey != noiseService.getStaticPublicKeyData() else { return } + let owner = PeerID(publicKey: bundle.noiseStaticPublicKey) + // The owner's genuine bundle (direct or relayed) always carries the + // owner's senderID + outer signature; gossip resends preserve both. A + // packet whose senderID isn't the owner can't be authenticated here. + guard PeerID(hexData: packet.senderID) == owner else { + SecureLogger.debug("🔑 Ignoring prekey bundle whose sender ≠ owner \(owner.id.prefix(8))…", category: .security) + return + } + // Look up the announce-bound signing key and stash-if-unbound in ONE + // barrier: the receive queue is concurrent, so this bundle can race + // ahead of the announce that binds the key. Reading the live registry + // and stashing atomically closes the check-then-act gap against + // handleAnnounce's drain (see drainPendingPrekeyBundles). + let signingKey: Data? = collectionsQueue.sync(flags: .barrier) { + if let info = peerRegistry.info(for: owner), + info.noisePublicKey == bundle.noiseStaticPublicKey, + let key = info.signingPublicKey { + return key + } + // Offline-verified identities are stable across this race. + for candidate in identityManager.getCryptoIdentitiesByPeerIDPrefix(owner) + where candidate.publicKey == bundle.noiseStaticPublicKey { + if let key = candidate.signingPublicKey { return key } + } + // No binding yet: retain the latest bundle per owner, bounded, and + // retry once the verified announce lands. + if pendingPrekeyBundles[owner] != nil + || pendingPrekeyBundles.count < Self.pendingPrekeyBundleCap { + pendingPrekeyBundles[owner] = packet + } + return nil + } + guard let signingKey else { + SecureLogger.debug("[PREKEY] bundle defer (no bound signing key) owner \(owner.id.prefix(8))…", category: .session) + return + } + ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey) + } + + /// Verify a bundle's inner + outer signatures against the owner's bound + /// signing key and, on success, cache it and let it enter our gossip store. + private func ingestVerifiedPrekeyBundle(_ bundle: PrekeyBundle, packet: BitchatPacket, owner: PeerID, signingKey: Data) { + guard noiseService.verifyPrekeyBundleSignature(bundle, signingPublicKey: signingKey), + noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { + SecureLogger.info("[PREKEY] bundle reject (bad signature) owner \(owner.id.prefix(8))…", category: .session) + return + } + if prekeyBundleStore.ingest(bundle) { + SecureLogger.info("[PREKEY] bundle accept owner \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .session) + } else { + SecureLogger.debug("[PREKEY] bundle reject (not newer/invalid) owner \(owner.id.prefix(8))…", category: .session) + } + gossipSyncManager?.onPublicPacketSeen(packet) + } + + /// Re-attempt any prekey bundle that arrived before this owner's announce + /// bound a signing key. Called from handleAnnounce after a verified + /// announce, in a barrier ordered after the registry write, so a bundle + /// stashed before the write is always observed here. + private func drainPendingPrekeyBundles(for owner: PeerID) { + let pending: BitchatPacket? = collectionsQueue.sync(flags: .barrier) { + pendingPrekeyBundles.removeValue(forKey: owner) + } + guard let packet = pending, + let bundle = PrekeyBundle.decode(packet.payload), + let signingKey = announceBoundSigningKey(forNoiseKey: bundle.noiseStaticPublicKey) else { return } + ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey) + } + + /// Ed25519 signing key bound to a Noise static key by a verified + /// announce: from the live registry when the owner is on the mesh, else + /// from identities persisted for offline verification. + private func announceBoundSigningKey(forNoiseKey noiseKey: Data) -> Data? { + let shortID = PeerID(publicKey: noiseKey) + if let info = collectionsQueue.sync(execute: { peerRegistry.info(for: shortID) }), + info.noisePublicKey == noiseKey, + let signingKey = info.signingPublicKey { + return signingKey + } + for candidate in identityManager.getCryptoIdentitiesByPeerIDPrefix(shortID) + where candidate.publicKey == noiseKey { + if let signingKey = candidate.signingPublicKey { + return signingKey + } + } + return nil + } + + // MARK: Gateway carrier (nostrCarrier) + + /// Sign and send an encoded `toGateway` carrier payload directed at a + /// gateway peer. The packet is signed so the gateway can key its uplink + /// quotas to an authenticated depositor; the carried Nostr event has its + /// own Schnorr signature for content authenticity. Returns false when + /// the gateway is not reachable or signing fails. + func sendNostrCarrier(_ payload: Data, to gatewayPeer: PeerID) -> Bool { + guard isPeerReachable(gatewayPeer) else { return false } + let packet = BitchatPacket( + type: MessageType.nostrCarrier.rawValue, + senderID: myPeerIDData, + recipientID: Data(hexString: gatewayPeer.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: messageTTL + ) + guard let signed = noiseService.signPacket(packet) else { return false } + messageQueue.async { [weak self] in + // broadcastPacket applies a known route when one exists and + // otherwise floods the directed packet like a DM, so a gateway + // that is reachable but multi-hop still gets the deposit. + self?.broadcastPacket(signed) + } + return true + } + + /// Broadcast an encoded `fromGateway` carrier payload on the mesh with + /// the default TTL. Unsigned at the packet layer — receivers verify the + /// carried event's own Schnorr signature. + func broadcastNostrCarrier(_ payload: Data) { + let packet = BitchatPacket( + type: MessageType.nostrCarrier.rawValue, + senderID: myPeerIDData, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: messageTTL + ) + messageQueue.async { [weak self] in + self?.broadcastPacket(packet) + } + } + + /// Transport-level handling for a received nostrCarrier packet; policy + /// (verification of the carried event, quotas, loop prevention) lives in + /// `GatewayService` behind `onNostrCarrierPacket`. + private func handleNostrCarrier(_ packet: BitchatPacket, from peerID: PeerID) { + let senderID = PeerID(hexData: packet.senderID) + let directedToUs: Bool + if let recipientID = packet.recipientID { + // Carriers addressed elsewhere ride the generic relay path untouched. + guard recipientID == myPeerIDData else { return } + // Uplink deposit: quotas are keyed by the depositor, so the + // packet signature must verify against the sender's announced + // signing key. Unlike courier deposits the depositor may be + // multi-hop away, so ingress-link identity is not required. + let signingKey = collectionsQueue.sync { peerRegistry.info(for: senderID)?.signingPublicKey } + guard let signingKey, + noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { + SecureLogger.debug("🌐 nostrCarrier uplink from \(senderID.id.prefix(8))… rejected (missing/invalid packet signature)", category: .security) + return + } + directedToUs = true + } else { + directedToUs = false + } + let payload = packet.payload + notifyUI { [weak self] in + self?.onNostrCarrierPacket?(payload, senderID, directedToUs) + } + } + // MARK: Link capability snapshots (thread-safe via bleQueue) private func readLinkState(_ body: (BLELinkStateStore) -> T) -> T { @@ -2946,7 +3719,7 @@ extension BLEService { // Notify delegate of failure notifyUI { [weak self] in - self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer")))) + self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: String(localized: "content.delivery.reason.encryption_failed", defaultValue: "encryption failed", comment: "Failure reason shown when a message could not be encrypted for the peer")))) } } } @@ -3170,13 +3943,23 @@ extension BLEService { // Update peer info without verbose logging - update the peer we received from, not the original sender updatePeerLastSeen(peerID) - // Track recent traffic timestamps for adaptive behavior + // Track recent traffic timestamps for adaptive behavior; the same + // barrier hop confirms route health for the packet's originator. collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } self.recentTrafficTracker.recordPacket(at: Date()) + self.sourceRouteFailures.noteInboundActivity(from: senderID) + } + + // Per-peer protocol version: originated source routes only use hops + // observed speaking v2 (a v1-only node cannot decode v2 frames). + if packet.version >= 2 { + meshTopology.recordObservedVersion(packet.version, for: packet.senderID) + if peerID != senderID { + meshTopology.recordObservedVersion(packet.version, for: routingData(for: peerID)) + } } - // Process by type switch context.messageType { case .announce: @@ -3203,6 +3986,28 @@ extension BLEService { case .courierEnvelope: handleCourierEnvelope(packet, from: peerID) + case .prekeyBundle: + handlePrekeyBundle(packet, from: senderID) + + case .groupMessage: + handleGroupMessage(packet, from: senderID) + + case .nostrCarrier: + handleNostrCarrier(packet, from: peerID) + + case .ping: + // Rate limiting must key on the ingress link (`peerID`), not the + // packet-claimed sender: pings are unsigned, so `senderID` is + // attacker-controlled and rotating it would reset the budget. + handleMeshPing(packet, fromLink: peerID) + + case .pong: + handleMeshPong(packet, from: senderID) + + case .boardPost: + // Invalid or deleted posts must not spread; skip the relay step. + guard handleBoardPost(packet, from: senderID) else { return } + case .leave: handleLeave(packet, from: senderID) @@ -3266,6 +4071,12 @@ extension BLEService { private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { let result = announceHandler.handle(packet, from: peerID) + // A verified announce is the moment a signing key becomes bound to this + // owner's noise key: retry any prekey bundle that raced ahead of it. + if let result, result.isVerified { + drainPendingPrekeyBundles(for: result.peerID) + } + // Courier work: an announce is the moment we learn a peer's Noise // static key, so check whether we're carrying mail addressed to them // (or spray-able mail they could carry). Verified announces only. @@ -3315,7 +4126,8 @@ extension BLEService { noisePublicKey: announcement.noisePublicKey, signingPublicKey: announcement.signingPublicKey, isConnected: isConnected, - now: now + now: now, + capabilities: announcement.capabilities ?? [] ) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) }, shouldEmitReconnectLog: { [weak self] peerID, now in @@ -3374,6 +4186,63 @@ extension BLEService { ) } + // MARK: - Board (geohash bulletin board) + + /// Validates and stores an incoming board post or tombstone. Returns + /// whether the packet is worth relaying onward. + private func handleBoardPost(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { + guard let wire = BoardWire.decode(from: packet.payload) else { + SecureLogger.warning("⚠️ Malformed board packet from \(peerID.id.prefix(8))…", category: .session) + return false + } + // Posts are self-authenticating: the payload embeds the author's + // Ed25519 key and signature, so verification does not depend on the + // author still being around to announce. + guard wire.verifySignature() else { + if logRateLimiter.shouldLog(key: "board-sig:\(peerID.id)") { + SecureLogger.warning("🚫 Dropping board packet with invalid signature from \(peerID.id.prefix(8))…", category: .security) + } + return false + } + switch boardStore.ingest(wire, packet: packet) { + case .accepted, .duplicate: + return true + case .rejected: + return false + } + } + + /// Broadcasts a pre-signed board payload (post or tombstone) built by the + /// board manager, and ingests it locally so it shows up on our own board + /// and joins gossip sync immediately. + func sendBoardPayload(_ payload: Data) { + guard let wire = BoardWire.decode(from: payload), wire.verifySignature() else { + SecureLogger.error("❌ Refusing to send invalid board payload", category: .session) + return + } + messageQueue.async { [weak self] in + guard let self = self else { return } + let basePacket = BitchatPacket( + type: MessageType.boardPost.rawValue, + senderID: Data(hexString: self.myPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: self.messageTTL + ) + guard let signedPacket = self.noiseService.signPacket(basePacket) else { + SecureLogger.error("❌ Failed to sign board packet", category: .security) + return + } + // Pre-mark our own broadcast as processed to avoid handling a relayed self copy + let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket) + self.messageDeduplicator.markProcessed(dedupID) + self.boardStore.ingest(wire, packet: signedPacket) + self.broadcastPacket(signedPacket) + } + } + // Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager private func handleRequestSync(_ packet: BitchatPacket, from peerID: PeerID) { // REQUEST_SYNC is link-local by design (always sent with ttl 0): a @@ -3458,6 +4327,26 @@ extension BLEService { ) } + /// Group broadcasts are opaque ciphertext to this layer: track them for + /// gossip backfill and hand the payload to the UI layer, where the group + /// coordinator decrypts and authenticates against the roster. Non-members + /// still relay (generic broadcast relay path) but never decode. + private func handleGroupMessage(_ packet: BitchatPacket, from peerID: PeerID) { + let isBroadcastRecipient: Bool = { + guard let recipient = packet.recipientID else { return true } + return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF } + }() + guard isBroadcastRecipient, !packet.payload.isEmpty else { return } + + gossipSyncManager?.onPublicPacketSeen(packet) + + let payload = packet.payload + let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000) + notifyUI { [weak self] in + self?.deliverTransportEvent(.groupMessageReceived(payload: payload, timestamp: timestamp)) + } + } + private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) { noisePacketHandler.handleHandshake(packet, from: peerID) } @@ -3502,8 +4391,21 @@ extension BLEService { self?.noiseService.clearSession(for: peerID) }, deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in + guard let self = self else { return } + // Wi-Fi bulk negotiation is a transport concern; consume it + // here instead of surfacing it to the UI layer. + switch type { + case .bulkTransferOffer: + self.wifiBulkService.handleOfferPayload(payload, from: peerID) + return + case .bulkTransferResponse: + self.wifiBulkService.handleResponsePayload(payload, from: peerID) + return + default: + break + } // Single main-actor hop delivering `.noisePayloadReceived`. - self?.notifyUI { [weak self] in + self.notifyUI { [weak self] in self?.deliverTransportEvent(.noisePayloadReceived( peerID: peerID, type: type, @@ -3681,10 +4583,21 @@ extension BLEService { // Clean old processed messages efficiently messageDeduplicator.cleanup() - // Clean old fragments (> configured seconds old) - collectionsQueue.sync(flags: .barrier) { + // Clean old fragments (> configured seconds old), then ask peers for + // the specific fragment streams whose reassembly has stalled instead + // of waiting for the next periodic GCS fragment round. + let stalledFragmentIDs = collectionsQueue.sync(flags: .barrier) { () -> [Data] in let cutoff = now.addingTimeInterval(-TransportConfig.bleFragmentLifetimeSeconds) fragmentAssemblyBuffer.removeExpired(before: cutoff) + sourceRouteFailures.prune(now: now) + return fragmentAssemblyBuffer.stalledBroadcastFragmentIDs( + stalledAfter: TransportConfig.bleFragmentResyncStallSeconds, + retryAfter: TransportConfig.bleFragmentResyncRetrySeconds, + now: now + ) + } + if !stalledFragmentIDs.isEmpty { + gossipSyncManager?.requestMissingFragments(fragmentIDs: stalledFragmentIDs) } // Clean old connection timeout backoff entries (> window) diff --git a/bitchat/Services/BLE/BLESourceRouteFailureCache.swift b/bitchat/Services/BLE/BLESourceRouteFailureCache.swift new file mode 100644 index 00000000..fcf23d2c --- /dev/null +++ b/bitchat/Services/BLE/BLESourceRouteFailureCache.swift @@ -0,0 +1,95 @@ +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 + } + } +} diff --git a/bitchat/Services/BLE/BLESourceRouteOriginationPolicy.swift b/bitchat/Services/BLE/BLESourceRouteOriginationPolicy.swift new file mode 100644 index 00000000..48d3d173 --- /dev/null +++ b/bitchat/Services/BLE/BLESourceRouteOriginationPolicy.swift @@ -0,0 +1,59 @@ +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 { + /// Why a packet kept flood/direct-write behavior instead of routing. + /// The `rawValue` doubles as the greppable `[ROUTE]` log reason. + enum FloodReason: String { + case relayedNotOriginator = "not originator" + case broadcast = "broadcast recipient" + case noTTLHeadroom = "link-local ttl" + case recipientDirect = "recipient direct" + case routeSuppressed = "route suppressed→flood" + case noPath = "no v2 path" + } + + /// The routing decision for an originated packet. + enum Decision: Equatable { + /// Keep flood/direct-write behavior unchanged; carries the reason. + case flood(FloodReason) + /// Originate a v2 source route over these intermediate hops. + case route([Data]) + } + + /// Returns whether to originate a v2 source route (with its hops) or keep + /// flood/direct-write, with the reason for the latter. + /// + /// 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 decide( + for packet: BitchatPacket, + to recipient: PeerID, + localPeerIDData: Data, + isRecipientConnected: (PeerID) -> Bool, + shouldAttemptRoute: (PeerID) -> Bool, + computeRoute: (PeerID) -> [Data]? + ) -> Decision { + guard packet.senderID == localPeerIDData else { return .flood(.relayedNotOriginator) } + guard let recipientData = packet.recipientID, + recipientData.count == 8, + !recipientData.allSatisfy({ $0 == 0xFF }) else { return .flood(.broadcast) } + guard packet.ttl > 1 else { return .flood(.noTTLHeadroom) } + guard !isRecipientConnected(recipient) else { return .flood(.recipientDirect) } + guard shouldAttemptRoute(recipient) else { return .flood(.routeSuppressed) } + guard let route = computeRoute(recipient), !route.isEmpty else { return .flood(.noPath) } + return .route(route) + } +} diff --git a/bitchat/Services/Board/BoardManager.swift b/bitchat/Services/Board/BoardManager.swift new file mode 100644 index 00000000..989fda34 --- /dev/null +++ b/bitchat/Services/Board/BoardManager.swift @@ -0,0 +1,165 @@ +// +// BoardManager.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +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 + private let store: BoardStore + private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String) -> Void + private var cancellable: AnyCancellable? + + init( + transport: Transport, + store: BoardStore = .shared, + publishToNostr: ((String, String, String) -> Void)? = nil + ) { + self.transport = transport + self.store = store + self.publishToNostr = publishToNostr ?? Self.livePublishToNostr + 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()) + + // One-way Nostr bridge (v1): geohash posts also go out as kind-1 + // location notes so online users see them. No inbound merge yet. + if !geohash.isEmpty { + publishToNostr(trimmed, geohash, cleanNickname) + } + 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()) + return true + } + + private static func livePublishToNostr(content: String, geohash: String, nickname: 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 + } + do { + let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash) + let event = try NostrProtocol.createGeohashTextNote( + content: content, + geohash: geohash, + senderIdentity: identity, + nickname: nickname + ) + NostrRelayManager.shared.sendEvent(event, to: relays) + } catch { + SecureLogger.error("Board: failed to bridge post to Nostr: \(error)", category: .session) + } + } +} diff --git a/bitchat/Services/Board/BoardStore.swift b/bitchat/Services/Board/BoardStore.swift new file mode 100644 index 00000000..8ffdf7f3 --- /dev/null +++ b/bitchat/Services/Board/BoardStore.swift @@ -0,0 +1,358 @@ +// +// BoardStore.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import Combine +import Foundation + +/// 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] = [] + + 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() + } + 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 + + func pruneExpired() { + let nowMs = currentMs() + queue.sync { + pruneExpiredLocked(nowMs: nowMs) + persistLocked() + } + } + + /// 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() + } + } + + // 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) { + 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") + } +} diff --git a/bitchat/Services/CashuTokenDecoder.swift b/bitchat/Services/CashuTokenDecoder.swift new file mode 100644 index 00000000..87dfee02 --- /dev/null +++ b/bitchat/Services/CashuTokenDecoder.swift @@ -0,0 +1,339 @@ +// +// 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 +// + +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.. (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.. [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 + } +} diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index 9f2f8e9b..b8a4785e 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -22,6 +22,17 @@ 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 @@ -45,15 +56,33 @@ 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 @@ -100,12 +129,23 @@ 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 "/help": return .success(message: Self.helpText) default: @@ -125,6 +165,12 @@ final class CommandProcessor { /slap @name — slap with a large trout /block @name · /unblock @name /fav @name · /unfav @name — favorites (mesh only) + /group create — 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 — send a cashu ecash token in this chat /help — this list """ @@ -331,6 +377,138 @@ final class CommandProcessor { return .error(message: "cannot unblock \(nickname): not found") } + private static let groupUsage = "usage: /group create · 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) ")) + } + 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 ` — 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 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 — 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 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 { diff --git a/bitchat/Services/Courier/CourierStore.swift b/bitchat/Services/Courier/CourierStore.swift index 063c94d5..a24eb371 100644 --- a/bitchat/Services/Courier/CourierStore.swift +++ b/bitchat/Services/Courier/CourierStore.swift @@ -42,9 +42,11 @@ final class CourierStore { var sprayedTo: Set /// Last speculative multi-hop handover toward a relayed announce. var lastRemoteHandoverAt: 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) + CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID) } init( @@ -56,7 +58,8 @@ final class CourierStore { tier: CourierDepositTier, copies: UInt8, sprayedTo: Set = [], - lastRemoteHandoverAt: Date? = nil + lastRemoteHandoverAt: Date? = nil, + prekeyID: UInt32? = nil ) { self.recipientTag = recipientTag self.expiry = expiry @@ -67,6 +70,7 @@ final class CourierStore { self.copies = copies self.sprayedTo = sprayedTo self.lastRemoteHandoverAt = lastRemoteHandoverAt + self.prekeyID = prekeyID } // Files written before tiers/spray lack the newer fields; treat that @@ -82,6 +86,7 @@ final class CourierStore { copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1 sprayedTo = try container.decodeIfPresent(Set.self, forKey: .sprayedTo) ?? [] lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt) + prekeyID = try container.decodeIfPresent(UInt32.self, forKey: .prekeyID) } } @@ -186,7 +191,8 @@ final class CourierStore { depositorNoiseKey: depositorNoiseKey, storedAt: date, tier: tier, - copies: envelope.copies + copies: envelope.copies, + prekeyID: envelope.prekeyID )) persistLocked() return true diff --git a/bitchat/Services/Gateway/GatewayService.swift b/bitchat/Services/Gateway/GatewayService.swift new file mode 100644 index 00000000..c7bbb1ba --- /dev/null +++ b/bitchat/Services/Gateway/GatewayService.swift @@ -0,0 +1,496 @@ +// +// GatewayService.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import Combine +import Foundation + +/// Policy engine for gateway mode: an opt-in "share my internet with the +/// mesh" bridge. While the toggle is on, this device advertises the +/// `.gateway` capability bit, publishes signed geohash events deposited by +/// mesh-only peers to Nostr relays (uplink), and rebroadcasts inbound relay +/// events onto the mesh (downlink) so mesh-only peers can take part in the +/// local geohash channel. Mesh-only peers need no toggle: their uplink +/// engages automatically when relays are unreachable and a gateway peer +/// exists. +/// +/// Threat model: +/// - Keys never leave the originating device. Mesh-only senders sign events +/// locally with their per-geohash ephemeral identity; the gateway carries +/// only the finished, signed event. +/// - The gateway cannot forge or alter events: every carried event is +/// Schnorr-verified here before it is published or rebroadcast, and again +/// independently by relays and receivers. +/// - Carried contents are public geohash chat, already plaintext on Nostr, +/// so the mesh carrier adds no confidentiality loss. +/// +/// Loop-prevention rules: +/// 1. An event learned from a `fromGateway` mesh broadcast is never +/// re-published to relays, never re-uplinked, and never rebroadcast +/// (`meshBroadcastEventIDs`), so a second gateway on the same mesh cannot +/// echo mesh-carried traffic back out. Mesh-level propagation of the +/// original broadcast packet is the TTL relay's job, not ours. +/// 2. An uplink deposit is published at most once (`publishedEventIDs`) and +/// a relay event is rebroadcast at most once (`rebroadcastEventIDs`), so +/// repeat deposits and relay echoes are absorbed. +/// 3. Uplink is only attempted for locally composed events at the send site +/// (`GeohashSubscriptionManager.sendGeohash`); events received over the +/// carrier never re-enter the uplink path. This is a call-site convention; +/// the `meshBroadcastEventIDs`/`publishedEventIDs` backstops in +/// `uplinkViaMesh` enforce it defensively and are unit-tested. +/// Rules 1 and 2 are enforced here and unit-tested. +/// Rebroadcast storms at the mesh layer are additionally bounded by the BLE +/// `MessageDeduplicator` and packet TTL, and receivers dedup carried events +/// against their own relay subscriptions via the Nostr event-ID cache in +/// `NostrInboundPipeline`. +/// +/// All dependencies are closure-injected (repo convention) so the policy +/// layer is unit-testable without relays or radios. +@MainActor +final class GatewayService: ObservableObject { + enum Limits { + /// Uplink deposits held while relays are unreachable (CourierStore-style + /// bounded mailbag: bounded total, bounded per depositor). + static let maxQueuedUplinks = 20 + static let maxQueuedUplinksPerDepositor = 5 + /// Uplink deposits accepted per depositor per minute. + static let uplinkEventsPerMinutePerDepositor = 10 + /// Downlink mesh rebroadcasts per minute — BLE airtime is precious. + /// Beyond the budget events queue (bounded, drop-oldest) and drain on + /// a scheduled timer once the window frees (also re-driven by the next + /// inbound relay event); a quiet channel does not strand its backlog. + static let downlinkEventsPerMinute = 30 + static let maxPendingDownlinks = 30 + /// Accepted clock skew for a carried ephemeral event; anything older + /// is stale replay the relays would drop anyway. + static let maxEventAgeSeconds: TimeInterval = 15 * 60 + /// Bounded loop-prevention ID caches (oldest evicted). + static let maxTrackedEventIDs = 512 + } + + struct QueuedUplink { + let depositor: PeerID + let geohash: String + let event: NostrEvent + let queuedAt: Date + } + + static let shared = GatewayService() + + /// The user toggle. While true this device advertises `.gateway` and + /// bridges mesh <-> Nostr for geohash channels. + @Published private(set) var isEnabled: Bool + + // MARK: Wiring (set once by the bootstrapper; fakes in tests) + + /// Publishes a verified event to the geo relays for a geohash. + var publishToRelays: (@MainActor (NostrEvent, String) -> Void)? + /// Broadcasts an encoded `fromGateway` carrier payload on the mesh. + var broadcastToMesh: (@MainActor (Data) -> Void)? + /// Sends an encoded `toGateway` carrier payload directed to a gateway + /// peer. Returns false when the transport could not accept it. + var sendToGatewayPeer: (@MainActor (Data, PeerID) -> Bool)? + /// Reachable mesh peers currently advertising the `.gateway` capability. + var availableGatewayPeers: (@MainActor () -> [PeerID])? + /// Whether any Nostr relay connection is currently working. + var relaysConnected: (@MainActor () -> Bool)? + /// The geohash channel the local user is viewing, if any. + var currentGeohash: (@MainActor () -> String?)? + /// Injects a verified carried event into the same inbound pipeline as + /// relay-received events (blocking, rate limits, dedup, rendering). + var injectInbound: (@MainActor (NostrEvent) -> Void)? + /// Fired on toggle changes (advertise/withdraw the capability bit and + /// force a re-announce). + var onEnabledChanged: (@MainActor (Bool) -> Void)? + /// Schedules a downlink-drain closure to run after a delay. Injected so + /// the drain timer is deterministic in tests; nil arms a real `Task`. + var scheduleDrainTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)? + + // MARK: State + + /// Loop rule 1: event IDs seen in `fromGateway` mesh broadcasts. + private var meshBroadcastEventIDs: BoundedIDSet + /// Loop rule 2 (uplink): event IDs this gateway already published. + private var publishedEventIDs: BoundedIDSet + /// Loop rule 2 (downlink): event IDs this gateway already rebroadcast. + private var rebroadcastEventIDs: BoundedIDSet + + private(set) var queuedUplinks: [QueuedUplink] = [] + private var uplinkDepositTimes: [PeerID: [Date]] = [:] + private var downlinkSendTimes: [Date] = [] + private var pendingDownlinks: [(event: NostrEvent, geohash: String)] = [] + /// True while a drain timer is armed, so a burst schedules at most one. + private var downlinkDrainScheduled = false + + private let defaults: UserDefaults + private let now: () -> Date + private static let enabledKey = "gateway.userEnabled" + + init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) { + self.defaults = defaults + self.now = now + self.isEnabled = defaults.bool(forKey: Self.enabledKey) + self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + } + + // MARK: - Toggle + + func setEnabled(_ enabled: Bool) { + guard enabled != isEnabled else { return } + isEnabled = enabled + defaults.set(enabled, forKey: Self.enabledKey) + if !enabled { + queuedUplinks.removeAll() + pendingDownlinks.removeAll() + uplinkDepositTimes.removeAll() + } + SecureLogger.info("[GW] mode \(enabled ? "enabled" : "disabled")", category: .gateway) + onEnabledChanged?(enabled) + } + + // MARK: - Mesh carrier ingress (both roles) + + /// Entry point for received `nostrCarrier` packets. `directedToUs` is + /// true for packets addressed to this device (uplink deposits); false + /// for broadcasts (downlink rebroadcasts from a gateway). + func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) { + guard let carrier = NostrCarrierPacket.decode(payload) else { + SecureLogger.debug("[GW] carrier drop (undecodable) from \(peerID.id.prefix(8))…", category: .gateway) + return + } + switch carrier.direction { + case .toGateway: + // Uplink deposits are directed; a broadcast toGateway is malformed. + guard directedToUs else { return } + handleUplinkDeposit(carrier, from: peerID) + case .fromGateway: + // Downlink rides broadcast only; a directed fromGateway is malformed. + guard !directedToUs else { return } + handleDownlinkBroadcast(carrier) + } + } + + // MARK: - Uplink (gateway role: mesh peer -> internet) + + private func handleUplinkDeposit(_ carrier: NostrCarrierPacket, from depositor: PeerID) { + guard isEnabled else { return } + // Cheap structural checks first (parse, size, geohash, kind, #g tag, + // age) — no crypto — so junk and stale replays are dropped before we + // ever pay for a MainActor Schnorr verify. + guard let event = structurallyValidEvent(from: carrier) else { + SecureLogger.info("[GW] uplink reject (validation) from \(depositor.id.prefix(8))…", category: .gateway) + return + } + // Dedup by the carried event ID BEFORE verification. Loop rule 1: a + // fromGateway-learned event is mesh-carried and must never be + // re-published. Loop rule 2: repeat deposits of an already handled + // event are absorbed. A replay of one valid deposit is short-circuited + // here without a per-packet signature verify. + guard !meshBroadcastEventIDs.contains(event.id), + !publishedEventIDs.contains(event.id), + !queuedUplinks.contains(where: { $0.event.id == event.id }) else { + SecureLogger.debug("[GW] uplink skip (loop/dup) \(event.id.prefix(8))…", category: .gateway) + return + } + // Consume the per-depositor rate token BEFORE the expensive verify so + // a flood of distinct forged/junk deposits is bounded by cheap work, + // not by main-actor Schnorr verifications. + guard allowUplinkDeposit(from: depositor) else { + SecureLogger.info("[GW] uplink reject (rate-limit) from \(depositor.id.prefix(8))…", category: .gateway) + return + } + // Only now pay for cryptographic verification; receivers verify again. + guard event.isValidSignature() else { + SecureLogger.info("[GW] uplink reject (sig-fail) from \(depositor.id.prefix(8))…", category: .gateway) + return + } + + let accepted: Bool + if relaysConnected?() ?? false { + publish(event, geohash: carrier.geohash) + accepted = true + } else { + accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now())) + if accepted { + SecureLogger.info("[GW] uplink accept→queue \(event.id.prefix(8))… (relays down)", category: .gateway) + } + } + + // Only render on our own timeline what we actually accepted for + // publish or queue: a quota-dropped deposit is never published and, + // being directed, no other peer will ever see it, so showing it would + // diverge our timeline permanently from what reached the channel. + if accepted, currentGeohash?() == carrier.geohash { + injectInbound?(event) + } + } + + /// Publish everything queued while relays were unreachable. Called when + /// relay connectivity comes back. + func flushQueuedUplinks() { + guard isEnabled, relaysConnected?() ?? false, !queuedUplinks.isEmpty else { return } + let queued = queuedUplinks + queuedUplinks.removeAll() + for item in queued where !publishedEventIDs.contains(item.event.id) { + publish(item.event, geohash: item.geohash) + } + } + + private func publish(_ event: NostrEvent, geohash: String) { + publishedEventIDs.insert(event.id) + publishToRelays?(event, geohash) + SecureLogger.info("[GW] uplink accept→publish \(event.id.prefix(8))… to relays for #\(geohash)", category: .gateway) + } + + /// Returns true when the item was actually stored for later publish. + @discardableResult + private func enqueueUplink(_ item: QueuedUplink) -> Bool { + let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count + guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else { + SecureLogger.info("[GW] uplink reject (quota) for \(item.depositor.id.prefix(8))…", category: .gateway) + return false + } + if queuedUplinks.count >= Limits.maxQueuedUplinks { + queuedUplinks.removeFirst(queuedUplinks.count - Limits.maxQueuedUplinks + 1) + } + queuedUplinks.append(item) + return true + } + + private func allowUplinkDeposit(from depositor: PeerID) -> Bool { + let cutoff = now().addingTimeInterval(-60) + var times = uplinkDepositTimes[depositor, default: []] + times.removeAll { $0 < cutoff } + guard times.count < Limits.uplinkEventsPerMinutePerDepositor else { + uplinkDepositTimes[depositor] = times + return false + } + times.append(now()) + uplinkDepositTimes[depositor] = times + // Bound the tracker itself against a churn of spoofed depositors. + if uplinkDepositTimes.count > Limits.maxTrackedEventIDs { + uplinkDepositTimes = uplinkDepositTimes.filter { !$0.value.isEmpty && $0.value.contains { $0 >= cutoff } } + } + return true + } + + // MARK: - Downlink (gateway role: internet -> mesh) + + /// Called for every event the gateway's own geohash-channel subscription + /// delivers. Wraps it in a `fromGateway` carrier and broadcasts it on + /// the mesh, within the airtime budget. + func rebroadcastRelayEvent(_ event: NostrEvent, geohash: String) { + guard isEnabled, broadcastToMesh != nil else { return } + guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } + // Freshness + geohash gate BEFORE spending any budget. A channel + // (re)subscribe backfills up to an hour of history (limit 200), but + // every receiver's `validatedEvent` drops anything older than the + // same window — so rebroadcasting backfill would burn the whole + // per-minute budget on events no mesh peer accepts. Also require the + // event's own `#g` tag to match the carrier geohash. + guard isFresh(event), + event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == geohash }) else { + SecureLogger.debug("[GW] downlink drop (stale/mismatch) \(event.id.prefix(8))…", category: .gateway) + return + } + // Loop rule 1: never rebroadcast mesh-carried events back onto the + // mesh. Loop rule 2: rebroadcast each relay event at most once — but + // mark only AFTER it is actually sent (in `drainPendingDownlinks`), so + // an event dropped by the queue overflow stays retryable on relay + // redelivery. Guard against a redelivery re-queueing an event that is + // still waiting to be sent. + guard !meshBroadcastEventIDs.contains(event.id), + !rebroadcastEventIDs.contains(event.id), + !pendingDownlinks.contains(where: { $0.event.id == event.id }) else { + SecureLogger.debug("[GW] downlink skip (loop/dup) \(event.id.prefix(8))…", category: .gateway) + return + } + // Verify before spending BLE airtime; receivers verify again. + guard event.isValidSignature() else { + SecureLogger.debug("[GW] downlink drop (sig-fail) \(event.id.prefix(8))…", category: .gateway) + return + } + + pendingDownlinks.append((event, geohash)) + if pendingDownlinks.count > Limits.maxPendingDownlinks { + // Bandwidth guard: drop-oldest — fresher chat is worth more. The + // dropped event is not yet in `rebroadcastEventIDs`, so a later + // relay redelivery can still carry it. + pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks) + SecureLogger.info("[GW] downlink drop-oldest (budget), \(pendingDownlinks.count) pending", category: .gateway) + } + drainPendingDownlinks() + } + + private func drainPendingDownlinks() { + let cutoff = now().addingTimeInterval(-60) + downlinkSendTimes.removeAll { $0 < cutoff } + while !pendingDownlinks.isEmpty, + downlinkSendTimes.count < Limits.downlinkEventsPerMinute { + let (event, geohash) = pendingDownlinks.removeFirst() + // A queued event may have aged past the window while it waited; + // don't burn airtime on what receivers would now drop. + guard isFresh(event) else { continue } + guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event), + let payload = carrier.encode() else { continue } + broadcastToMesh?(payload) + SecureLogger.info("[GW] downlink rebroadcast \(event.id.prefix(8))… to mesh for #\(geohash)", category: .gateway) + // Mark-after-send: only now is the relay event definitively + // rebroadcast (loop rule 2). + rebroadcastEventIDs.insert(event.id) + downlinkSendTimes.append(now()) + } + // Budget exhausted with events still queued: arm a timer to drain when + // the window frees, instead of stranding them until the next inbound + // relay event (which may never come on a channel that went quiet). + scheduleDownlinkDrainIfNeeded() + } + + /// Arms a single timer to drain the backlog once the per-minute window + /// frees. No-op when nothing is pending or a drain is already scheduled. + private func scheduleDownlinkDrainIfNeeded() { + guard !pendingDownlinks.isEmpty, !downlinkDrainScheduled else { return } + // The window frees when the oldest recorded send ages out of 60s. + let oldest = downlinkSendTimes.min() ?? now() + let delay = max(0.05, 60 - now().timeIntervalSince(oldest)) + downlinkDrainScheduled = true + SecureLogger.info("[GW] drain-timer armed (\(String(format: "%.1f", delay))s, \(pendingDownlinks.count) pending)", category: .gateway) + let fire: @MainActor () -> Void = { [weak self] in + guard let self else { return } + self.downlinkDrainScheduled = false + SecureLogger.info("[GW] drain-timer fired", category: .gateway) + self.drainPendingDownlinks() + } + if let scheduleDrainTimer { + scheduleDrainTimer(delay, fire) + } else { + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + fire() + } + } + } + + // MARK: - Downlink (receiver role: carried event arrives over mesh) + + private func handleDownlinkBroadcast(_ carrier: NostrCarrierPacket) { + guard let event = validatedEvent(from: carrier) else { return } + // Mark only AFTER signature verification, so a forged copy carrying a + // real event's ID cannot poison the never-republish set, and use the + // marking as dedup: the same broadcast relayed along several mesh + // paths injects once (the pipeline's Nostr event-ID cache additionally + // dedups against our own relay subscription). + guard meshBroadcastEventIDs.insert(event.id) else { return } + // Only inject events for the channel we're viewing; the inbound + // pipeline files public messages under the current geohash. + guard currentGeohash?() == carrier.geohash else { return } + injectInbound?(event) + } + + // MARK: - Uplink (sender role: mesh-only peer with no relays) + + /// Hands a locally signed event to a mesh gateway peer when we have no + /// working relay connection. Returns true when the event was sent. + /// + /// v1 is deliberately fire-and-forget: no gateway ack. The event also + /// stays in `NostrRelayManager`'s own pending queue, so if our internet + /// comes back the relays dedup the duplicate publish by event ID. + /// + /// Loop rule 3: call sites only pass freshly composed events (see + /// `GeohashSubscriptionManager.sendGeohash`); received carrier events + /// never reach this path, and the mesh-carried guard below backstops it. + func uplinkViaMesh(event: NostrEvent, geohash: String) -> Bool { + if relaysConnected?() ?? true { return false } + guard !meshBroadcastEventIDs.contains(event.id), + !publishedEventIDs.contains(event.id) else { + return false + } + // A single gateway is enough — relays fan out from there, and BLE + // airtime is precious. + guard let gateway = availableGatewayPeers?().first else { return false } + guard let carrier = NostrCarrierPacket(direction: .toGateway, geohash: geohash, event: event), + let payload = carrier.encode() else { + return false + } + guard sendToGatewayPeer?(payload, gateway) ?? false else { return false } + SecureLogger.info("[GW] uplink send \(event.id.prefix(8))… for #\(geohash) via gateway \(gateway.id.prefix(8))…", category: .gateway) + return true + } + + // MARK: - Validation + + /// Structural and cryptographic checks every carried event must pass + /// before a gateway publishes it or a receiver displays it. Ordered + /// cheap-first; Schnorr verification runs last. + private func validatedEvent(from carrier: NostrCarrierPacket) -> NostrEvent? { + guard let event = structurallyValidEvent(from: carrier), + event.isValidSignature() else { + return nil + } + return event + } + + /// The cheap half of `validatedEvent`: parse + size + geohash + kind + + /// `#g` tag + freshness, with NO signature verification. Callers that can + /// dedup or rate-limit on the carried ID run this first so the expensive + /// Schnorr verify is reached only for events that survive the cheap gates. + private func structurallyValidEvent(from carrier: NostrCarrierPacket) -> NostrEvent? { + guard carrier.eventJSON.count <= NostrCarrierPacket.maxEventJSONBytes, + Self.isValidGeohash(carrier.geohash), + let event = carrier.event(), + event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue, + event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == carrier.geohash }), + isFresh(event) else { + return nil + } + return event + } + + /// True when `event.created_at` is within the accepted clock skew — the + /// SAME freshness window receivers enforce, so a gateway never spends + /// airtime on events every receiver would drop as stale. + private func isFresh(_ event: NostrEvent) -> Bool { + abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds + } + + static func isValidGeohash(_ geohash: String) -> Bool { + let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") + return (1...NostrCarrierPacket.maxGeohashLength).contains(geohash.count) + && geohash.allSatisfy { allowed.contains($0) } + } +} + +/// Insertion-ordered string set with a fixed capacity; the oldest entry is +/// evicted when full. +private struct BoundedIDSet { + private var members: Set = [] + private var order: [String] = [] + let capacity: Int + + init(capacity: Int) { + self.capacity = capacity + } + + func contains(_ id: String) -> Bool { + members.contains(id) + } + + /// Returns false when the ID was already present. + @discardableResult + mutating func insert(_ id: String) -> Bool { + guard members.insert(id).inserted else { return false } + order.append(id) + if order.count > capacity { + members.remove(order.removeFirst()) + } + return true + } +} diff --git a/bitchat/Services/Groups/GroupProtocol.swift b/bitchat/Services/Groups/GroupProtocol.swift new file mode 100644 index 00000000..dc4a0abf --- /dev/null +++ b/bitchat/Services/Groups/GroupProtocol.swift @@ -0,0 +1,569 @@ +// +// GroupProtocol.swift +// bitchat +// +// Wire formats and crypto for private groups: creator-signed group state +// (invites and key updates over Noise) and ChaCha20-Poly1305 group messages +// broadcast as MessageType.groupMessage (0x25). +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import CryptoKit +import Foundation + +// MARK: - Models + +/// A member of a private group as pinned in the creator-signed roster. +struct GroupMember: Codable, Equatable { + /// SHA-256 fingerprint (64 hex chars) of the member's Noise static key. + let fingerprint: String + /// The member's Ed25519 signing public key (32 bytes, from their announce). + let signingKey: Data + /// Nickname at invite time; display fallback when the peer is offline. + var nickname: String +} + +/// Creator-managed encrypted group. Metadata only — the symmetric key lives +/// in the keychain (see `GroupStore`). +struct BitchatGroup: Codable, Equatable { + static let maxMembers = 16 + static let groupIDLength = 16 + static let keyLength = 32 + + /// 16 random bytes; travels in cleartext on group message packets so + /// relays can dedup/filter without membership. + let groupID: Data + var name: String + /// Bumps on every key rotation; messages are bound to the epoch they + /// were sealed under. + var epoch: UInt32 + var members: [GroupMember] + /// Fingerprint of the creator — the only identity allowed to sign group + /// state (invites, key updates) in v1. + let creatorFingerprint: String + + /// Virtual conversation ID this group's chat is keyed under. + var peerID: PeerID { PeerID(groupID: groupID) } + + var creator: GroupMember? { + members.first { $0.fingerprint == creatorFingerprint } + } + + func isMember(fingerprint: String) -> Bool { + members.contains { $0.fingerprint == fingerprint } + } + + func member(withSigningKey signingKey: Data) -> GroupMember? { + members.first { $0.signingKey == signingKey } + } +} + +// MARK: - TLV helpers + +enum GroupTLVError: Error, Equatable { + /// A TLV value exceeded the 16-bit length field. Encoding fails instead + /// of silently truncating (which would ship a value the receiver drops). + case valueTooLong +} + +private enum GroupTLV { + /// Appends a (type, 16-bit length, value) triple. Throws rather than + /// truncating when `value` does not fit the 16-bit length field, so an + /// oversize field surfaces a send failure instead of a silently truncated + /// blob the recipient rejects during decrypt/verify. + static func put(_ type: UInt8, _ value: Data, into out: inout Data) throws { + guard value.count <= Int(UInt16.max) else { throw GroupTLVError.valueTooLong } + out.append(type) + let length = UInt16(value.count) + out.append(UInt8((length >> 8) & 0xFF)) + out.append(UInt8(length & 0xFF)) + out.append(value) + } + + /// Iterates (type, value) pairs; returns nil on malformed framing. + static func parse(_ data: Data) -> [(type: UInt8, value: Data)]? { + var fields: [(UInt8, Data)] = [] + var offset = data.startIndex + while offset < data.endIndex { + guard data.distance(from: offset, to: data.endIndex) >= 3 else { return nil } + let type = data[offset] + let high = Int(data[data.index(offset, offsetBy: 1)]) + let low = Int(data[data.index(offset, offsetBy: 2)]) + let length = (high << 8) | low + let valueStart = data.index(offset, offsetBy: 3) + guard data.distance(from: valueStart, to: data.endIndex) >= length else { return nil } + let valueEnd = data.index(valueStart, offsetBy: length) + fields.append((type, Data(data[valueStart.. Data { + var bigEndian = epoch.bigEndian + return withUnsafeBytes(of: &bigEndian) { Data($0) } + } + + static func epoch(from data: Data) -> UInt32? { + guard data.count == 4 else { return nil } + return data.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } + } + + static func timestampData(_ timestampMs: UInt64) -> Data { + var bigEndian = timestampMs.bigEndian + return withUnsafeBytes(of: &bigEndian) { Data($0) } + } + + static func timestamp(from data: Data) -> UInt64? { + guard data.count == 8 else { return nil } + return data.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) } + } +} + +// MARK: - Roster wire form + +enum GroupRosterCoding { + private static let fingerprintLength = 32 + private static let signingKeyLength = 32 + private static let maxNicknameBytes = 64 + + /// Deterministic roster blob: count byte, then per member the raw 32-byte + /// fingerprint, 32-byte signing key, and length-prefixed UTF-8 nickname. + /// The creator signature covers the SHA-256 of these exact bytes. + static func encode(_ members: [GroupMember]) -> Data? { + guard members.count <= BitchatGroup.maxMembers else { return nil } + var out = Data([UInt8(members.count)]) + for member in members { + guard let fingerprintData = Data(hexString: member.fingerprint), + fingerprintData.count == fingerprintLength, + member.signingKey.count == signingKeyLength else { return nil } + out.append(fingerprintData) + out.append(member.signingKey) + // Truncate on a Character boundary so the byte prefix is always + // valid UTF-8; a raw byte-prefix could split a multi-byte scalar + // and make the whole signed roster undecodable on the recipient. + let nickname = truncatedNicknameBytes(member.nickname) + out.append(UInt8(nickname.count)) + out.append(nickname) + } + return out + } + + static func decode(_ data: Data) -> [GroupMember]? { + guard let count = data.first, count <= UInt8(BitchatGroup.maxMembers) else { return nil } + var members: [GroupMember] = [] + var offset = data.index(after: data.startIndex) + for _ in 0..= fixed else { return nil } + let fingerprintEnd = data.index(offset, offsetBy: fingerprintLength) + let fingerprint = Data(data[offset..= nickLength else { return nil } + let nickEnd = data.index(nickStart, offsetBy: nickLength) + guard let nickname = String(data: Data(data[nickStart.. Data { + var candidate = nickname + while Data(candidate.utf8).count > maxNicknameBytes { + candidate.removeLast() + } + return Data(candidate.utf8) + } +} + +// MARK: - Group state payload (groupInvite / groupKeyUpdate over Noise) + +/// Creator-signed group state. The same wire form serves invites (0x06) and +/// key updates (0x07); receivers verify the creator signature — computed over +/// "bitchat-group-v1" | groupID | epoch | SHA256(key) | SHA256(roster) — +/// against the creator's signing key pinned in the roster, and require the +/// Noise session peer to BE the creator before accepting any state. +struct GroupStatePayload: Equatable { + let groupID: Data + let name: String + /// Symmetric ChaCha20-Poly1305 group key (32 bytes) for `epoch`. + let key: Data + let epoch: UInt32 + let members: [GroupMember] + let creatorFingerprint: String + /// Ed25519 signature by the creator. + let signature: Data + + private enum FieldType: UInt8 { + case groupID = 0x01 + case name = 0x02 + case key = 0x03 + case epoch = 0x04 + case roster = 0x05 + case creatorFingerprint = 0x06 + case signature = 0x07 + } + + static let signingDomain = Data("bitchat-group-v1".utf8) + + /// The bytes the creator signs. Binding the key, roster, and name by hash + /// keeps the signed content fixed-size. The name is covered so a relay + /// that caches/replays a signed state (e.g. store-and-forward) cannot swap + /// the display name while keeping a valid creator signature. + static func signingContent(groupID: Data, epoch: UInt32, key: Data, rosterBlob: Data, name: String) -> Data { + var content = signingDomain + content.append(groupID) + content.append(GroupTLV.epochData(epoch)) + content.append(key.sha256Hash()) + content.append(rosterBlob.sha256Hash()) + content.append(Data(name.utf8).sha256Hash()) + return content + } + + /// Builds a signed state payload. Returns nil when the roster cannot be + /// encoded (over cap, malformed member) or signing fails. + static func makeSigned( + group: BitchatGroup, + key: Data, + sign: (Data) -> Data? + ) -> GroupStatePayload? { + guard let rosterBlob = GroupRosterCoding.encode(group.members) else { return nil } + let content = signingContent(groupID: group.groupID, epoch: group.epoch, key: key, rosterBlob: rosterBlob, name: group.name) + guard let signature = sign(content) else { return nil } + return GroupStatePayload( + groupID: group.groupID, + name: group.name, + key: key, + epoch: group.epoch, + members: group.members, + creatorFingerprint: group.creatorFingerprint, + signature: signature + ) + } + + func encode() -> Data? { + guard let rosterBlob = GroupRosterCoding.encode(members), + let fingerprintData = Data(hexString: creatorFingerprint), + fingerprintData.count == 32 else { return nil } + var out = Data() + do { + try GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out) + try GroupTLV.put(FieldType.name.rawValue, Data(name.utf8), into: &out) + try GroupTLV.put(FieldType.key.rawValue, key, into: &out) + try GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out) + try GroupTLV.put(FieldType.roster.rawValue, rosterBlob, into: &out) + try GroupTLV.put(FieldType.creatorFingerprint.rawValue, fingerprintData, into: &out) + try GroupTLV.put(FieldType.signature.rawValue, signature, into: &out) + } catch { + return nil + } + return out + } + + static func decode(_ data: Data) -> GroupStatePayload? { + guard let fields = GroupTLV.parse(data) else { return nil } + var groupID: Data? + var name: String? + var key: Data? + var epoch: UInt32? + var rosterBlob: Data? + var members: [GroupMember]? + var creatorFingerprint: String? + var signature: Data? + + for (type, value) in fields { + switch FieldType(rawValue: type) { + case .groupID where value.count == BitchatGroup.groupIDLength: + groupID = value + case .name: + name = String(data: value, encoding: .utf8) + case .key where value.count == BitchatGroup.keyLength: + key = value + case .epoch: + epoch = GroupTLV.epoch(from: value) + case .roster: + rosterBlob = value + members = GroupRosterCoding.decode(value) + case .creatorFingerprint where value.count == 32: + creatorFingerprint = value.hexEncodedString() + case .signature where value.count == 64: + signature = value + default: + break // forward compatible; ignore unknown TLVs + } + } + + guard let groupID, let name, let key, let epoch, + rosterBlob != nil, let members, !members.isEmpty, + let creatorFingerprint, let signature else { return nil } + return GroupStatePayload( + groupID: groupID, + name: name, + key: key, + epoch: epoch, + members: members, + creatorFingerprint: creatorFingerprint, + signature: signature + ) + } + + /// Verifies the creator signature against the creator's signing key + /// pinned in the roster, and that the creator is actually in the roster. + func verifyCreatorSignature() -> Bool { + guard members.count <= BitchatGroup.maxMembers, + let creator = members.first(where: { $0.fingerprint == creatorFingerprint }), + let rosterBlob = GroupRosterCoding.encode(members) else { return false } + let content = GroupStatePayload.signingContent(groupID: groupID, epoch: epoch, key: key, rosterBlob: rosterBlob, name: name) + return GroupCrypto.verify(signature: signature, for: content, publicKey: creator.signingKey) + } + + var asGroup: BitchatGroup { + BitchatGroup( + groupID: groupID, + name: name, + epoch: epoch, + members: members, + creatorFingerprint: creatorFingerprint + ) + } +} + +// MARK: - Group message envelope (MessageType 0x25 payload) + +/// Cleartext framing of a group message broadcast. Only the group ID, epoch, +/// and nonce are visible to relays; everything about the message — sender, +/// content, timestamps — is inside the ChaCha20-Poly1305 ciphertext. +struct GroupMessageEnvelope: Equatable { + let groupID: Data + let epoch: UInt32 + let nonce: Data + /// ChaChaPoly ciphertext || 16-byte tag. + let ciphertext: Data + + private enum FieldType: UInt8 { + case groupID = 0x01 + case epoch = 0x02 + case nonce = 0x03 + case ciphertext = 0x04 + } + + func encode() throws -> Data { + var out = Data() + try GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out) + try GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out) + try GroupTLV.put(FieldType.nonce.rawValue, nonce, into: &out) + try GroupTLV.put(FieldType.ciphertext.rawValue, ciphertext, into: &out) + return out + } + + static func decode(_ data: Data) -> GroupMessageEnvelope? { + guard let fields = GroupTLV.parse(data) else { return nil } + var groupID: Data? + var epoch: UInt32? + var nonce: Data? + var ciphertext: Data? + for (type, value) in fields { + switch FieldType(rawValue: type) { + case .groupID where value.count == BitchatGroup.groupIDLength: + groupID = value + case .epoch: + epoch = GroupTLV.epoch(from: value) + case .nonce where value.count == 12: + nonce = value + case .ciphertext where !value.isEmpty: + ciphertext = value + default: + break + } + } + guard let groupID, let epoch, let nonce, let ciphertext else { return nil } + return GroupMessageEnvelope(groupID: groupID, epoch: epoch, nonce: nonce, ciphertext: ciphertext) + } +} + +/// Decrypted, signature-verified inner content of a group message. +struct GroupMessagePlaintext: Equatable { + let messageID: String + let senderSigningKey: Data + let senderNickname: String + let timestampMs: UInt64 + let content: String +} + +// MARK: - Crypto + +enum GroupCryptoError: Error, Equatable { + case malformedPayload + case signingFailed + case sealFailed + case wrongEpoch + case decryptionFailed + case badSenderSignature +} + +enum GroupCrypto { + static let messageSigningDomain = Data("bitchat-group-msg-v1".utf8) + + private enum InnerField: UInt8 { + case messageID = 0x01 + case senderSigningKey = 0x02 + case senderNickname = 0x03 + case timestamp = 0x04 + case content = 0x05 + case signature = 0x06 + } + + /// Bytes the sender signs: domain | groupID | epoch | messageID | timestamp | content. + /// Covering the epoch stops a current member from re-sealing another + /// member's decrypted inner bytes under a later epoch key (the signature + /// would no longer verify at the new epoch). + static func messageSigningContent(groupID: Data, epoch: UInt32, messageID: String, timestampMs: UInt64, content: String) -> Data { + var data = messageSigningDomain + data.append(groupID) + data.append(GroupTLV.epochData(epoch)) + data.append(Data(messageID.utf8)) + data.append(GroupTLV.timestampData(timestampMs)) + data.append(Data(content.utf8)) + return data + } + + static func verify(signature: Data, for data: Data, publicKey: Data) -> Bool { + guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else { return false } + return key.isValidSignature(signature, for: data) + } + + /// Seals a group message: builds the signed inner TLV and encrypts it with + /// the epoch key. The cleartext group ID and epoch are bound into the AEAD + /// as additional data so ciphertext cannot be replayed across groups or + /// epochs. Returns the encoded 0x25 packet payload. + static func sealMessage( + content: String, + messageID: String, + senderNickname: String, + senderSigningKey: Data, + timestampMs: UInt64, + groupID: Data, + epoch: UInt32, + key: Data, + sign: (Data) -> Data? + ) throws -> Data { + let signingContent = messageSigningContent( + groupID: groupID, + epoch: epoch, + messageID: messageID, + timestampMs: timestampMs, + content: content + ) + guard let signature = sign(signingContent), signature.count == 64 else { + throw GroupCryptoError.signingFailed + } + + var inner = Data() + try GroupTLV.put(InnerField.messageID.rawValue, Data(messageID.utf8), into: &inner) + try GroupTLV.put(InnerField.senderSigningKey.rawValue, senderSigningKey, into: &inner) + try GroupTLV.put(InnerField.senderNickname.rawValue, Data(senderNickname.utf8), into: &inner) + try GroupTLV.put(InnerField.timestamp.rawValue, GroupTLV.timestampData(timestampMs), into: &inner) + try GroupTLV.put(InnerField.content.rawValue, Data(content.utf8), into: &inner) + try GroupTLV.put(InnerField.signature.rawValue, signature, into: &inner) + + do { + let symmetricKey = SymmetricKey(data: key) + var aad = groupID + aad.append(GroupTLV.epochData(epoch)) + let sealed = try ChaChaPoly.seal(inner, using: symmetricKey, authenticating: aad) + var ciphertext = sealed.ciphertext + ciphertext.append(sealed.tag) + let envelope = GroupMessageEnvelope( + groupID: groupID, + epoch: epoch, + nonce: Data(sealed.nonce), + ciphertext: ciphertext + ) + return try envelope.encode() + } catch { + throw GroupCryptoError.sealFailed + } + } + + /// Opens a group message envelope with the epoch key: decrypts, parses the + /// inner TLV, and verifies the sender's Ed25519 signature. Roster + /// membership of the sender is the CALLER's check — this function only + /// proves the payload was authored by `senderSigningKey`. + static func openMessage(_ envelope: GroupMessageEnvelope, key: Data) throws -> GroupMessagePlaintext { + let inner: Data + do { + let symmetricKey = SymmetricKey(data: key) + var aad = envelope.groupID + aad.append(GroupTLV.epochData(envelope.epoch)) + let nonce = try ChaChaPoly.Nonce(data: envelope.nonce) + guard envelope.ciphertext.count > 16 else { throw GroupCryptoError.decryptionFailed } + let tag = envelope.ciphertext.suffix(16) + let body = envelope.ciphertext.prefix(envelope.ciphertext.count - 16) + let sealedBox = try ChaChaPoly.SealedBox(nonce: nonce, ciphertext: body, tag: tag) + inner = try ChaChaPoly.open(sealedBox, using: symmetricKey, authenticating: aad) + } catch { + throw GroupCryptoError.decryptionFailed + } + + guard let fields = GroupTLV.parse(inner) else { throw GroupCryptoError.malformedPayload } + var messageID: String? + var senderSigningKey: Data? + var senderNickname: String? + var timestampMs: UInt64? + var content: String? + var signature: Data? + for (type, value) in fields { + switch InnerField(rawValue: type) { + case .messageID: + messageID = String(data: value, encoding: .utf8) + case .senderSigningKey where value.count == 32: + senderSigningKey = value + case .senderNickname: + senderNickname = String(data: value, encoding: .utf8) + case .timestamp: + timestampMs = GroupTLV.timestamp(from: value) + case .content: + content = String(data: value, encoding: .utf8) + case .signature where value.count == 64: + signature = value + default: + break + } + } + guard let messageID, !messageID.isEmpty, + let senderSigningKey, + let senderNickname, + let timestampMs, + let content, + let signature else { throw GroupCryptoError.malformedPayload } + + let signingContent = messageSigningContent( + groupID: envelope.groupID, + epoch: envelope.epoch, + messageID: messageID, + timestampMs: timestampMs, + content: content + ) + guard verify(signature: signature, for: signingContent, publicKey: senderSigningKey) else { + throw GroupCryptoError.badSenderSignature + } + + return GroupMessagePlaintext( + messageID: messageID, + senderSigningKey: senderSigningKey, + senderNickname: senderNickname, + timestampMs: timestampMs, + content: content + ) + } +} diff --git a/bitchat/Services/Groups/GroupStore.swift b/bitchat/Services/Groups/GroupStore.swift new file mode 100644 index 00000000..3a45dcb7 --- /dev/null +++ b/bitchat/Services/Groups/GroupStore.swift @@ -0,0 +1,194 @@ +// +// GroupStore.swift +// bitchat +// +// Persistence for private groups: symmetric keys in the keychain, metadata +// (roster, name, epoch) as protected JSON in Application Support. Both are +// dropped by the panic wipe. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import Combine +import Foundation +import Security + +@MainActor +final class GroupStore: ObservableObject { + /// All groups this device is a member of, in creation/join order. + @Published private(set) var groups: [BitchatGroup] = [] + + private let keychain: KeychainManagerProtocol + private let fileURL: URL? + + /// - Parameter fileURL: Overrides the on-disk location (tests). Ignored + /// when `persistsToDisk` is false. + init(keychain: KeychainManagerProtocol, persistsToDisk: Bool = true, fileURL: URL? = nil) { + self.keychain = keychain + self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil + loadFromDisk() + } + + // MARK: - Reads + + func group(withID groupID: Data) -> BitchatGroup? { + groups.first { $0.groupID == groupID } + } + + func group(for peerID: PeerID) -> BitchatGroup? { + guard let groupID = peerID.groupIDData else { return nil } + return group(withID: groupID) + } + + /// Current-epoch symmetric key for the group, from the keychain. + func key(forGroupID groupID: Data) -> Data? { + keychain.getIdentityKey(forKey: Self.keychainKey(for: groupID)) + } + + // MARK: - Mutations + + /// Creates a new group with a random 16-byte ID and 32-byte key at + /// epoch 1, with the creator as sole member. Returns nil when key + /// generation or persistence fails. + func createGroup(named name: String, creator: GroupMember) -> BitchatGroup? { + guard let groupID = Self.randomBytes(BitchatGroup.groupIDLength), + let key = Self.randomBytes(BitchatGroup.keyLength) else { return nil } + let group = BitchatGroup( + groupID: groupID, + name: name, + epoch: 1, + members: [creator], + creatorFingerprint: creator.fingerprint + ) + guard upsert(group, key: key) else { return nil } + return group + } + + /// Inserts or replaces a group and its current key. Rejects rosters over + /// the hard cap or groups whose creator is missing from the roster. + @discardableResult + func upsert(_ group: BitchatGroup, key: Data) -> Bool { + guard group.groupID.count == BitchatGroup.groupIDLength, + key.count == BitchatGroup.keyLength, + !group.members.isEmpty, + group.members.count <= BitchatGroup.maxMembers, + group.creator != nil else { return false } + guard keychain.saveIdentityKey(key, forKey: Self.keychainKey(for: group.groupID)) else { + SecureLogger.error("Failed to store group key in keychain", category: .security) + return false + } + if let index = groups.firstIndex(where: { $0.groupID == group.groupID }) { + groups[index] = group + } else { + groups.append(group) + } + persist() + return true + } + + /// Updates the roster of an existing group without changing key or epoch + /// (creator-side invite). Enforces the member cap. + @discardableResult + func updateRoster(groupID: Data, members: [GroupMember]) -> BitchatGroup? { + guard let index = groups.firstIndex(where: { $0.groupID == groupID }), + !members.isEmpty, + members.count <= BitchatGroup.maxMembers, + members.contains(where: { $0.fingerprint == groups[index].creatorFingerprint }) else { return nil } + groups[index].members = members + persist() + return groups[index] + } + + /// Rotates the group key (creator-side removal/rotation): new random key, + /// epoch + 1, and the given roster. Returns the updated group and new key. + func rotateKey(groupID: Data, members: [GroupMember]) -> (group: BitchatGroup, key: Data)? { + guard let existing = group(withID: groupID), + let newKey = Self.randomBytes(BitchatGroup.keyLength) else { return nil } + var rotated = existing + rotated.epoch = existing.epoch &+ 1 + rotated.members = members + guard upsert(rotated, key: newKey) else { return nil } + return (rotated, newKey) + } + + func removeGroup(withID groupID: Data) { + groups.removeAll { $0.groupID == groupID } + _ = keychain.deleteIdentityKey(forKey: Self.keychainKey(for: groupID)) + persist() + } + + /// Panic wipe: drop all group keys and metadata from memory and disk. + /// (The panic flow also nukes the whole keychain; deleting per-group keys + /// here keeps the store safe to wipe on its own.) + func wipe() { + for group in groups { + _ = keychain.deleteIdentityKey(forKey: Self.keychainKey(for: group.groupID)) + } + groups.removeAll() + if let fileURL { + try? FileManager.default.removeItem(at: fileURL) + } + } + + // MARK: - Internals + + private static func keychainKey(for groupID: Data) -> String { + "groupKey-\(groupID.hexEncodedString())" + } + + private static func randomBytes(_ count: Int) -> Data? { + var bytes = Data(count: count) + let status = bytes.withUnsafeMutableBytes { buffer -> OSStatus in + guard let baseAddress = buffer.baseAddress else { return errSecParam } + return SecRandomCopyBytes(kSecRandomDefault, count, baseAddress) + } + return status == errSecSuccess ? bytes : nil + } + + private func persist() { + guard let fileURL else { return } + do { + if groups.isEmpty { + try? FileManager.default.removeItem(at: fileURL) + return + } + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(groups) + 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 group store: \(error)", category: .session) + } + } + + private func loadFromDisk() { + guard let fileURL, + let data = try? Data(contentsOf: fileURL), + let stored = try? JSONDecoder().decode([BitchatGroup].self, from: data) else { + return + } + // Only groups whose key survived in the keychain are usable. + groups = stored.filter { key(forGroupID: $0.groupID) != nil } + } + + 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("groups", isDirectory: true) + .appendingPathComponent("groups.json") + } +} diff --git a/bitchat/Services/LocationNotesManager.swift b/bitchat/Services/LocationNotesManager.swift index 910ecced..a3437fb5 100644 --- a/bitchat/Services/LocationNotesManager.swift +++ b/bitchat/Services/LocationNotesManager.swift @@ -89,11 +89,11 @@ final class LocationNotesManager: ObservableObject { private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200) private enum Strings { - static let noRelays = String(localized: "location_notes.error.no_relays", comment: "Shown when no geo relays are available near the selected location") + static let noRelays = String(localized: "location_notes.error.no_relays", defaultValue: "no geo relays available near this location. try again soon.", comment: "Shown when no geo relays are available near the selected location") static func failedToSend(_ detail: String) -> String { String( - format: String(localized: "location_notes.error.failed_to_send", comment: "Shown when a location note fails to send"), + format: String(localized: "location_notes.error.failed_to_send", defaultValue: "failed to send note. %@", comment: "Shown when a location note fails to send"), locale: .current, detail ) diff --git a/bitchat/Services/MeshTopologyTracker.swift b/bitchat/Services/MeshTopologyTracker.swift index fdd8749f..5596b5b1 100644 --- a/bitchat/Services/MeshTopologyTracker.swift +++ b/bitchat/Services/MeshTopologyTracker.swift @@ -10,6 +10,10 @@ final class MeshTopologyTracker { private var claims: [RoutingID: Set] = [:] // Last time we received an update from a node private var lastSeen: [RoutingID: Date] = [:] + // Highest protocol version observed from each node's decoded packets. + // Nodes absent from this map are assumed v1-only and are never used as + // hops (or targets) for version-gated routes. + private var observedVersions: [RoutingID: (version: UInt8, seenAt: Date)] = [:] // Maximum age for topology claims to be considered fresh for routing // Routes computed using stale topology can fail when the network has changed @@ -19,48 +23,75 @@ final class MeshTopologyTracker { queue.sync(flags: .barrier) { self.claims.removeAll() self.lastSeen.removeAll() + self.observedVersions.removeAll() } } /// Update the topology with a node's self-reported neighbor list - func updateNeighbors(for sourceData: Data?, neighbors: [Data]) { + func updateNeighbors(for sourceData: Data?, neighbors: [Data], at now: Date = Date()) { guard let source = sanitize(sourceData) else { return } // Sanitize neighbors and exclude self-loops let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source]) - + queue.sync(flags: .barrier) { self.claims[source] = validNeighbors - self.lastSeen[source] = Date() + self.lastSeen[source] = now } } + /// Record the protocol version observed on a decoded packet from a node. + /// Only versions above the v1 baseline are stored; the highest wins. + func recordObservedVersion(_ version: UInt8, for peerData: Data?, at now: Date = Date()) { + guard version > 1, let peer = sanitize(peerData) else { return } + queue.sync(flags: .barrier) { + let current = self.observedVersions[peer]?.version ?? 1 + self.observedVersions[peer] = (version: max(version, current), seenAt: now) + } + } + + /// Raw directed neighbor claims, for diagnostics (topology map, /trace). + /// Callers treat the claims as advisory: announces cap `directNeighbors` + /// at 10, so an edge may be claimed by only one of its endpoints. + func adjacencySnapshot() -> [Data: Set] { + queue.sync { claims } + } + func removePeer(_ data: Data?) { guard let peer = sanitize(data) else { return } queue.sync(flags: .barrier) { self.claims.removeValue(forKey: peer) self.lastSeen.removeValue(forKey: peer) + self.observedVersions.removeValue(forKey: peer) } } - + /// Prune nodes that haven't updated their topology in `age` seconds - func prune(olderThan age: TimeInterval) { - let deadline = Date().addingTimeInterval(-age) + func prune(olderThan age: TimeInterval, now: Date = Date()) { + let deadline = now.addingTimeInterval(-age) queue.sync(flags: .barrier) { let stale = self.lastSeen.filter { $0.value < deadline } for (peer, _) in stale { self.claims.removeValue(forKey: peer) self.lastSeen.removeValue(forKey: peer) } + self.observedVersions = self.observedVersions.filter { $0.value.seenAt >= deadline } } } - func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10) -> [Data]? { + /// BFS over confirmed, fresh edges. When `requiringVersion` is set, every + /// node on the path except the source (i.e. all intermediate hops and the + /// target) must have been observed speaking at least that protocol + /// version — a v1-only hop cannot decode a v2 routed packet. + func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10, requiringVersion: UInt8? = nil, now: Date = Date()) -> [Data]? { guard let source = sanitize(start), let target = sanitize(goal) else { return nil } if source == target { return [] } // Direct connection, no intermediate hops return queue.sync { - let now = Date() let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold) + func meetsRequiredVersion(_ peer: RoutingID) -> Bool { + guard let requiringVersion else { return true } + return (observedVersions[peer]?.version ?? 1) >= requiringVersion + } // BFS var visited: Set = [source] @@ -86,6 +117,10 @@ final class MeshTopologyTracker { for neighbor in neighbors { if visited.contains(neighbor) { continue } + // Version gate: skip nodes not known to speak the + // required protocol version. + guard meetsRequiredVersion(neighbor) else { continue } + // CONFIRMED EDGE CHECK: // 'last' claims 'neighbor' (checked above) // Does 'neighbor' claim 'last'? diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 8a99d9ec..3cbd1520 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -116,30 +116,30 @@ enum EncryptionStatus: Equatable { var description: String { switch self { case .none: - return String(localized: "encryption.status.failed", comment: "Status text when encryption failed") + return String(localized: "encryption.status.failed", defaultValue: "encryption failed", comment: "Status text when encryption failed") case .noHandshake: - return String(localized: "encryption.status.not_encrypted", comment: "Status text when no encryption handshake happened") + return String(localized: "encryption.status.not_encrypted", defaultValue: "not encrypted", comment: "Status text when no encryption handshake happened") case .noiseHandshaking: - return String(localized: "encryption.status.establishing", comment: "Status text when encryption is being established") + return String(localized: "encryption.status.establishing", defaultValue: "establishing encryption...", comment: "Status text when encryption is being established") case .noiseSecured: - return String(localized: "encryption.status.secured", comment: "Status text when encryption is secured but not verified") + return String(localized: "encryption.status.secured", defaultValue: "encrypted", comment: "Status text when encryption is secured but not verified") case .noiseVerified: - return String(localized: "encryption.status.verified", comment: "Status text when encryption is verified") + return String(localized: "encryption.status.verified", defaultValue: "encrypted & verified", comment: "Status text when encryption is verified") } } var accessibilityDescription: String { switch self { case .none: - return String(localized: "encryption.accessibility.failed", comment: "Accessibility text when encryption failed") + return String(localized: "encryption.accessibility.failed", defaultValue: "encryption failed", comment: "Accessibility text when encryption failed") case .noHandshake: - return String(localized: "encryption.accessibility.not_encrypted", comment: "Accessibility text when encryption is not established") + return String(localized: "encryption.accessibility.not_encrypted", defaultValue: "not encrypted", comment: "Accessibility text when encryption is not established") case .noiseHandshaking: - return String(localized: "encryption.accessibility.establishing", comment: "Accessibility text when encryption is being established") + return String(localized: "encryption.accessibility.establishing", defaultValue: "establishing encryption", comment: "Accessibility text when encryption is being established") case .noiseSecured: - return String(localized: "encryption.accessibility.secured", comment: "Accessibility text when encryption is secured") + return String(localized: "encryption.accessibility.secured", defaultValue: "encrypted", comment: "Accessibility text when encryption is secured") case .noiseVerified: - return String(localized: "encryption.accessibility.verified", comment: "Accessibility text when encryption is verified") + return String(localized: "encryption.accessibility.verified", defaultValue: "encrypted and verified", comment: "Accessibility text when encryption is verified") } } } @@ -172,6 +172,10 @@ final class NoiseEncryptionService { // Security components private let rateLimiter = NoiseRateLimiter() private let keychain: KeychainManagerProtocol + + // One-time prekeys for forward-secret courier sealing (lazy generation + // inside the store; the batch is minted on first bundle build). + private let localPrekeys: LocalPrekeyStore // Session maintenance private var rekeyTimer: Timer? @@ -200,6 +204,7 @@ final class NoiseEncryptionService { init(keychain: KeychainManagerProtocol) { self.keychain = keychain + self.localPrekeys = LocalPrekeyStore(keychain: keychain) // BCH-01-009: Load or create static identity key with proper error handling let loadedKey: Curve25519.KeyAgreement.PrivateKey @@ -412,13 +417,112 @@ final class NoiseEncryptionService { } return (payload: payload, senderStaticKey: senderKey.rawRepresentation) } - + + // MARK: - One-Time Prekey Envelopes (forward-secret Noise X) + + /// Domain separation for prekey-sealed envelopes: distinct from both the + /// interactive XX transcripts and static-sealed courier envelopes, and + /// bound to the specific prekey ID so a ciphertext cannot be replayed + /// against a different prekey. + private static let prekeyProloguePrefix = Data("bitchat-prekey-v1".utf8) + + private static func prekeyPrologue(for prekeyID: UInt32) -> Data { + var prologue = prekeyProloguePrefix + var big = prekeyID.bigEndian + withUnsafeBytes(of: &big) { prologue.append(contentsOf: $0) } + return prologue + } + + /// Encrypt a payload to one of the recipient's gossiped one-time prekeys + /// (Noise X where the responder static is the prekey, not the identity + /// key). Unlike `sealCourierPayload`, this is forward secret: once the + /// recipient consumes the prekey and its grace window lapses, the private + /// key is deleted and captured ciphertext becomes undecryptable even if + /// the recipient's identity key is later compromised. The initiator's + /// static still rides inside (encrypted), so the recipient authenticates + /// the sender exactly as with static-sealed envelopes. + func sealPrekeyPayload(_ payload: Data, recipientPrekey: PrekeyBundle.Prekey) throws -> Data { + let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientPrekey.publicKey) + let handshake = NoiseHandshakeState( + role: .initiator, + pattern: .X, + keychain: keychain, + localStaticKey: staticIdentityKey, + remoteStaticKey: remoteKey, + prologue: Self.prekeyPrologue(for: recipientPrekey.id) + ) + return try handshake.writeMessage(payload: payload) + } + + /// Decrypt an envelope sealed to one of our one-time prekeys. On success + /// the prekey is marked consumed (its private key survives a 48h grace + /// window for spray-and-wait redeliveries, then is deleted for good). + /// Returns the payload, the sender's authenticated static key (same + /// contract as `openCourierPayload`), and whether this open actually + /// retired the prekey — false for a redelivery of already-consumed mail — + /// so the caller can re-gossip the shrunken bundle only when it changed. + func openPrekeyPayload(_ envelopeCiphertext: Data, prekeyID: UInt32) throws -> (payload: Data, senderStaticKey: Data, consumedPrekey: Bool) { + guard let prekeyPrivate = localPrekeys.privateKey(for: prekeyID) else { + SecureLogger.info("[PREKEY] open failed (unknown prekey id=\(prekeyID))", category: .session) + throw NoiseEncryptionError.unknownPrekey + } + let handshake = NoiseHandshakeState( + role: .responder, + pattern: .X, + keychain: keychain, + localStaticKey: prekeyPrivate, + prologue: Self.prekeyPrologue(for: prekeyID) + ) + let payload = try handshake.readMessage(envelopeCiphertext) + guard let senderKey = handshake.getRemoteStaticPublicKey() else { + throw NoiseError.missingKeys + } + let consumedPrekey = localPrekeys.markConsumed(prekeyID) + return (payload: payload, senderStaticKey: senderKey.rawRepresentation, consumedPrekey: consumedPrekey) + } + + /// Current signed prekey bundle for gossip, minting the initial batch on + /// first use. Nil only when signing fails. + func currentPrekeyBundle() -> PrekeyBundle? { + let (prekeys, generatedAt) = localPrekeys.currentBundlePrekeys() + guard !prekeys.isEmpty else { return nil } + let unsigned = PrekeyBundle( + noiseStaticPublicKey: getStaticPublicKeyData(), + prekeys: prekeys, + generatedAt: generatedAt, + signature: Data(count: PrekeyBundle.signatureLength) + ) + guard let signature = signData(unsigned.signableBytes()) else { return nil } + return PrekeyBundle( + noiseStaticPublicKey: unsigned.noiseStaticPublicKey, + prekeys: prekeys, + generatedAt: generatedAt, + signature: signature + ) + } + + /// Verify a peer's bundle signature against their announce-bound Ed25519 + /// signing key. + func verifyPrekeyBundleSignature(_ bundle: PrekeyBundle, signingPublicKey: Data) -> Bool { + verifySignature(bundle.signature, for: bundle.signableBytes(), publicKey: signingPublicKey) + } + + /// Prune dead prekeys and top the batch back up when consumption runs it + /// low. Returns true when the published bundle changed and should be + /// re-gossiped. + @discardableResult + func replenishPrekeysIfNeeded() -> Bool { + localPrekeys.replenishIfNeeded() + } + /// Clear persistent identity (for panic mode) func clearPersistentIdentity() { // Clear from keychain let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey") let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey") SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning) + // One-time prekey privates go with the identity they were bound to. + localPrekeys.wipe() SecureLogger.warning("Panic mode activated - identity cleared", category: .security) // Stop rekey timer stopRekeyTimer() @@ -812,4 +916,7 @@ struct NoiseMessage: Codable { enum NoiseEncryptionError: Error { case handshakeRequired case sessionNotEstablished + /// Envelope references a prekey ID we don't hold (never ours, already + /// deleted after its grace window, or wiped in a panic). + case unknownPrekey } diff --git a/bitchat/Services/Prekeys/LocalPrekeyStore.swift b/bitchat/Services/Prekeys/LocalPrekeyStore.swift new file mode 100644 index 00000000..26f9ec67 --- /dev/null +++ b/bitchat/Services/Prekeys/LocalPrekeyStore.swift @@ -0,0 +1,228 @@ +// +// LocalPrekeyStore.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import CryptoKit +import Foundation + +/// Owns this device's one-time Curve25519 prekey private keys. +/// +/// Privates persist in the Keychain (single blob, same protection class as +/// the identity keys). A batch of `batchSize` unconsumed prekeys backs the +/// gossiped bundle; when consumption drops the unconsumed count below +/// `replenishThreshold`, the batch tops back up and the bundle's +/// `generatedAt` bumps so peers replace their cached copy. +/// +/// Redelivery grace: spray-and-wait means the same prekey-sealed ciphertext +/// (or a re-seal of the same message to the same prekey ID) can arrive via +/// several couriers days apart. A consumed prekey's private key is therefore +/// retained for `consumedGraceSeconds` after first use and only then deleted. +/// Tradeoff: during the grace window a compromise of the device still exposes +/// mail sealed to that prekey — the forward-secrecy clock starts at deletion, +/// not at first open. Refusing new ciphertexts while accepting redeliveries +/// is not possible (the recipient cannot distinguish them), so the window is +/// kept short and fixed. +final class LocalPrekeyStore { + struct Record: Codable { + let id: UInt32 + let privateKey: Data + let createdAt: Date + var consumedAt: Date? + } + + private struct Persisted: Codable { + var records: [Record] + var nextID: UInt32 + var generatedAt: UInt64 + } + + enum Policy { + static let batchSize = PrekeyBundle.maxPrekeys + static let replenishThreshold = 3 + /// How long a consumed prekey private survives for duplicate courier + /// deliveries of mail sealed to it. + static let consumedGraceSeconds: TimeInterval = 48 * 60 * 60 + /// Unconsumed prekeys older than this are rotated out: no honest + /// sender seals to a bundle that stale (see + /// `PrekeyBundleStore.Limits.maxBundleAgeForSealingSeconds`). + static let unconsumedRetentionSeconds: TimeInterval = 30 * 24 * 60 * 60 + } + + private static let keychainKey = "prekeysV1" + + private let keychain: KeychainManagerProtocol + private let now: () -> Date + private let queue = DispatchQueue(label: "chat.bitchat.prekeys.local") + + // Guarded by `queue`. + private var records: [Record] = [] + private var nextID: UInt32 = 0 + private var generatedAt: UInt64 = 0 + private var loaded = false + + init(keychain: KeychainManagerProtocol, now: @escaping () -> Date = Date.init) { + self.keychain = keychain + self.now = now + } + + // MARK: - Bundle contents (public prekeys) + + /// Unconsumed public prekeys for the gossiped bundle, generating the + /// initial batch on first use. Sorted by ID for canonical signing bytes. + func currentBundlePrekeys() -> (prekeys: [PrekeyBundle.Prekey], generatedAt: UInt64) { + queue.sync { + loadLocked() + _ = replenishLocked() + let prekeys = records + .filter { $0.consumedAt == nil } + .sorted { $0.id < $1.id } + .compactMap { record -> PrekeyBundle.Prekey? in + guard let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: record.privateKey) else { return nil } + return PrekeyBundle.Prekey(id: record.id, publicKey: key.publicKey.rawRepresentation) + } + return (prekeys, generatedAt) + } + } + + // MARK: - Opening (private prekeys) + + /// Private key for a prekey ID: unconsumed, or consumed within the + /// redelivery grace window. + func privateKey(for id: UInt32) -> Curve25519.KeyAgreement.PrivateKey? { + queue.sync { + loadLocked() + let date = now() + guard let record = records.first(where: { $0.id == id }) else { return nil } + if let consumedAt = record.consumedAt, + date.timeIntervalSince(consumedAt) > Policy.consumedGraceSeconds { + return nil + } + return try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: record.privateKey) + } + } + + /// Marks a prekey consumed (starts its grace clock). Idempotent: a + /// redelivery within the grace window does not restart the clock. + /// + /// Returns true when this call actually retired a prekey, i.e. the + /// published bundle shrank. Consuming a prekey drops it from + /// `currentBundlePrekeys()`, so `generatedAt` must advance strictly too: + /// otherwise peers that cached the old bundle reject the same-`generatedAt` + /// replacement in `PrekeyBundleStore.ingest`, keep assigning the consumed + /// ID, and their mail starts failing `unknownPrekey` once the 48h grace + /// lapses. The caller re-gossips on a true result. + @discardableResult + func markConsumed(_ id: UInt32) -> Bool { + queue.sync { + loadLocked() + guard let index = records.firstIndex(where: { $0.id == id }), + records[index].consumedAt == nil else { return false } + records[index].consumedAt = now() + advanceGeneratedAtLocked() + persistLocked() + return true + } + } + + /// Prunes dead prekeys and tops the unconsumed batch back up when it runs + /// low. Returns true when the published bundle changed (caller should + /// re-gossip). + @discardableResult + func replenishIfNeeded() -> Bool { + queue.sync { + loadLocked() + return replenishLocked() + } + } + + var unconsumedCount: Int { + queue.sync { + loadLocked() + return records.filter { $0.consumedAt == nil }.count + } + } + + /// Panic wipe: drop all prekey privates from memory and the Keychain. + func wipe() { + queue.sync { + records.removeAll() + nextID = 0 + generatedAt = 0 + loaded = true + _ = keychain.deleteIdentityKey(forKey: Self.keychainKey) + } + } + + // MARK: - Internals (call only on `queue`) + + private func replenishLocked() -> Bool { + let date = now() + + // Consumed prekeys past the grace window are gone for good; stale + // unconsumed ones rotate out (their bundle is too old to seal to). + let recordsBefore = records.count + let unconsumedBefore = records.filter { $0.consumedAt == nil }.count + records.removeAll { record in + if let consumedAt = record.consumedAt { + return date.timeIntervalSince(consumedAt) > Policy.consumedGraceSeconds + } + return date.timeIntervalSince(record.createdAt) > Policy.unconsumedRetentionSeconds + } + // Only a change to the *unconsumed* set alters the published bundle; + // grace-expired consumed keys were never in it. + let unconsumed = records.filter { $0.consumedAt == nil }.count + var bundleChanged = unconsumed != unconsumedBefore + + if unconsumed < Policy.replenishThreshold { + for _ in unconsumed.. +// + +import BitFoundation +import BitLogger +import Foundation + +/// Signature-verified one-time prekey bundles received from other peers. +/// +/// One bundle per Noise static key: a newer `generatedAt` replaces the cached +/// copy, keeping the IDs we already sealed with marked used so a prekey is +/// never reused across messages. Assignments are remembered per message ID so +/// deposit retries of the same message re-use its prekey (and its budget) +/// instead of burning a fresh one per courier. +/// +/// Only public key material lives here; it persists to disk so a sender can +/// prekey-seal for recipients met long ago. Included in the panic wipe. +final class PrekeyBundleStore { + struct StoredBundle: Codable { + let noiseKey: Data + var generatedAt: UInt64 + var prekeyIDs: [UInt32] + var prekeyPublicKeys: [Data] + /// IDs this device already sealed with (never reused). + var usedIDs: Set + /// messageID → prekey ID, so re-deposits of one message share one prekey. + var assignments: [String: UInt32] + var updatedAt: Date + } + + enum Limits { + static let maxPeers = 200 + /// Don't seal to bundles older than this: the owner may have rotated + /// the unconsumed keys out (see `LocalPrekeyStore.Policy`). + static let maxBundleAgeForSealingSeconds: TimeInterval = 7 * 24 * 60 * 60 + } + + static let shared = PrekeyBundleStore() + + private var bundles: [Data: StoredBundle] = [:] + private let queue = DispatchQueue(label: "chat.bitchat.prekeys.bundles") + private let fileURL: URL? + private let maxPeers: Int + private let now: () -> Date + + /// - Parameter fileURL: Overrides the on-disk location (tests). Ignored + /// when `persistsToDisk` is false. + init( + persistsToDisk: Bool = true, + fileURL: URL? = nil, + maxPeers: Int = Limits.maxPeers, + now: @escaping () -> Date = Date.init + ) { + self.now = now + self.maxPeers = maxPeers + self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil + loadFromDisk() + } + + // MARK: - Ingest + + /// Stores a bundle whose signature the caller has already verified + /// against the owner's announce-bound signing key. Returns false when an + /// equal-or-newer bundle is already cached (nothing changed). + @discardableResult + func ingest(_ bundle: PrekeyBundle) -> Bool { + guard bundle.noiseStaticPublicKey.count == PrekeyBundle.keyLength, + !bundle.prekeys.isEmpty else { return false } + return queue.sync { + if let existing = bundles[bundle.noiseStaticPublicKey], + existing.generatedAt >= bundle.generatedAt { + return false + } + let previous = bundles[bundle.noiseStaticPublicKey] + let newIDs = Set(bundle.prekeys.map(\.id)) + // Keep consumption state for IDs the fresh bundle still offers + // (a top-up keeps the owner's unconsumed keys); drop the rest. + let carriedUsed = (previous?.usedIDs ?? []).intersection(newIDs) + let carriedAssignments = (previous?.assignments ?? [:]).filter { newIDs.contains($0.value) } + bundles[bundle.noiseStaticPublicKey] = StoredBundle( + noiseKey: bundle.noiseStaticPublicKey, + generatedAt: bundle.generatedAt, + prekeyIDs: bundle.prekeys.map(\.id), + prekeyPublicKeys: bundle.prekeys.map(\.publicKey), + usedIDs: carriedUsed, + assignments: carriedAssignments, + updatedAt: now() + ) + enforceCapLocked() + persistLocked() + return true + } + } + + // MARK: - Sealing support + + /// Whether an unexpired bundle with sealable prekeys is cached for a peer. + func hasUsableBundle(for noiseKey: Data) -> Bool { + queue.sync { + guard let bundle = bundles[noiseKey], isFreshLocked(bundle) else { return false } + return bundle.usedIDs.count < bundle.prekeyIDs.count + } + } + + /// The prekey to seal a message with: the message's existing assignment if + /// any (re-deposits reuse it), else the lowest unused ID, which is then + /// marked used. Nil when no fresh bundle is cached or all its prekeys are + /// spent — callers fall back to static sealing. + func assignPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? { + queue.sync { + guard var bundle = bundles[recipientNoiseKey], isFreshLocked(bundle) else { return nil } + + if let assigned = bundle.assignments[messageID], + let index = bundle.prekeyIDs.firstIndex(of: assigned) { + return PrekeyBundle.Prekey(id: assigned, publicKey: bundle.prekeyPublicKeys[index]) + } + + guard let index = bundle.prekeyIDs.indices + .filter({ !bundle.usedIDs.contains(bundle.prekeyIDs[$0]) }) + .min(by: { bundle.prekeyIDs[$0] < bundle.prekeyIDs[$1] }) else { + return nil + } + let id = bundle.prekeyIDs[index] + bundle.usedIDs.insert(id) + bundle.assignments[messageID] = id + bundle.updatedAt = now() + bundles[recipientNoiseKey] = bundle + persistLocked() + return PrekeyBundle.Prekey(id: id, publicKey: bundle.prekeyPublicKeys[index]) + } + } + + // MARK: - Maintenance + + /// Panic wipe: drop all cached bundles from memory and disk. + func wipe() { + queue.sync { + bundles.removeAll() + if let fileURL { + try? FileManager.default.removeItem(at: fileURL) + } + } + } + + // MARK: - Internals (call only on `queue`) + + private func isFreshLocked(_ bundle: StoredBundle) -> Bool { + let ageSeconds = now().timeIntervalSince1970 - Double(bundle.generatedAt) / 1000 + return ageSeconds <= Limits.maxBundleAgeForSealingSeconds + } + + private func enforceCapLocked() { + while bundles.count > maxPeers { + guard let victim = bundles.min(by: { $0.value.updatedAt < $1.value.updatedAt }) else { return } + bundles.removeValue(forKey: victim.key) + } + } + + private func persistLocked() { + guard let fileURL else { return } + do { + if bundles.isEmpty { + try? FileManager.default.removeItem(at: fileURL) + return + } + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(Array(bundles.values)) + 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 prekey bundle store: \(error)", category: .security) + } + } + + private func loadFromDisk() { + guard let fileURL else { return } + queue.sync { + guard let data = try? Data(contentsOf: fileURL), + let stored = try? JSONDecoder().decode([StoredBundle].self, from: data) else { + return + } + for bundle in stored where bundle.prekeyIDs.count == bundle.prekeyPublicKeys.count { + bundles[bundle.noiseKey] = bundle + } + } + } + + 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("prekeys", isDirectory: true) + .appendingPathComponent("bundles.json") + } +} diff --git a/bitchat/Services/RelayController.swift b/bitchat/Services/RelayController.swift index fae850ea..10b4f2c6 100644 --- a/bitchat/Services/RelayController.swift +++ b/bitchat/Services/RelayController.swift @@ -19,6 +19,7 @@ struct RelayController { isHandshake: Bool, isAnnounce: Bool, isRequestSync: Bool = false, + isUrgentBoardPost: Bool = false, degree: Int, highDegreeThreshold: Int) -> RelayDecision { let ttlCap = min(ttl, TransportConfig.messageTTLDefault) @@ -64,7 +65,7 @@ struct RelayController { // - Dense graphs: keep lower but still allow multi-hop bridging // - Thin chains (degree <= 2): every hop counts and flood cost is // minimal, so relay at full incoming depth - // - Announces get a bit more headroom + // - Announces (and urgent board posts) get a bit more headroom let ttlLimit: UInt8 = { if degree >= highDegreeThreshold { return max(UInt8(2), min(ttlCap, UInt8(5))) @@ -72,7 +73,7 @@ struct RelayController { if degree <= 2 { return ttlCap } - let preferred = UInt8(isAnnounce ? 7 : 6) + let preferred = UInt8((isAnnounce || isUrgentBoardPost) ? 7 : 6) return max(UInt8(2), min(ttlCap, preferred)) }() let newTTL = ttlLimit &- 1 diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 27c38700..6d429c46 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -31,10 +31,47 @@ struct TransportPeerSnapshot: Equatable, Hashable { } } +/// Outcome of a `/ping` probe over the mesh. +struct MeshPingResult: Equatable { + /// Round-trip time in milliseconds. + let rttMs: Int + /// Total hops to the peer (1 = directly connected), derived from the + /// pong's TTL decrements; nil when the reply carried inconsistent TTLs. + let hops: Int? +} + +/// Undirected mesh link between two peers, normalized so `(a, b)` and +/// `(b, a)` collapse to one edge. +struct MeshTopologyEdge: Hashable { + let a: PeerID + let b: PeerID + + init(_ first: PeerID, _ second: PeerID) { + if first < second { + a = first + b = second + } else { + a = second + b = first + } + } +} + +/// Point-in-time view of the mesh graph learned from gossiped announces +/// (each announce carries up to 10 `directNeighbors`). +struct MeshTopologySnapshot: Equatable { + let localPeerID: PeerID + let nodes: [PeerID] + let edges: [MeshTopologyEdge] +} + enum TransportEvent: @unchecked Sendable { case messageReceived(BitchatMessage) case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) case noisePayloadReceived(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) + /// Encrypted group broadcast (MessageType 0x25). Opaque here — the group + /// coordinator decrypts and authenticates against the roster. + case groupMessageReceived(payload: Data, timestamp: Date) case peerConnected(PeerID) case peerDisconnected(PeerID) case peerListUpdated([PeerID]) @@ -124,10 +161,43 @@ protocol Transport: AnyObject { // transport cannot courier (no connected courier, or unsupported). func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool + // Private groups (mesh transports only): creator-signed state travels + // 1:1 over Noise sessions; group messages flood like public broadcasts. + func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) + func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) + func broadcastGroupMessage(_ envelope: Data) + + // Mesh diagnostics (optional for transports). Defaults are inert so + // queue-backed transports (e.g. NostrTransport) stay untouched. + /// Sends a directed ping probe; the completion fires exactly once on the + /// main actor with the measured result, or nil on timeout/unsupported. + func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) + /// Estimated intermediate hops toward `peerID` from gossiped topology + /// ([] = direct link, nil = no known path). + func computeMeshPath(to peerID: PeerID) -> [PeerID]? + /// Current mesh graph for the topology map; nil when unsupported. + func currentMeshTopology() -> MeshTopologySnapshot? + + // Bulletin board (mesh transports only): broadcast a pre-signed board + // payload (post or tombstone) so it spreads over relay and gossip sync. + func sendBoardPayload(_ payload: Data) + // QR verification (optional for transports) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) + // Vouching / transitive verification (optional for transports) + /// Capabilities the peer advertised in its last verified announce; + /// empty for peers that predate the capabilities TLV. + func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities + /// Sends an encoded vouch-attestation batch inside the Noise session. + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) + /// Appends a peer-authenticated observer. Unlike + /// `installNoiseSessionCallbacks` this never touches the (single-slot) + /// handshake-required callback, so secondary features can observe + /// session establishment without disturbing the primary registration. + func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) + // Pending file management (BCH-01-002: files held in memory until user accepts) func acceptPendingFile(id: String) -> URL? func declinePendingFile(id: String) @@ -153,7 +223,22 @@ extension Transport { func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} + func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] } + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {} + func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {} + func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {} + func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {} + func broadcastGroupMessage(_ envelope: Data) {} func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false } + + // Mesh diagnostics are mesh-transport-only; other transports report + // "no reply"/"no path" rather than pretending to measure anything. + func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) { + Task { @MainActor in completion(nil) } + } + func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil } + func currentMeshTopology() -> MeshTopologySnapshot? { nil } + func sendBoardPayload(_ payload: Data) {} func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} func cancelTransfer(_ transferId: String) {} @@ -186,6 +271,8 @@ extension BitchatDelegate { ) case let .noisePayloadReceived(peerID, type, payload, timestamp): didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp) + case let .groupMessageReceived(payload, timestamp): + didReceiveGroupMessage(payload: payload, timestamp: timestamp) case .peerConnected(let peerID): didConnectToPeer(peerID) case .peerDisconnected(let peerID): diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 59d15c55..a33c1e7c 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -16,6 +16,11 @@ enum TransportConfig { static let bleFragmentRelayTtlCap: UInt8 = 7 static let bleFragmentRelayTtlCapDense: UInt8 = 5 // Contain fragment floods in dense graphs + // Mesh diagnostics (/ping) + static let meshPingTimeoutSeconds: TimeInterval = 10 // Give up on a probe after this window + static let meshPingInboundMaxPerLink: Int = 5 // Inbound ping budget per ingress link (claimed sender is spoofable)... + static let meshPingInboundWindowSeconds: TimeInterval = 10 // ...per sliding window (anti-amplification) + // UI / Storage Caps static let privateChatCap: Int = 1337 static let meshTimelineCap: Int = 1337 @@ -208,6 +213,25 @@ enum TransportConfig { static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown + // Source routing (v2 directed packets) + // Longest path we will originate, in intermediate hops between us and the + // recipient. Keep small: every hop must be a fresh, confirmed, v2-capable + // node, and long stale paths fail more often than floods. + static let bleSourceRouteMaxIntermediateHops: Int = 4 + // A routed send with no inbound traffic from the recipient within this + // window counts as a route failure. + static let bleSourceRouteConfirmationWindowSeconds: TimeInterval = 10.0 + // After a route failure, directed sends to that recipient flood instead + // of routing until this lapses. + static let bleSourceRouteSuppressionSeconds: TimeInterval = 60.0 + + // Targeted fragment resync (REQUEST_SYNC fragmentIdFilter) + // A broadcast reassembly with no new fragment for this long is stalled + // and triggers a targeted REQUEST_SYNC naming its fragment stream. + static let bleFragmentResyncStallSeconds: TimeInterval = 5.0 + // Minimum spacing between targeted resync requests for the same stream. + static let bleFragmentResyncRetrySeconds: TimeInterval = 10.0 + // Store-and-forward for directed packets at relays. Spooled packets retry // on each maintenance flush until the window lapses; a longer window lets // brief link gaps (walking between rooms, reconnect churn) heal themselves. @@ -281,6 +305,21 @@ enum TransportConfig { static let syncResponseRateLimitMaxResponses: Int = 8 static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0 + // Wi-Fi bulk transport (peer-to-peer AWDL data plane for large media). + // BLE stays the control plane: offers/responses ride the Noise session, + // only the sealed chunk stream moves to TCP over AWDL. + static let wifiBulkEnabled: Bool = true + // Below this size BLE fragmentation is fast enough that negotiation + // overhead isn't worth it. + static let wifiBulkMinPayloadBytes: Int = 64 * 1024 + static let wifiBulkChunkBytes: Int = 64 * 1024 + // Offer unanswered for this long → fall back to BLE fragmentation. + static let wifiBulkOfferTimeoutSeconds: TimeInterval = 10.0 + // Hard ceiling on how long the Bonjour listener/connection may live. + static let wifiBulkTransferWindowSeconds: TimeInterval = 60.0 + static let wifiBulkServiceType: String = "_bitchat-bulk._tcp" + static let wifiBulkMaxConcurrentIncoming: Int = 4 + // Courier store-and-forward // Initial spray-and-wait budget per deposited envelope: each courier may // hand half its remaining copies to another courier on encounter, so a @@ -289,4 +328,16 @@ enum TransportConfig { // Cooldown between speculative multi-hop handovers of the same envelope // toward a recipient heard only via relayed announces. static let courierRemoteHandoverCooldownSeconds: TimeInterval = 10 * 60 + + // One-time prekey bundles (forward-secret courier sealing) + // Own gossip-sync round for bundles: modest cadence, bounded peer count, + // and a long freshness window so bundles persist mesh-wide while their + // owners are away. + static let syncPrekeyBundleCapacity: Int = 200 + static let syncPrekeyBundleIntervalSeconds: TimeInterval = 60.0 + static let syncPrekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60 + // Unforced re-broadcasts of our own (unchanged) bundle, piggybacked on + // announces, keep it alive in peers' gossip stores; changed bundles are + // sent immediately. + static let prekeyBundleRebroadcastSeconds: TimeInterval = 60 * 60 } diff --git a/bitchat/Services/WifiBulk/WifiBulkChannel.swift b/bitchat/Services/WifiBulk/WifiBulkChannel.swift new file mode 100644 index 00000000..76327538 --- /dev/null +++ b/bitchat/Services/WifiBulk/WifiBulkChannel.swift @@ -0,0 +1,466 @@ +// +// WifiBulkChannel.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitLogger +import CryptoKit +import Foundation +import Network + +/// Shared frame-stream reading over an `NWConnection`. All callbacks fire on +/// the connection's dispatch queue. +enum WifiBulkStream { + /// Largest sealed frame body on the wire: one plaintext chunk plus AEAD overhead. + static func maxFrameBodyBytes(chunkBytes: Int) -> Int { + chunkBytes + WifiBulkCrypto.frameOverhead + } + + /// Reads frames until `onFrame` returns false (stop) or the stream + /// errors/closes. `onFrame` returning true keeps the loop alive. + static func readFrames( + on connection: NWConnection, + buffer: WifiBulkFrameBuffer, + maxFrameBodyBytes: Int, + onFrame: @escaping (Data) -> Bool, + onError: @escaping (String) -> Void + ) { + // Drain any frames already buffered before touching the socket. + do { + while let body = try buffer.nextFrameBody() { + guard onFrame(body) else { return } + } + } catch { + onError("frame decode failed: \(error)") + return + } + + connection.receive( + minimumIncompleteLength: 1, + maximumLength: maxFrameBodyBytes + WifiBulkCrypto.framePrefixLength + ) { data, _, isComplete, error in + if let data, !data.isEmpty { + buffer.append(data) + } + if let error { + onError("receive failed: \(error)") + return + } + if isComplete { + // Peer closed: hand over whatever complete frames remain, then + // report the close (sessions that already got what they need + // will have stopped the loop from inside onFrame). + do { + while let body = try buffer.nextFrameBody() { + guard onFrame(body) else { return } + } + } catch { + onError("frame decode failed: \(error)") + return + } + onError("connection closed by peer") + return + } + readFrames( + on: connection, + buffer: buffer, + maxFrameBodyBytes: maxFrameBodyBytes, + onFrame: onFrame, + onError: onError + ) + } + } +} + +/// Sender side of the bulk channel: publishes the per-transfer Bonjour +/// listener, requires the first inbound frame to prove knowledge of the +/// Noise-exchanged channel key, then streams sealed chunks and waits for the +/// receiver's verified receipt. +/// +/// The listener starts at offer time (Bonjour registration takes a moment) +/// but data can only flow after `activate(key:)` supplies the channel key +/// derived from the accepted response. +final class WifiBulkSenderSession { + private let queue: DispatchQueue + private let payload: Data + private let transferID: Data + private let payloadHash: Data + private let chunkBytes: Int + private let parameters: NWParameters + private let service: NWListener.Service? + private let maxCandidateConnections = 4 + + private var key: SymmetricKey? + private var listener: NWListener? + /// Connections that have not yet produced a valid auth frame. + private var candidates: [NWConnection] = [] + private var authenticated: NWConnection? + private var finished = false + + let totalChunks: Int + + /// Test hook: fires once the listener is ready, with its bound port. + var onListenerReady: ((UInt16) -> Void)? + var onChunkSent: ((_ sent: Int, _ total: Int) -> Void)? + var onCompleted: (() -> Void)? + var onFailed: ((String) -> Void)? + + init( + payload: Data, + transferID: Data, + chunkBytes: Int, + parameters: NWParameters, + service: NWListener.Service?, + queue: DispatchQueue + ) { + self.payload = payload + self.transferID = transferID + self.payloadHash = Data(SHA256.hash(data: payload)) + self.chunkBytes = chunkBytes + self.parameters = parameters + self.service = service + self.queue = queue + self.totalChunks = (payload.count + chunkBytes - 1) / chunkBytes + } + + deinit { + cancelNetworkResources() + } + + /// Starts the listener. Returns false when the listener cannot be created + /// (caller falls back to BLE immediately). + func start() -> Bool { + let listener: NWListener + do { + listener = try NWListener(using: parameters) + } catch { + SecureLogger.error("[WIFI] listener creation failed: \(error)", category: .transport) + return false + } + listener.service = service + listener.stateUpdateHandler = { [weak self] state in + guard let self else { return } + switch state { + case .ready: + if let port = listener.port?.rawValue { + self.onListenerReady?(port) + } + case .failed(let error): + self.fail("listener failed: \(error)") + default: + break + } + } + listener.newConnectionHandler = { [weak self] connection in + self?.acceptCandidate(connection) + } + self.listener = listener + listener.start(queue: queue) + return true + } + + /// Supplies the channel key once the receiver accepted the offer; begins + /// authenticating any connections that raced ahead of the response. + func activate(key: SymmetricKey) { + guard !finished, self.key == nil else { return } + self.key = key + for candidate in candidates { + beginAuthentication(on: candidate, key: key) + } + } + + func cancel() { + finished = true + cancelNetworkResources() + } + + // MARK: - Connection handling + + private func acceptCandidate(_ connection: NWConnection) { + guard !finished, authenticated == nil, candidates.count < maxCandidateConnections else { + connection.cancel() + return + } + candidates.append(connection) + connection.stateUpdateHandler = { [weak self, weak connection] state in + guard let self, let connection else { return } + if case .failed = state { + self.dropCandidate(connection) + } + } + connection.start(queue: queue) + if let key { + beginAuthentication(on: connection, key: key) + } + } + + private func dropCandidate(_ connection: NWConnection) { + if let index = candidates.firstIndex(where: { $0 === connection }) { + candidates.remove(at: index) + connection.cancel() + } + } + + private func beginAuthentication(on connection: NWConnection, key: SymmetricKey) { + let buffer = WifiBulkFrameBuffer(maxBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes)) + WifiBulkStream.readFrames( + on: connection, + buffer: buffer, + maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes), + onFrame: { [weak self, weak connection] body in + guard let self, let connection, !self.finished, self.authenticated == nil else { return false } + guard WifiBulkCrypto.validateClientAuthFrameBody(body, transferID: self.transferID, key: key) else { + // Bonjour-level gatecrasher: no channel key, no service. + SecureLogger.warning("[WIFI] AWDL connect rejected (invalid auth frame)", category: .security) + self.dropCandidate(connection) + return false + } + self.promoteAuthenticated(connection, key: key, residualBuffer: buffer) + return false + }, + onError: { [weak self, weak connection] _ in + guard let self, let connection, self.authenticated !== connection else { return } + self.dropCandidate(connection) + } + ) + } + + private func promoteAuthenticated(_ connection: NWConnection, key: SymmetricKey, residualBuffer: WifiBulkFrameBuffer) { + authenticated = connection + // One authenticated peer is all a transfer needs: stop advertising and + // shed the other candidates. + listener?.cancel() + listener = nil + for candidate in candidates where candidate !== connection { + candidate.cancel() + } + candidates.removeAll() + SecureLogger.info("[WIFI] AWDL connected (sender), streaming \(totalChunks) chunk(s)", category: .transport) + streamChunk(at: 0, over: connection, key: key, receiptBuffer: residualBuffer) + } + + // MARK: - Streaming + + private func streamChunk(at index: Int, over connection: NWConnection, key: SymmetricKey, receiptBuffer: WifiBulkFrameBuffer) { + guard !finished else { return } + guard index < totalChunks else { + awaitReceipt(on: connection, key: key, buffer: receiptBuffer) + return + } + + let start = payload.index(payload.startIndex, offsetBy: index * chunkBytes) + let end = payload.index(start, offsetBy: min(chunkBytes, payload.distance(from: start, to: payload.endIndex))) + let chunk = Data(payload[start.. Void)? + var onFailed: ((String) -> Void)? + + /// Fails (returns nil) when the offer exceeds `sizeCap` — the receiver + /// enforces the cap it advertised, not the sender's word. + init?( + endpoint: NWEndpoint, + parameters: NWParameters, + key: SymmetricKey, + transferID: Data, + expectedSize: UInt64, + expectedHash: Data, + sizeCap: Int, + chunkBytes: Int, + queue: DispatchQueue + ) { + guard let assembler = WifiBulkPayloadAssembler( + key: key, + expectedSize: expectedSize, + expectedHash: expectedHash, + sizeCap: sizeCap + ) else { + return nil + } + self.assembler = assembler + self.connection = NWConnection(to: endpoint, using: parameters) + self.key = key + self.transferID = transferID + self.payloadHash = expectedHash + self.chunkBytes = chunkBytes + self.queue = queue + } + + deinit { + connection.cancel() + } + + func start() { + connection.stateUpdateHandler = { [weak self] state in + guard let self else { return } + switch state { + case .ready: + SecureLogger.info("[WIFI] AWDL connect established (receiver)", category: .transport) + self.sendAuthFrameAndReceive() + case .failed(let error): + SecureLogger.info("[WIFI] AWDL connect failed: \(error)", category: .transport) + self.fail("connect failed: \(error)") + case .waiting(let error): + // .waiting can resolve on its own, but a per-transfer channel + // has a peer actively listening; treat unreachable as fatal so + // the sender's fallback isn't left to the window timeout alone. + self.fail("connection waiting: \(error)") + default: + break + } + } + connection.start(queue: queue) + } + + func cancel() { + finished = true + connection.cancel() + } + + private func sendAuthFrameAndReceive() { + guard !finished else { return } + let authBody: Data + do { + authBody = try WifiBulkCrypto.makeClientAuthFrameBody(transferID: transferID, key: key) + } catch { + fail("auth frame seal failed: \(error)") + return + } + connection.send(content: WifiBulkCrypto.frameData(body: authBody), completion: .contentProcessed { [weak self] error in + guard let self, !self.finished else { return } + if let error { + self.fail("auth frame send failed: \(error)") + return + } + self.receiveChunks() + }) + } + + private func receiveChunks() { + let buffer = WifiBulkFrameBuffer(maxBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes)) + WifiBulkStream.readFrames( + on: connection, + buffer: buffer, + maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes), + onFrame: { [weak self] body in + guard let self, !self.finished else { return false } + do { + guard let payload = try self.assembler.consume(frameBody: body) else { + return true // keep reading + } + self.sendReceiptAndComplete(payload) + return false + } catch { + self.fail("chunk rejected: \(error)") + return false + } + }, + onError: { [weak self] reason in + self?.fail(reason) + } + ) + } + + private func sendReceiptAndComplete(_ payload: Data) { + let receiptBody: Data + do { + receiptBody = try WifiBulkCrypto.makeReceiptFrameBody(payloadHash: payloadHash, key: key) + } catch { + fail("receipt seal failed: \(error)") + return + } + connection.send(content: WifiBulkCrypto.frameData(body: receiptBody), completion: .contentProcessed { [weak self] _ in + // Receipt is best-effort from the receiver's perspective: the + // payload is already verified. Close the channel either way. + guard let self, !self.finished else { return } + self.finished = true + self.connection.cancel() + self.onCompleted?(payload) + }) + } + + private func fail(_ reason: String) { + guard !finished else { return } + finished = true + connection.cancel() + onFailed?(reason) + } +} diff --git a/bitchat/Services/WifiBulk/WifiBulkCrypto.swift b/bitchat/Services/WifiBulk/WifiBulkCrypto.swift new file mode 100644 index 00000000..6db02f3b --- /dev/null +++ b/bitchat/Services/WifiBulk/WifiBulkCrypto.swift @@ -0,0 +1,215 @@ +// +// WifiBulkCrypto.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import CryptoKit +import Foundation + +/// Channel security for the Wi-Fi bulk data plane. +/// +/// The TCP stream is encrypted and authenticated independently of TLS: both +/// endpoints exchanged random 32-byte tokens inside the established Noise +/// session, so only they can derive the ChaChaPoly channel key via +/// HKDF-SHA256 (domain "bitchat-bulk-v1", transferID as salt). A Bonjour-level +/// gatecrasher that connects to the listener cannot produce a single valid +/// frame and is disconnected. +/// +/// Stream format: length-prefixed frames, each a ChaChaPoly sealed box in +/// combined form (12-byte nonce ‖ ciphertext ‖ 16-byte tag). Nonces are +/// structured, never random: [direction byte][3 zero bytes][8-byte BE counter], +/// and the reader requires the exact expected nonce for each frame, so frames +/// cannot be replayed, reordered, or reflected across directions. +enum WifiBulkCryptoError: Error, Equatable { + case invalidParameters + case frameTooLarge + case truncatedFrame + case nonceMismatch + case authenticationFailed + case emptyChunk + case payloadOverflow + case hashMismatch +} + +enum WifiBulkFrameDirection: UInt8 { + /// Data chunks: counters 0, 1, 2, … + case senderToReceiver = 0x00 + /// Counter 0 = client auth frame, counter 1 = final receipt. + case receiverToSender = 0x01 +} + +enum WifiBulkCrypto { + static let keyDomain = "bitchat-bulk-v1" + static let nonceLength = 12 + static let tagLength = 16 + /// AEAD overhead per frame body (nonce + tag). + static let frameOverhead = nonceLength + tagLength + /// 4-byte big-endian length prefix per frame. + static let framePrefixLength = 4 + + // MARK: Key derivation + + /// Derives the ChaChaPoly channel key from the two Noise-exchanged tokens. + /// Deterministic: same tokens + transferID always yield the same key. + static func deriveKey(senderToken: Data, receiverToken: Data, transferID: Data) -> SymmetricKey? { + guard senderToken.count == WifiBulkWire.tokenLength, + receiverToken.count == WifiBulkWire.tokenLength, + transferID.count == WifiBulkWire.transferIDLength else { + return nil + } + var inputKeyMaterial = Data() + inputKeyMaterial.append(senderToken) + inputKeyMaterial.append(receiverToken) + return HKDF.deriveKey( + inputKeyMaterial: SymmetricKey(data: inputKeyMaterial), + salt: transferID, + info: Data(keyDomain.utf8), + outputByteCount: 32 + ) + } + + // MARK: Frame sealing + + static func nonceData(direction: WifiBulkFrameDirection, counter: UInt64) -> Data { + var nonce = Data(count: nonceLength) + nonce[0] = direction.rawValue + var counterBE = counter.bigEndian + withUnsafeBytes(of: &counterBE) { nonce.replaceSubrange(4.. Data { + let nonce = try ChaChaPoly.Nonce(data: nonceData(direction: direction, counter: counter)) + return try ChaChaPoly.seal(plaintext, using: key, nonce: nonce).combined + } + + /// Opens one frame body, enforcing the exact expected nonce. + static func openFrameBody( + _ body: Data, + direction: WifiBulkFrameDirection, + counter: UInt64, + key: SymmetricKey + ) throws -> Data { + guard body.count >= frameOverhead else { throw WifiBulkCryptoError.truncatedFrame } + guard body.prefix(nonceLength) == nonceData(direction: direction, counter: counter) else { + throw WifiBulkCryptoError.nonceMismatch + } + do { + let box = try ChaChaPoly.SealedBox(combined: body) + return try ChaChaPoly.open(box, using: key) + } catch { + throw WifiBulkCryptoError.authenticationFailed + } + } + + /// Prefixes a frame body with its 4-byte big-endian length for the wire. + static func frameData(body: Data) -> Data { + var framed = Data(capacity: framePrefixLength + body.count) + var lengthBE = UInt32(body.count).bigEndian + withUnsafeBytes(of: &lengthBE) { framed.append(contentsOf: $0) } + framed.append(body) + return framed + } + + // MARK: Control frames + + /// First frame on the wire, receiver → sender: proves the connecting + /// client holds the Noise-exchanged secret before any data flows. + static func makeClientAuthFrameBody(transferID: Data, key: SymmetricKey) throws -> Data { + try sealFrameBody(transferID, direction: .receiverToSender, counter: 0, key: key) + } + + static func validateClientAuthFrameBody(_ body: Data, transferID: Data, key: SymmetricKey) -> Bool { + (try? openFrameBody(body, direction: .receiverToSender, counter: 0, key: key)) == transferID + } + + /// Final frame, receiver → sender: acknowledges the fully verified payload. + static func makeReceiptFrameBody(payloadHash: Data, key: SymmetricKey) throws -> Data { + try sealFrameBody(payloadHash, direction: .receiverToSender, counter: 1, key: key) + } + + static func validateReceiptFrameBody(_ body: Data, payloadHash: Data, key: SymmetricKey) -> Bool { + (try? openFrameBody(body, direction: .receiverToSender, counter: 1, key: key)) == payloadHash + } +} + +/// Incremental length-prefix parser for the frame stream. Bounded: bodies +/// larger than `maxBodyBytes` throw instead of buffering unboundedly. +final class WifiBulkFrameBuffer { + private var buffer = Data() + private let maxBodyBytes: Int + + init(maxBodyBytes: Int) { + self.maxBodyBytes = maxBodyBytes + } + + func append(_ data: Data) { + buffer.append(data) + } + + /// Extracts the next complete frame body, or nil when more bytes are needed. + func nextFrameBody() throws -> Data? { + guard buffer.count >= WifiBulkCrypto.framePrefixLength else { return nil } + let length = buffer.prefix(WifiBulkCrypto.framePrefixLength).reduce(Int(0)) { ($0 << 8) | Int($1) } + guard length <= maxBodyBytes else { throw WifiBulkCryptoError.frameTooLarge } + guard buffer.count >= WifiBulkCrypto.framePrefixLength + length else { return nil } + let body = Data(buffer.dropFirst(WifiBulkCrypto.framePrefixLength).prefix(length)) + buffer.removeFirst(WifiBulkCrypto.framePrefixLength + length) + return body + } +} + +/// Receiver-side reassembly: opens sequential data frames, enforces the size +/// negotiated in the accepted offer, and verifies the final SHA-256. +final class WifiBulkPayloadAssembler { + private let key: SymmetricKey + private let expectedSize: Int + private let expectedHash: Data + private var received = Data() + private var counter: UInt64 = 0 + + /// Fails when the offer exceeds the receiver-enforced cap. + init?(key: SymmetricKey, expectedSize: UInt64, expectedHash: Data, sizeCap: Int) { + guard expectedSize > 0, + expectedSize <= UInt64(sizeCap), + expectedHash.count == WifiBulkWire.hashLength else { + return nil + } + self.key = key + self.expectedSize = Int(expectedSize) + self.expectedHash = expectedHash + } + + var isComplete: Bool { received.count == expectedSize } + + /// Consumes one sealed data frame body. Returns the verified payload when + /// the final byte arrives; throws on tampering, overflow, or hash mismatch. + func consume(frameBody: Data) throws -> Data? { + let chunk = try WifiBulkCrypto.openFrameBody( + frameBody, + direction: .senderToReceiver, + counter: counter, + key: key + ) + guard !chunk.isEmpty else { throw WifiBulkCryptoError.emptyChunk } + counter += 1 + guard received.count + chunk.count <= expectedSize else { + throw WifiBulkCryptoError.payloadOverflow + } + received.append(chunk) + guard isComplete else { return nil } + guard Data(SHA256.hash(data: received)) == expectedHash else { + throw WifiBulkCryptoError.hashMismatch + } + return received + } +} diff --git a/bitchat/Services/WifiBulk/WifiBulkMessages.swift b/bitchat/Services/WifiBulk/WifiBulkMessages.swift new file mode 100644 index 00000000..8a718a8c --- /dev/null +++ b/bitchat/Services/WifiBulk/WifiBulkMessages.swift @@ -0,0 +1,191 @@ +// +// WifiBulkMessages.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// TLV payloads for negotiating a Wi-Fi bulk transfer inside an established +/// Noise session (`NoisePayloadType.bulkTransferOffer` / `.bulkTransferResponse`). +/// +/// Both messages ride the encrypted Noise channel, so every field — including +/// the session tokens and the random Bonjour instance name — is only visible +/// to the two endpoints. TLV format matches `BitchatFilePacket`: 1-byte type, +/// 2-byte big-endian length, value. Unknown TLVs are skipped for forward +/// compatibility. +enum WifiBulkWire { + static let transferIDLength = 16 + static let tokenLength = 32 + static let hashLength = 32 + /// Bonjour instance names are capped at 63 UTF-8 bytes. + static let maxServiceNameBytes = 63 + + static func appendTLV(_ type: UInt8, value: Data, into data: inout Data) { + data.append(type) + var length = UInt16(value.count).bigEndian + withUnsafeBytes(of: &length) { data.append(contentsOf: $0) } + data.append(value) + } + + /// Iterates well-formed TLVs, handing each (type, value) to `visit`. + /// Returns false when the buffer is structurally malformed. + static func parseTLVs(_ data: Data, visit: (UInt8, Data) -> Void) -> Bool { + var cursor = data.startIndex + let end = data.endIndex + while cursor < end { + let type = data[cursor] + cursor = data.index(after: cursor) + guard data.distance(from: cursor, to: end) >= 2 else { return false } + let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)]) + cursor = data.index(cursor, offsetBy: 2) + guard data.distance(from: cursor, to: end) >= length else { return false } + let valueEnd = data.index(cursor, offsetBy: length) + visit(type, Data(data[cursor.. Data? { + guard transferID.count == WifiBulkWire.transferIDLength, + payloadHash.count == WifiBulkWire.hashLength, + token.count == WifiBulkWire.tokenLength else { return nil } + let nameData = Data(serviceName.utf8) + guard !nameData.isEmpty, nameData.count <= WifiBulkWire.maxServiceNameBytes else { return nil } + + var encoded = Data() + WifiBulkWire.appendTLV(TLVType.transferID.rawValue, value: transferID, into: &encoded) + var sizeBE = fileSize.bigEndian + WifiBulkWire.appendTLV(TLVType.fileSize.rawValue, value: withUnsafeBytes(of: &sizeBE) { Data($0) }, into: &encoded) + WifiBulkWire.appendTLV(TLVType.payloadHash.rawValue, value: payloadHash, into: &encoded) + WifiBulkWire.appendTLV(TLVType.token.rawValue, value: token, into: &encoded) + WifiBulkWire.appendTLV(TLVType.serviceName.rawValue, value: nameData, into: &encoded) + return encoded + } + + static func decode(_ data: Data) -> WifiBulkOffer? { + var transferID: Data? + var fileSize: UInt64? + var payloadHash: Data? + var token: Data? + var serviceName: String? + + let wellFormed = WifiBulkWire.parseTLVs(data) { type, value in + switch TLVType(rawValue: type) { + case .transferID where value.count == WifiBulkWire.transferIDLength: + transferID = value + case .fileSize where value.count == 8: + fileSize = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) } + case .payloadHash where value.count == WifiBulkWire.hashLength: + payloadHash = value + case .token where value.count == WifiBulkWire.tokenLength: + token = value + case .serviceName where !value.isEmpty && value.count <= WifiBulkWire.maxServiceNameBytes: + serviceName = String(data: value, encoding: .utf8) + default: + break // Unknown or malformed field: ignore; required checks below. + } + } + guard wellFormed, + let transferID, let fileSize, let payloadHash, let token, let serviceName else { + return nil + } + return WifiBulkOffer( + transferID: transferID, + fileSize: fileSize, + payloadHash: payloadHash, + token: token, + serviceName: serviceName + ) + } +} + +/// Receiver → sender: accept (with the receiver's token half) or decline. +struct WifiBulkResponse: Equatable { + let transferID: Data + let accepted: Bool + /// Receiver's random half of the channel secret; present iff accepted. + let token: Data? + + private enum TLVType: UInt8 { + case transferID = 0x01 + case accepted = 0x02 + case token = 0x03 + } + + static func accept(transferID: Data, token: Data) -> WifiBulkResponse { + WifiBulkResponse(transferID: transferID, accepted: true, token: token) + } + + static func decline(transferID: Data) -> WifiBulkResponse { + WifiBulkResponse(transferID: transferID, accepted: false, token: nil) + } + + func encode() -> Data? { + guard transferID.count == WifiBulkWire.transferIDLength else { return nil } + if accepted { + guard token?.count == WifiBulkWire.tokenLength else { return nil } + } + + var encoded = Data() + WifiBulkWire.appendTLV(TLVType.transferID.rawValue, value: transferID, into: &encoded) + WifiBulkWire.appendTLV(TLVType.accepted.rawValue, value: Data([accepted ? 1 : 0]), into: &encoded) + if accepted, let token { + WifiBulkWire.appendTLV(TLVType.token.rawValue, value: token, into: &encoded) + } + return encoded + } + + static func decode(_ data: Data) -> WifiBulkResponse? { + var transferID: Data? + var accepted: Bool? + var token: Data? + + let wellFormed = WifiBulkWire.parseTLVs(data) { type, value in + switch TLVType(rawValue: type) { + case .transferID where value.count == WifiBulkWire.transferIDLength: + transferID = value + case .accepted where value.count == 1: + accepted = value.first == 1 + case .token where value.count == WifiBulkWire.tokenLength: + token = value + default: + break + } + } + guard wellFormed, let transferID, let accepted else { return nil } + if accepted { + guard let token else { return nil } + return WifiBulkResponse(transferID: transferID, accepted: true, token: token) + } + return WifiBulkResponse(transferID: transferID, accepted: false, token: nil) + } +} diff --git a/bitchat/Services/WifiBulk/WifiBulkPolicy.swift b/bitchat/Services/WifiBulk/WifiBulkPolicy.swift new file mode 100644 index 00000000..8edddee3 --- /dev/null +++ b/bitchat/Services/WifiBulk/WifiBulkPolicy.swift @@ -0,0 +1,57 @@ +// +// WifiBulkPolicy.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation + +/// Pure eligibility decisions for the Wi-Fi bulk data plane. Anything that +/// fails these gates rides BLE fragmentation exactly as before — the BLE +/// fallback is the common case and must stay bulletproof. +enum WifiBulkPolicy { + struct SendCandidate { + let payloadBytes: Int + let peerCapabilities: PeerCapabilities + /// Direct BLE link (1 hop). Multi-hop recipients stay on BLE: AWDL + /// only reaches direct neighbors, and relays can't proxy the channel. + let isDirectlyConnected: Bool + /// The offer rides the Noise session, so one must already exist. + let hasEstablishedNoiseSession: Bool + } + + static func shouldOffer( + _ candidate: SendCandidate, + enabled: Bool = TransportConfig.wifiBulkEnabled, + minPayloadBytes: Int = TransportConfig.wifiBulkMinPayloadBytes, + maxPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes + ) -> Bool { + enabled + && candidate.payloadBytes > minPayloadBytes + && candidate.payloadBytes <= maxPayloadBytes + && candidate.peerCapabilities.contains(.wifiBulk) + && candidate.isDirectlyConnected + && candidate.hasEstablishedNoiseSession + } + + /// Receiver-side gate. Field lengths were validated at decode; this + /// enforces the size cap (from the local ceiling, not the sender's word) + /// and local enablement. + static func shouldAccept( + offer: WifiBulkOffer, + senderIsDirectlyConnected: Bool, + activeIncomingTransfers: Int, + enabled: Bool = TransportConfig.wifiBulkEnabled, + maxPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes, + maxConcurrentIncoming: Int = TransportConfig.wifiBulkMaxConcurrentIncoming + ) -> Bool { + enabled + && senderIsDirectlyConnected + && activeIncomingTransfers < maxConcurrentIncoming + && offer.fileSize > 0 + && offer.fileSize <= UInt64(maxPayloadBytes) + } +} diff --git a/bitchat/Services/WifiBulk/WifiBulkTransferService.swift b/bitchat/Services/WifiBulk/WifiBulkTransferService.swift new file mode 100644 index 00000000..d71bad60 --- /dev/null +++ b/bitchat/Services/WifiBulk/WifiBulkTransferService.swift @@ -0,0 +1,479 @@ +// +// WifiBulkTransferService.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import CryptoKit +import Foundation +import Network + +/// Narrow environment for `WifiBulkTransferService`. All BLE-service queue +/// hops live inside the closures supplied by `BLEService`, keeping this +/// service independently testable. +struct WifiBulkTransferServiceEnvironment { + /// Sends a typed payload inside the established Noise session with the + /// peer. Returns false when no established session exists (the caller + /// falls back to BLE). + let sendNoisePayload: (_ typedPayload: Data, _ peerID: PeerID) -> Bool + /// Whether the peer is on a direct BLE link right now. + let isPeerConnected: (PeerID) -> Bool + /// Delivers a fully received, hash-verified payload (encoded + /// `BitchatFilePacket` TLV) into the normal incoming-file pipeline. + let deliverReceivedFile: (_ payload: Data, _ peerID: PeerID, _ payloadLimit: Int) -> Void + /// Progress bus hooks mirroring the BLE fragmentation path so the UI is + /// unchanged (chunks report as "fragments"). + let progressStart: (_ transferId: String, _ totalChunks: Int) -> Void + let progressChunkSent: (_ transferId: String) -> Void + /// Silently forgets progress state ahead of a BLE fallback re-start. + let progressReset: (_ transferId: String) -> Void + /// Emits the cancelled event for user-cancelled transfers. + let progressCancel: (_ transferId: String) -> Void +} + +/// Knobs with test overrides; production values come from `TransportConfig`. +struct WifiBulkTransferServiceConfig { + var serviceType: String = TransportConfig.wifiBulkServiceType + var chunkBytes: Int = TransportConfig.wifiBulkChunkBytes + var offerTimeout: TimeInterval = TransportConfig.wifiBulkOfferTimeoutSeconds + var transferWindow: TimeInterval = TransportConfig.wifiBulkTransferWindowSeconds + var maxIncomingPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes + var maxConcurrentIncoming: Int = TransportConfig.wifiBulkMaxConcurrentIncoming + /// Tests disable peer-to-peer so loopback interfaces stay usable. + var usePeerToPeer: Bool = true + /// Tests disable Bonjour publication (unit-test hosts may lack mDNS access). + var publishBonjourService: Bool = true +} + +/// Orchestrates the Wi-Fi bulk data plane: BLE/Noise carries the offer and +/// response (control plane), then the payload crosses a per-transfer TCP +/// channel over AWDL, sealed with a key both sides derived from the +/// Noise-exchanged tokens. Any failure at any stage falls back to BLE +/// fragmentation exactly once; the receiver side fails silently and lets the +/// sender's timeout drive that fallback. +final class WifiBulkTransferService { + private let queue = DispatchQueue(label: "com.bitchat.wifi-bulk", qos: .userInitiated) + private let environment: WifiBulkTransferServiceEnvironment + private let config: WifiBulkTransferServiceConfig + + private final class OutgoingTransfer { + let transferID: Data + let transferId: String + let peerID: PeerID + let token: Data + let fallback: () -> Void + var session: WifiBulkSenderSession? + var offerTimeout: DispatchWorkItem? + var windowTimeout: DispatchWorkItem? + var accepted = false + var finished = false + + init(transferID: Data, transferId: String, peerID: PeerID, token: Data, fallback: @escaping () -> Void) { + self.transferID = transferID + self.transferId = transferId + self.peerID = peerID + self.token = token + self.fallback = fallback + } + } + + private final class IncomingTransfer { + let offer: WifiBulkOffer + let peerID: PeerID + let key: SymmetricKey + var browser: NWBrowser? + var session: WifiBulkReceiverSession? + var windowTimeout: DispatchWorkItem? + + init(offer: WifiBulkOffer, peerID: PeerID, key: SymmetricKey) { + self.offer = offer + self.peerID = peerID + self.key = key + } + } + + private var outgoing: [Data: OutgoingTransfer] = [:] + private var incoming: [Data: IncomingTransfer] = [:] + + init( + environment: WifiBulkTransferServiceEnvironment, + config: WifiBulkTransferServiceConfig = WifiBulkTransferServiceConfig() + ) { + self.environment = environment + self.config = config + } + + // MARK: - Sender + + /// Offers `payload` over the Wi-Fi bulk channel. `fallbackToBLE` runs at + /// most once, on decline, timeout, or any mid-transfer error. + func sendFile(payload: Data, to peerID: PeerID, transferId: String, fallbackToBLE: @escaping () -> Void) { + queue.async { [weak self] in + self?.beginOutgoing(payload: payload, peerID: peerID, transferId: transferId, fallbackToBLE: fallbackToBLE) + } + } + + /// Handles a decrypted `bulkTransferResponse` Noise payload. + func handleResponsePayload(_ payload: Data, from peerID: PeerID) { + queue.async { [weak self] in + self?.processResponse(payload, from: peerID) + } + } + + /// User-initiated cancel from the UI (mirrors BLE `cancelTransfer`). + func cancelTransfer(transferId: String) { + queue.async { [weak self] in + guard let self, + let transfer = self.outgoing.values.first(where: { $0.transferId == transferId }) else { return } + self.finishOutgoing(transfer, outcome: .cancelled, reason: "cancelled by user") + } + } + + /// Tears down every transfer (service shutdown / emergency disconnect). + /// In-flight outgoing transfers do NOT fall back — the transport is going away. + func stop() { + queue.async { [weak self] in + guard let self else { return } + for transfer in self.outgoing.values { + transfer.finished = true + transfer.offerTimeout?.cancel() + transfer.windowTimeout?.cancel() + transfer.session?.cancel() + } + self.outgoing.removeAll() + for transfer in self.incoming.values { + self.tearDownIncomingResources(transfer) + } + self.incoming.removeAll() + } + } + + private enum OutgoingOutcome { + case completed + case fallback + case cancelled + } + + private func beginOutgoing(payload: Data, peerID: PeerID, transferId: String, fallbackToBLE: @escaping () -> Void) { + let transferID = Self.randomData(WifiBulkWire.transferIDLength) + let token = Self.randomData(WifiBulkWire.tokenLength) + // Random per-transfer instance name — never the nickname or peer ID. + let serviceName = Self.randomData(16).hexEncodedString() + + let offer = WifiBulkOffer( + transferID: transferID, + fileSize: UInt64(payload.count), + payloadHash: Data(SHA256.hash(data: payload)), + token: token, + serviceName: serviceName + ) + guard let offerData = offer.encode() else { + SecureLogger.info("[WIFI] fallback→BLE to \(peerID.id.prefix(8))… (offer encode failed)", category: .transport) + fallbackToBLE() + return + } + + let transfer = OutgoingTransfer( + transferID: transferID, + transferId: transferId, + peerID: peerID, + token: token, + fallback: fallbackToBLE + ) + + let session = WifiBulkSenderSession( + payload: payload, + transferID: transferID, + chunkBytes: config.chunkBytes, + parameters: makeParameters(), + service: config.publishBonjourService + ? NWListener.Service(name: serviceName, type: config.serviceType) + : nil, + queue: queue + ) + if let onListenerReady = _test_onListenerReady { + session.onListenerReady = { port in onListenerReady(transferID, port) } + } + session.onChunkSent = { [weak self, weak transfer] sent, total in + guard let self, let transfer, !transfer.finished else { return } + // Hold the final tick until the receipt confirms delivery, so the + // progress bus only emits .completed for verified transfers. + if sent < total { + self.environment.progressChunkSent(transfer.transferId) + } + } + session.onCompleted = { [weak self, weak transfer] in + guard let self, let transfer, !transfer.finished else { return } + self.environment.progressChunkSent(transfer.transferId) + self.finishOutgoing(transfer, outcome: .completed, reason: "receipt verified") + } + session.onFailed = { [weak self, weak transfer] reason in + guard let self, let transfer else { return } + self.finishOutgoing(transfer, outcome: .fallback, reason: reason) + } + transfer.session = session + outgoing[transferID] = transfer + + guard session.start() else { + finishOutgoing(transfer, outcome: .fallback, reason: "listener unavailable") + return + } + guard environment.sendNoisePayload( + BLENoisePayloadFactory.typedPayload(.bulkTransferOffer, payload: offerData), + peerID + ) else { + finishOutgoing(transfer, outcome: .fallback, reason: "no established noise session") + return + } + + SecureLogger.info("[WIFI] offer sent \(payload.count) bytes to \(peerID.id.prefix(8))… over \(serviceName.prefix(8))…", category: .transport) + environment.progressStart(transferId, session.totalChunks) + + let offerTimeout = DispatchWorkItem { [weak self, weak transfer] in + guard let self, let transfer, !transfer.accepted else { return } + self.finishOutgoing(transfer, outcome: .fallback, reason: "offer timed out") + } + transfer.offerTimeout = offerTimeout + queue.asyncAfter(deadline: .now() + config.offerTimeout, execute: offerTimeout) + + let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in + guard let self, let transfer else { return } + self.finishOutgoing(transfer, outcome: .fallback, reason: "transfer window expired") + } + transfer.windowTimeout = windowTimeout + queue.asyncAfter(deadline: .now() + config.transferWindow, execute: windowTimeout) + } + + private func processResponse(_ payload: Data, from peerID: PeerID) { + guard let response = WifiBulkResponse.decode(payload), + let transfer = outgoing[response.transferID], + transfer.peerID.toShort() == peerID.toShort(), + !transfer.accepted, !transfer.finished else { + return + } + + guard response.accepted, let receiverToken = response.token else { + finishOutgoing(transfer, outcome: .fallback, reason: "offer declined") + return + } + guard let key = WifiBulkCrypto.deriveKey( + senderToken: transfer.token, + receiverToken: receiverToken, + transferID: transfer.transferID + ) else { + finishOutgoing(transfer, outcome: .fallback, reason: "key derivation failed") + return + } + + transfer.accepted = true + transfer.offerTimeout?.cancel() + transfer.offerTimeout = nil + SecureLogger.info("[WIFI] offer accepted by \(peerID.id.prefix(8))…, activating channel", category: .transport) + transfer.session?.activate(key: key) + } + + private func finishOutgoing(_ transfer: OutgoingTransfer, outcome: OutgoingOutcome, reason: String) { + guard !transfer.finished else { return } + transfer.finished = true + transfer.offerTimeout?.cancel() + transfer.windowTimeout?.cancel() + transfer.session?.cancel() + outgoing.removeValue(forKey: transfer.transferID) + + switch outcome { + case .completed: + SecureLogger.info("[WIFI] transfer \(transfer.transferId.prefix(8))… complete (\(reason))", category: .transport) + case .fallback: + SecureLogger.info("[WIFI] fallback→BLE \(transfer.transferId.prefix(8))… (\(reason))", category: .transport) + environment.progressReset(transfer.transferId) + transfer.fallback() + case .cancelled: + SecureLogger.info("[WIFI] transfer \(transfer.transferId.prefix(8))… cancelled", category: .transport) + environment.progressCancel(transfer.transferId) + } + } + + // MARK: - Receiver + + /// Handles a decrypted `bulkTransferOffer` Noise payload. + func handleOfferPayload(_ payload: Data, from peerID: PeerID) { + queue.async { [weak self] in + self?.processOffer(payload, from: peerID) + } + } + + private func processOffer(_ payload: Data, from peerID: PeerID) { + guard let offer = WifiBulkOffer.decode(payload) else { return } + guard incoming[offer.transferID] == nil else { return } + + guard WifiBulkPolicy.shouldAccept( + offer: offer, + senderIsDirectlyConnected: environment.isPeerConnected(peerID), + activeIncomingTransfers: incoming.count, + maxPayloadBytes: config.maxIncomingPayloadBytes, + maxConcurrentIncoming: config.maxConcurrentIncoming + ) else { + decline(offer: offer, peerID: peerID) + return + } + + let token = Self.randomData(WifiBulkWire.tokenLength) + guard let key = WifiBulkCrypto.deriveKey( + senderToken: offer.token, + receiverToken: token, + transferID: offer.transferID + ), + let responseData = WifiBulkResponse.accept(transferID: offer.transferID, token: token).encode() else { + decline(offer: offer, peerID: peerID) + return + } + guard environment.sendNoisePayload( + BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData), + peerID + ) else { + return // No session to answer on; the sender's timeout handles fallback. + } + + let transfer = IncomingTransfer(offer: offer, peerID: peerID, key: key) + incoming[offer.transferID] = transfer + SecureLogger.info("[WIFI] offer accept \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .transport) + + startBrowsing(for: transfer) + + let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in + guard let self, let transfer else { return } + SecureLogger.info("[WIFI] incoming window expired", category: .transport) + self.tearDownIncoming(transfer) + } + transfer.windowTimeout = windowTimeout + queue.asyncAfter(deadline: .now() + config.transferWindow, execute: windowTimeout) + } + + private func decline(offer: WifiBulkOffer, peerID: PeerID) { + SecureLogger.info("[WIFI] offer decline \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .transport) + guard let responseData = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return } + _ = environment.sendNoisePayload( + BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData), + peerID + ) + } + + private func startBrowsing(for transfer: IncomingTransfer) { + let browser = NWBrowser( + for: .bonjour(type: config.serviceType, domain: nil), + using: makeParameters() + ) + transfer.browser = browser + browser.browseResultsChangedHandler = { [weak self, weak transfer] results, _ in + guard let self, let transfer, transfer.session == nil else { return } + let match = results.first { result in + if case .service(let name, _, _, _) = result.endpoint { + return name == transfer.offer.serviceName + } + return false + } + guard let match else { return } + self.connect(transfer, to: match.endpoint) + } + browser.stateUpdateHandler = { [weak self, weak transfer] state in + guard let self, let transfer else { return } + if case .failed(let error) = state { + SecureLogger.warning("[WIFI] browser failed: \(error)", category: .transport) + self.tearDownIncoming(transfer) + } + } + browser.start(queue: queue) + } + + /// Test hook: connects an accepted incoming transfer straight to an + /// endpoint, standing in for Bonjour discovery on hosts without mDNS. + func _test_connectIncoming(transferID: Data, to endpoint: NWEndpoint) { + queue.async { [weak self] in + guard let self, let transfer = self.incoming[transferID], transfer.session == nil else { return } + self.connect(transfer, to: endpoint) + } + } + + private func connect(_ transfer: IncomingTransfer, to endpoint: NWEndpoint) { + transfer.browser?.cancel() + transfer.browser = nil + + guard let session = WifiBulkReceiverSession( + endpoint: endpoint, + parameters: makeParameters(), + key: transfer.key, + transferID: transfer.offer.transferID, + expectedSize: transfer.offer.fileSize, + expectedHash: transfer.offer.payloadHash, + sizeCap: config.maxIncomingPayloadBytes, + chunkBytes: config.chunkBytes, + queue: queue + ) else { + tearDownIncoming(transfer) + return + } + session.onCompleted = { [weak self, weak transfer] payload in + guard let self, let transfer else { return } + SecureLogger.info("[WIFI] transfer complete, received \(payload.count) bytes from \(transfer.peerID.id.prefix(8))…", category: .transport) + self.environment.deliverReceivedFile(payload, transfer.peerID, self.config.maxIncomingPayloadBytes) + self.tearDownIncoming(transfer) + } + session.onFailed = { [weak self, weak transfer] reason in + guard let self, let transfer else { return } + SecureLogger.info("[WIFI] incoming failed (\(reason)); sender falls back to BLE", category: .transport) + self.tearDownIncoming(transfer) + } + transfer.session = session + session.start() + } + + private func tearDownIncoming(_ transfer: IncomingTransfer) { + tearDownIncomingResources(transfer) + incoming.removeValue(forKey: transfer.offer.transferID) + } + + private func tearDownIncomingResources(_ transfer: IncomingTransfer) { + transfer.windowTimeout?.cancel() + transfer.windowTimeout = nil + transfer.browser?.cancel() + transfer.browser = nil + transfer.session?.cancel() + transfer.session = nil + } + + // MARK: - Helpers + + private func makeParameters() -> NWParameters { + let parameters = NWParameters.tcp + if config.usePeerToPeer { + parameters.includePeerToPeer = true + // Keep the channel off infrastructure-independent radios we never + // want (cellular/wired); AWDL rides on the peer-to-peer flag. + parameters.prohibitedInterfaceTypes = [.cellular, .wiredEthernet, .loopback] + } + return parameters + } + + /// Cryptographically secure random bytes (Swift's default RNG is CSPRNG-backed). + private static func randomData(_ count: Int) -> Data { + Data((0.. Void)? + + var _test_activeOutgoingCount: Int { + queue.sync { outgoing.count } + } + + var _test_activeIncomingCount: Int { + queue.sync { incoming.count } + } +} diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index f848be7b..78c0ac21 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -73,11 +73,22 @@ final class GossipSyncManager { var stalePeerTimeoutSeconds: TimeInterval = 60.0 var fragmentCapacity: Int = 600 var fileTransferCapacity: Int = 200 + var groupMessageCapacity: Int = 200 var fragmentSyncIntervalSeconds: TimeInterval = 30.0 var fileTransferSyncIntervalSeconds: TimeInterval = 60.0 var messageSyncIntervalSeconds: TimeInterval = 15.0 + // Board posts are few but long-lived (days, until each post's own + // expiry), so they get a slow round with their own capacity instead + // of competing with the 15-minute message window. + var boardCapacity: Int = 200 + var boardSyncIntervalSeconds: TimeInterval = 60.0 var responseRateLimitMaxResponses: Int = 8 var responseRateLimitWindowSeconds: TimeInterval = 30.0 + // Prekey bundles: one per peer, own sync round, long freshness so + // bundles persist mesh-wide while their owners are offline. + var prekeyBundleCapacity: Int = 200 + var prekeyBundleSyncIntervalSeconds: TimeInterval = 60.0 + var prekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60 } private let myPeerID: PeerID @@ -86,11 +97,22 @@ final class GossipSyncManager { private let archive: GossipMessageArchive? weak var delegate: Delegate? + /// Source of raw signed board packets (posts + tombstones). The board + /// store is the single owner of board retention (expiry, tombstones, + /// caps, persistence), so sync rounds query it instead of keeping a + /// second copy here. Must be thread-safe; set before `start()`. + var boardPacketsProvider: (() -> [BitchatPacket])? + // Storage: broadcast packets by type, and latest announce per sender private var messages = PacketStore() private var fragments = PacketStore() private var fileTransfers = PacketStore() + private var groupMessages = PacketStore() private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:] + // Latest verified prekey bundle per owner. Unlike announces, bundles are + // NOT dropped on leave/stale peer: their whole purpose is reaching a + // sender while the owner is away. + private var latestPrekeyBundleByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:] private var archiveDirty = false // Timer @@ -111,7 +133,13 @@ final class GossipSyncManager { ) var schedules: [SyncSchedule] = [] if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 { - schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast)) + // Group messages ride the public-message cadence; old clients + // ignore the extended bit and answer with announces/messages only. + var messageTypes: SyncTypeFlags = .publicMessages + if config.groupMessageCapacity > 0 { + messageTypes.formUnion(.groupMessage) + } + schedules.append(SyncSchedule(types: messageTypes, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast)) } if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 { schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast)) @@ -119,6 +147,12 @@ final class GossipSyncManager { if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 { schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast)) } + if config.prekeyBundleCapacity > 0 && config.prekeyBundleSyncIntervalSeconds > 0 { + schedules.append(SyncSchedule(types: .prekeyBundle, interval: config.prekeyBundleSyncIntervalSeconds, lastSent: .distantPast)) + } + if config.boardCapacity > 0 && config.boardSyncIntervalSeconds > 0 { + schedules.append(SyncSchedule(types: .board, interval: config.boardSyncIntervalSeconds, lastSent: .distantPast)) + } syncSchedules = schedules if archive != nil { @@ -149,12 +183,21 @@ final class GossipSyncManager { guard let self = self else { return } var types: SyncTypeFlags = .publicMessages + if self.config.groupMessageCapacity > 0 { + types.formUnion(.groupMessage) + } if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 { types.formUnion(.fragment) } if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 { types.formUnion(.fileTransfer) } + if self.config.prekeyBundleCapacity > 0 && self.config.prekeyBundleSyncIntervalSeconds > 0 { + types.formUnion(.prekeyBundle) + } + if self.config.boardCapacity > 0 && self.config.boardSyncIntervalSeconds > 0 && self.boardPacketsProvider != nil { + types.formUnion(.board) + } self.sendRequestSync(to: peerID, types: types) } } @@ -169,9 +212,17 @@ final class GossipSyncManager { // messages get the long town-crier window; fragments, file transfers and // announces keep the short one. private func isPacketFresh(_ packet: BitchatPacket) -> Bool { - let maxAgeSeconds = packet.type == MessageType.message.rawValue - ? config.publicMessageMaxAgeSeconds - : config.maxMessageAgeSeconds + let maxAgeSeconds: TimeInterval + switch packet.type { + // Group messages share the whole-message window: members off the mesh + // for a while should backfill their crew's history like public chat. + case MessageType.message.rawValue, MessageType.groupMessage.rawValue: + maxAgeSeconds = config.publicMessageMaxAgeSeconds + case MessageType.prekeyBundle.rawValue: + maxAgeSeconds = config.prekeyBundleMaxAgeSeconds + default: + maxAgeSeconds = config.maxMessageAgeSeconds + } let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) let ageThresholdMs = UInt64(maxAgeSeconds * 1000) @@ -224,6 +275,36 @@ final class GossipSyncManager { guard isPacketFresh(packet) else { return } let idHex = PacketIdUtil.computeId(packet).hexEncodedString() fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity)) + case .prekeyBundle: + // Callers only feed verified bundles here (own bundles at send + // time, peers' after signature verification), so gossip never + // spreads a bundle this node couldn't attribute. + guard isBroadcastRecipient else { return } + guard isPacketFresh(packet) else { return } + // Key by the bundle's authenticated identity (its noise static key), + // NOT the unauthenticated packet senderID. Otherwise one valid + // bundle re-broadcast under many fabricated sender IDs would create + // one cache entry each and exhaust the per-owner cap, starving + // legitimate bundles. One owner ⇒ at most one entry. + guard let bundle = PrekeyBundle.decode(packet.payload) else { return } + let owner = PeerID(publicKey: bundle.noiseStaticPublicKey) + if let existing = latestPrekeyBundleByPeer[owner], + existing.packet.timestamp >= packet.timestamp { + return + } + // Bounded owner count; replacing a known owner's bundle is always + // allowed so the cap can't block refreshes. + guard latestPrekeyBundleByPeer[owner] != nil + || latestPrekeyBundleByPeer.count < max(1, config.prekeyBundleCapacity) else { return } + let idHex = PacketIdUtil.computeId(packet).hexEncodedString() + latestPrekeyBundleByPeer[owner] = (id: idHex, packet: packet) + case .groupMessage: + // Opaque ciphertext to non-members; carried and served like any + // other broadcast so members get backfill from any relay. + guard isBroadcastRecipient else { return } + guard isPacketFresh(packet) else { return } + let idHex = PacketIdUtil.computeId(packet).hexEncodedString() + groupMessages.insert(idHex: idHex, packet: packet, capacity: max(1, config.groupMessageCapacity)) default: break } @@ -232,7 +313,6 @@ final class GossipSyncManager { private func sendPeriodicSync(for types: SyncTypeFlags) { // Unicast sync to connected peers to allow RSR attribution if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty { - SecureLogger.debug("Sending periodic sync to \(connectedPeers.count) connected peers", category: .sync) for peerID in connectedPeers { sendRequestSync(to: peerID, types: types) } @@ -257,11 +337,29 @@ final class GossipSyncManager { delegate?.sendPacket(signed) } - private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) { + /// Targeted fragment recovery: ask connected peers for the specific + /// fragment streams whose reassembly has stalled, instead of waiting on + /// the next periodic GCS fragment round to cover them. + func requestMissingFragments(fragmentIDs: [Data]) { + queue.async { [weak self] in + self?._requestMissingFragments(fragmentIDs) + } + } + + private func _requestMissingFragments(_ fragmentIDs: [Data]) { + guard let filter = RequestSyncPacket.encodeFragmentIdFilter(fragmentIDs) else { return } + guard let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty else { return } + SecureLogger.info("[ROUTE] targeted-resync \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .mesh) + for peerID in connectedPeers { + sendRequestSync(to: peerID, types: .fragment, fragmentIdFilter: filter) + } + } + + private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags, fragmentIdFilter: String? = nil) { // Register the request for RSR validation requestSyncManager.registerRequest(to: peerID) - - let payload = buildGcsPayload(for: types) + + let payload = buildGcsPayload(for: types, fragmentIdFilter: fragmentIdFilter) var recipient = Data() var temp = peerID.id while temp.count >= 2 && recipient.count < 8 { @@ -292,7 +390,7 @@ final class GossipSyncManager { // A response can replay the whole store, so bound how often one peer // can trigger a diff pass regardless of how fast it asks. guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else { - SecureLogger.warning("Rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .sync) + SecureLogger.warning("[SYNC] rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .sync) return } let requestedTypes = (request.types ?? .publicMessages) @@ -300,6 +398,9 @@ final class GossipSyncManager { // older packets are outside the filter but not missing, and without // the cursor they would be re-sent every round. let since = request.sinceTimestamp + // One concise per-request summary (RSR is already rate-limited per + // peer), never per-packet spam. + var servedCount = 0 // Decode GCS into sorted set and prepare membership checker let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data) func mightContain(_ id: Data) -> Bool { @@ -319,6 +420,7 @@ final class GossipSyncManager { toSend.ttl = 0 toSend.isRSR = true // Mark as solicited response delegate?.sendPacket(to: peerID, packet: toSend) + servedCount += 1 } } } @@ -333,20 +435,32 @@ final class GossipSyncManager { toSend.ttl = 0 toSend.isRSR = true // Mark as solicited response delegate?.sendPacket(to: peerID, packet: toSend) + servedCount += 1 } } } if requestedTypes.contains(.fragment) { + // A fragment-ID filter narrows the diff to exactly the named + // fragment streams (targeted resync for stalled reassemblies) + // and bypasses the since-cursor for them; the GCS filter still + // excludes the pieces the requester already holds. Fragment + // payloads start with the 8-byte stream ID. + let fragmentIdFilter = RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter) let frags = fragments.allPackets(isFresh: isPacketFresh) for pkt in frags { - if let since, pkt.timestamp < since { continue } + if let fragmentIdFilter { + guard fragmentIdFilter.contains(Data(pkt.payload.prefix(8))) else { continue } + } else if let since, pkt.timestamp < since { + continue + } let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt toSend.ttl = 0 toSend.isRSR = true // Mark as solicited response delegate?.sendPacket(to: peerID, packet: toSend) + servedCount += 1 } } } @@ -361,13 +475,69 @@ final class GossipSyncManager { toSend.ttl = 0 toSend.isRSR = true // Mark as solicited response delegate?.sendPacket(to: peerID, packet: toSend) + servedCount += 1 } } } + + // Like announces, prekey bundles are exempt from the since-cursor: + // there is at most one per owner (newer replaces older), so the + // resend cost is bounded and a joining peer must be able to learn + // bundles generated long before it arrived. + if requestedTypes.contains(.prekeyBundle) { + for (_, pair) in latestPrekeyBundleByPeer { + let (idHex, pkt) = pair + guard isPacketFresh(pkt) else { continue } + let idBytes = Data(hexString: idHex) ?? Data() + if !mightContain(idBytes) { + var toSend = pkt + toSend.ttl = 0 + toSend.isRSR = true // Mark as solicited response + delegate?.sendPacket(to: peerID, packet: toSend) + servedCount += 1 + } + } + } + + if requestedTypes.contains(.groupMessage) { + let groupPkts = groupMessages.allPackets(isFresh: isPacketFresh) + for pkt in groupPkts { + if let since, pkt.timestamp < since { continue } + let idBytes = PacketIdUtil.computeId(pkt) + if !mightContain(idBytes) { + var toSend = pkt + toSend.ttl = 0 + toSend.isRSR = true // Mark as solicited response + delegate?.sendPacket(to: peerID, packet: toSend) + servedCount += 1 + } + } + } + + if requestedTypes.contains(.boardPost) { + // The board store already filters to live posts and tombstones; + // no freshness window applies (posts sync until their own expiry). + let boardPackets = boardPacketsProvider?() ?? [] + for pkt in boardPackets { + if let since, pkt.timestamp < since { continue } + let idBytes = PacketIdUtil.computeId(pkt) + if !mightContain(idBytes) { + var toSend = pkt + toSend.ttl = 0 + toSend.isRSR = true // Mark as solicited response + delegate?.sendPacket(to: peerID, packet: toSend) + servedCount += 1 + } + } + } + + if servedCount > 0 { + SecureLogger.info("[SYNC] served \(servedCount) packet(s) [\(requestedTypes.debugShortLabel)] to \(peerID.id.prefix(8))…", category: .sync) + } } // Build REQUEST_SYNC payload using current candidates and GCS params - private func buildGcsPayload(for types: SyncTypeFlags) -> Data { + private func buildGcsPayload(for types: SyncTypeFlags, fragmentIdFilter: String? = nil) -> Data { var candidates: [BitchatPacket] = [] if types.contains(.announce) { for (_, pkt) in latestAnnouncementByPeer where isPacketFresh(pkt) { @@ -383,9 +553,20 @@ final class GossipSyncManager { if types.contains(.fileTransfer) { candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh)) } + if types.contains(.prekeyBundle) { + for (_, pair) in latestPrekeyBundleByPeer where isPacketFresh(pair.packet) { + candidates.append(pair.packet) + } + } + if types.contains(.groupMessage) { + candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh)) + } + if types.contains(.boardPost) { + candidates.append(contentsOf: boardPacketsProvider?() ?? []) + } if candidates.isEmpty { let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr) - let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types) + let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter) return req.encode() } @@ -399,12 +580,16 @@ final class GossipSyncManager { cap = max(1, config.fragmentCapacity) } else if types == .fileTransfer { cap = max(1, config.fileTransferCapacity) + } else if types == .prekeyBundle { + cap = max(1, config.prekeyBundleCapacity) + } else if types == .board { + cap = max(1, config.boardCapacity) } else { cap = max(1, config.seenCapacity) } let takeN = min(candidates.count, min(nMax, cap)) if takeN <= 0 { - let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types) + let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter) return req.encode() } let included = Array(candidates.prefix(takeN)) @@ -422,7 +607,7 @@ final class GossipSyncManager { let sinceTimestamp: UInt64? = (covered < candidates.count && covered > 0) ? included[covered - 1].timestamp : nil - let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types, sinceTimestamp: sinceTimestamp) + let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter) return req.encode() } @@ -440,6 +625,10 @@ final class GossipSyncManager { } fragments.removeExpired(isFresh: isPacketFresh) fileTransfers.removeExpired(isFresh: isPacketFresh) + latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in + isPacketFresh(pair.packet) + } + groupMessages.removeExpired(isFresh: isPacketFresh) } // MARK: - Archive (public message persistence) @@ -488,13 +677,23 @@ final class GossipSyncManager { // One request per due schedule rather than a union filter: each type // group gets the full GCS capacity and its own since-cursor, so heavy // fragment traffic can't crowd messages out of the filter. + var dueLabels: [String] = [] for index in syncSchedules.indices { guard syncSchedules[index].interval > 0 else { continue } + // No board source wired up means nothing to offer or store; + // skip the round entirely. + if syncSchedules[index].types == .board && boardPacketsProvider == nil { continue } if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval { syncSchedules[index].lastSent = now sendPeriodicSync(for: syncSchedules[index].types) + dueLabels.append(syncSchedules[index].types.debugShortLabel) } } + // One concise summary per maintenance cycle, not per packet. + if !dueLabels.isEmpty { + let peerCount = delegate?.getConnectedPeers().count ?? 0 + SecureLogger.info("[SYNC] cycle: request [\(dueLabels.joined(separator: " "))] to \(peerCount) peer(s)", category: .sync) + } } private func cleanupStaleAnnouncementsIfNeeded(now: Date) { @@ -527,6 +726,8 @@ final class GossipSyncManager { } private func removeState(for peerID: PeerID) { + // Deliberately keeps the peer's prekey bundle: bundles exist to reach + // owners who left the mesh, and they age out on their own schedule. _ = latestAnnouncementByPeer.removeValue(forKey: peerID) let messageCountBefore = messages.packets.count messages.remove { PeerID(hexData: $0.senderID) == peerID } @@ -535,6 +736,7 @@ final class GossipSyncManager { } fragments.remove { PeerID(hexData: $0.senderID) == peerID } fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID } + groupMessages.remove { PeerID(hexData: $0.senderID) == peerID } } } @@ -552,6 +754,12 @@ extension GossipSyncManager { } } + func _hasPrekeyBundle(for peerID: PeerID) -> Bool { + queue.sync { + latestPrekeyBundleByPeer[peerID] != nil + } + } + func _messageCount(for peerID: PeerID) -> Int { queue.sync { messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index fe796809..356c31c7 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -36,9 +36,29 @@ struct SyncTypeFlags: OptionSet { case .fragment: return 5 case .requestSync: return 6 case .fileTransfer: return 7 + // Extended bits are compat-safe by construction: `toData()` encodes + // the bitfield little-endian with trailing zero bytes trimmed (bit 10 + // widens the wire form from 1 to 2 bytes inside the length-prefixed + // REQUEST_SYNC TLV 0x04), and `decode(_:)` accepts 1...8 bytes while + // `type(forBit:)` maps unknown bits to nil — so old clients simply + // ignore the group bit and answer with the types they know. + case .boardPost: return 8 + case .groupMessage: return 10 // Courier envelopes are directed deposits between trusted peers and // must never spread via gossip sync. case .courierEnvelope: return nil + // The bitfield is a wire-tolerant little-endian UInt64 (1-8 bytes, + // unknown high bits ignored by `type(forBit:)`), so bits 8+ need no + // format change: old clients decode the wider flags and simply never + // match the new bits. + case .prekeyBundle: return 9 + // Gateway carriers are ephemeral live traffic (uplinks are directed, + // downlinks are rate-budgeted rebroadcasts); replaying them via sync + // would waste airtime and extend their lifetime. + case .nostrCarrier: return nil + // Ping/pong are ephemeral directed probes; replaying them via gossip + // sync would only produce stale, unanswerable echoes. + case .ping, .pong: return nil } } @@ -52,6 +72,12 @@ struct SyncTypeFlags: OptionSet { case 5: return .fragment case 6: return .requestSync case 7: return .fileTransfer + // Bit 8 spills the encoded bitfield into a second byte. Decoders since + // type-aware sync (#853) accept 1-8 bytes and map unknown bits to no + // known type, so old clients ignore board rounds instead of choking. + case 8: return .boardPost + case 9: return .prekeyBundle + case 10: return .groupMessage default: return nil } @@ -61,6 +87,9 @@ struct SyncTypeFlags: OptionSet { static let message = SyncTypeFlags(messageTypes: [.message]) static let fragment = SyncTypeFlags(messageTypes: [.fragment]) static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer]) + static let board = SyncTypeFlags(messageTypes: [.boardPost]) + static let prekeyBundle = SyncTypeFlags(messageTypes: [.prekeyBundle]) + static let groupMessage = SyncTypeFlags(messageTypes: [.groupMessage]) static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message]) @@ -121,4 +150,19 @@ struct SyncTypeFlags: OptionSet { } return SyncTypeFlags(rawValue: raw) } + + /// Compact human label for the `[SYNC]` observability logs, e.g. "msg,frag,pre". + /// (Referenced only from DEBUG log lines, but kept unconditional so the + /// log-call arguments compile in release too, where the log itself no-ops.) + var debugShortLabel: String { + var parts: [String] = [] + if contains(.announce) { parts.append("ann") } + if contains(.message) { parts.append("msg") } + if contains(.fragment) { parts.append("frag") } + if contains(.fileTransfer) { parts.append("file") } + if contains(.prekeyBundle) { parts.append("pre") } + if contains(.groupMessage) { parts.append("grp") } + if contains(.boardPost) { parts.append("board") } + return parts.isEmpty ? "none" : parts.joined(separator: ",") + } } diff --git a/bitchat/ViewModels/ChatBluetoothAlertPolicy.swift b/bitchat/ViewModels/ChatBluetoothAlertPolicy.swift index 0e7168e4..14e8bad0 100644 --- a/bitchat/ViewModels/ChatBluetoothAlertPolicy.swift +++ b/bitchat/ViewModels/ChatBluetoothAlertPolicy.swift @@ -12,17 +12,17 @@ enum ChatBluetoothAlertPolicy { case .poweredOff: ChatBluetoothAlertUpdate( isPresented: true, - message: String(localized: "content.alert.bluetooth_required.off", comment: "Message shown when Bluetooth is turned off") + message: String(localized: "content.alert.bluetooth_required.off", defaultValue: "bluetooth is turned off. please turn on bluetooth in settings to use bitchat.", comment: "Message shown when Bluetooth is turned off") ) case .unauthorized: ChatBluetoothAlertUpdate( isPresented: true, - message: String(localized: "content.alert.bluetooth_required.permission", comment: "Message shown when Bluetooth permission is missing") + message: String(localized: "content.alert.bluetooth_required.permission", defaultValue: "bitchat needs bluetooth permission to connect with nearby devices. please enable bluetooth access in settings.", comment: "Message shown when Bluetooth permission is missing") ) case .unsupported: ChatBluetoothAlertUpdate( isPresented: true, - message: String(localized: "content.alert.bluetooth_required.unsupported", comment: "Message shown when the device lacks Bluetooth support") + message: String(localized: "content.alert.bluetooth_required.unsupported", defaultValue: "this device does not support bluetooth. bitchat requires bluetooth to function.", comment: "Message shown when the device lacks Bluetooth support") ) case .poweredOn: ChatBluetoothAlertUpdate(isPresented: false, message: "") diff --git a/bitchat/ViewModels/ChatGroupCoordinator.swift b/bitchat/ViewModels/ChatGroupCoordinator.swift new file mode 100644 index 00000000..eb50b577 --- /dev/null +++ b/bitchat/ViewModels/ChatGroupCoordinator.swift @@ -0,0 +1,602 @@ +import BitFoundation +import BitLogger +import Foundation + +/// The narrow surface `ChatGroupCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. Group chats are keyed like direct chats +/// (virtual "group_" peer IDs), so the conversation intents below reuse the +/// private-chat store operations. +@MainActor +protocol ChatGroupContext: AnyObject { + // MARK: Identity & state + var nickname: String { get } + var myPeerID: PeerID { get } + var selectedPrivateChatPeer: PeerID? { get } + var groupStore: GroupStore { get } + + /// Fingerprint of our own Noise static identity key. + func myNoiseFingerprint() -> String + /// Our Ed25519 signing public key. + func mySigningPublicKey() -> Data + /// Signs `data` with our Noise signing key. + func signWithNoiseKey(_ data: Data) -> Data? + + // MARK: Peers + func getPeerIDForNickname(_ nickname: String) -> PeerID? + func isPeerConnected(_ peerID: PeerID) -> Bool + func peerNickname(for peerID: PeerID) -> String? + /// The peer's Noise fingerprint from the live session/registry. + func meshFingerprint(for peerID: PeerID) -> String? + /// The peer's persisted crypto identity (fingerprint + signing key), if + /// the identity store has a signature-verified announce for them. + func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)? + /// The connected short peer ID whose fingerprint matches, if any. + func connectedPeerID(forFingerprint fingerprint: String) -> PeerID? + /// Whether the user has blocked the identity with this Noise fingerprint. + func isFingerprintBlocked(_ fingerprint: String) -> Bool + + // MARK: Transport + func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) + func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) + func broadcastGroupMessagePayload(_ payload: Data) + + // MARK: Conversation intents (group chats are direct-keyed) + @discardableResult + func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool + func markPrivateChatUnread(_ peerID: PeerID) + func removePrivateChat(_ peerID: PeerID) + func startPrivateChat(with peerID: PeerID) + func endPrivateChat() + func addSystemMessage(_ content: String) + func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) + func notifyUIChanged() + func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) +} + +extension ChatViewModel: ChatGroupContext { + // `nickname`, `myPeerID`, `selectedPrivateChatPeer`, `groupStore`, + // `getPeerIDForNickname(_:)`, `isPeerConnected(_:)`, `peerNickname(for:)`, + // `appendPrivateMessage(_:to:)`, `markPrivateChatUnread(_:)`, + // `removePrivateChat(_:)`, `startPrivateChat(with:)`, + // `addSystemMessage(_:)`, `addLocalPrivateSystemMessage(_:to:)`, + // `notifyUIChanged()`, and `notifyPrivateMessage(from:message:peerID:)` + // are shared requirements with the other contexts or satisfied by + // existing `ChatViewModel` members. The members below flatten nested + // service accesses into intent-named calls. + + func myNoiseFingerprint() -> String { + meshService.noiseIdentityFingerprint() + } + + func mySigningPublicKey() -> Data { + meshService.noiseSigningPublicKeyData() + } + + func signWithNoiseKey(_ data: Data) -> Data? { + meshService.noiseSignData(data) + } + + func meshFingerprint(for peerID: PeerID) -> String? { + meshService.getFingerprint(for: peerID) + } + + /// The persisted, signature-verified identity behind a short mesh peer + /// ID. Cross-checked against the live session fingerprint so a roster + /// entry can never be pinned to a signing key from a different identity. + func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)? { + guard let fingerprint = meshService.getFingerprint(for: peerID) else { return nil } + let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) + guard let identity = candidates.first(where: { $0.fingerprint == fingerprint }), + let signingKey = identity.signingPublicKey else { return nil } + return (fingerprint, signingKey) + } + + /// Short mesh peer IDs are the fingerprint's first 16 hex chars, so the + /// connected peer for a roster fingerprint is a direct derivation. + func connectedPeerID(forFingerprint fingerprint: String) -> PeerID? { + let shortID = PeerID(str: String(fingerprint.prefix(16))) + return meshService.isPeerConnected(shortID) ? shortID : nil + } + + func isFingerprintBlocked(_ fingerprint: String) -> Bool { + identityManager.isBlocked(fingerprint: fingerprint) + } + + func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) { + meshService.sendGroupInvite(payload, to: peerID) + } + + func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) { + meshService.sendGroupKeyUpdate(payload, to: peerID) + } + + func broadcastGroupMessagePayload(_ payload: Data) { + meshService.broadcastGroupMessage(payload) + } + + // MARK: CommandContextProvider group commands (parsed by CommandProcessor) + + func groupCreate(named name: String) -> CommandResult { + groupCoordinator.createGroup(named: name) + } + + func groupInvite(nickname: String) -> CommandResult { + groupCoordinator.inviteMember(nickname: nickname) + } + + func groupRemove(nickname: String) -> CommandResult { + groupCoordinator.removeMember(nickname: nickname) + } + + func groupLeave() -> CommandResult { + groupCoordinator.leaveGroup() + } + + func groupList() -> CommandResult { + groupCoordinator.listGroups() + } +} + +/// Owns the private-groups feature: creating groups, creator-managed invites +/// and key rotation over Noise, and sealing/opening group message broadcasts. +/// Delivery is fire-and-flood like public chat — no per-member acks in v1 — +/// with gossip-sync backfill as the only offline catch-up. +@MainActor +final class ChatGroupCoordinator { + private unowned let context: any ChatGroupContext + + private static let maxGroupNameLength = 40 + + init(context: any ChatGroupContext) { + self.context = context + } + + // MARK: - Commands + + func createGroup(named rawName: String) -> CommandResult { + let name = rawName.trimmed + guard !name.isEmpty else { + return .error(message: String(localized: "system.group.usage_create", defaultValue: "usage: /group create ", comment: "Usage hint for /group create")) + } + guard name.count <= Self.maxGroupNameLength else { + return .error(message: String(localized: "system.group.name_too_long", defaultValue: "group names are limited to 40 characters", comment: "Error when a group name exceeds the length cap")) + } + + let myFingerprint = context.myNoiseFingerprint() + let mySigningKey = context.mySigningPublicKey() + guard !myFingerprint.isEmpty, mySigningKey.count == 32 else { + return .error(message: String(localized: "system.group.identity_unavailable", defaultValue: "your identity keys are not ready yet", comment: "Error when the local identity is not ready for group operations")) + } + + let creator = GroupMember(fingerprint: myFingerprint, signingKey: mySigningKey, nickname: context.nickname) + guard let group = context.groupStore.createGroup(named: name, creator: creator) else { + return .error(message: String(localized: "system.group.create_failed", defaultValue: "could not create the group", comment: "Error when group creation fails")) + } + + context.startPrivateChat(with: group.peerID) + return .success(message: String( + format: String(localized: "system.group.created", defaultValue: "created group '%@' — use /group invite @name to add people", comment: "System message after creating a group; placeholder is the group name"), + locale: .current, + name + )) + } + + func inviteMember(nickname rawNickname: String) -> CommandResult { + let nickname = normalizedNickname(rawNickname) + guard !nickname.isEmpty else { + return .error(message: String(localized: "system.group.usage_invite", defaultValue: "usage: /group invite @name", comment: "Usage hint for /group invite")) + } + guard let group = selectedGroup() else { + return .error(message: String(localized: "system.group.not_in_group", defaultValue: "open a group chat first", comment: "Error when a group command requires an open group chat")) + } + guard group.creatorFingerprint == context.myNoiseFingerprint() else { + return .error(message: String(localized: "system.group.creator_only", defaultValue: "only the group creator can do that", comment: "Error when a non-creator attempts a creator-only group action")) + } + guard let peerID = context.getPeerIDForNickname(nickname) else { + return .error(message: String( + format: String(localized: "system.group.peer_not_found", defaultValue: "'%@' not found", comment: "Error when the invitee nickname is unknown; placeholder is the nickname"), + locale: .current, + nickname + )) + } + guard context.isPeerConnected(peerID) else { + return .error(message: String( + format: String(localized: "system.group.peer_not_connected", defaultValue: "%@ must be connected over mesh to be invited", comment: "Error when the invitee is not connected over mesh; placeholder is the nickname"), + locale: .current, + nickname + )) + } + guard let identity = context.cryptoIdentity(for: peerID) else { + return .error(message: String( + format: String(localized: "system.group.peer_identity_unknown", defaultValue: "cannot verify %@'s identity yet — wait for their announce", comment: "Error when the invitee's verified identity is unavailable; placeholder is the nickname"), + locale: .current, + nickname + )) + } + guard !group.isMember(fingerprint: identity.fingerprint) else { + return .error(message: String( + format: String(localized: "system.group.already_member", defaultValue: "%@ is already a member", comment: "Error when the invitee is already a member; placeholder is the nickname"), + locale: .current, + nickname + )) + } + guard group.members.count < BitchatGroup.maxMembers else { + return .error(message: String( + format: String(localized: "system.group.full", defaultValue: "group is full (max %@ members)", comment: "Error when the group is at the member cap; placeholder is the cap"), + locale: .current, + "\(BitchatGroup.maxMembers)" + )) + } + + let newMember = GroupMember( + fingerprint: identity.fingerprint, + signingKey: identity.signingKey, + nickname: context.peerNickname(for: peerID) ?? nickname + ) + // Rotate the key (epoch + 1) on every roster change, not just removals. + // A monotonically increasing epoch per roster gives the receiver a + // strict ordering: two out-of-order invite states can no longer share + // an epoch and last-writer-wins a just-added member back out. + let members = group.members + [newMember] + guard let (updated, key) = context.groupStore.rotateKey(groupID: group.groupID, members: members), + let payload = signedStatePayload(for: updated, key: key) else { + return .error(message: String(localized: "system.group.invite_failed", defaultValue: "could not build the group invite", comment: "Error when building or signing a group invite fails")) + } + + context.sendGroupInvitePayload(payload, to: peerID) + distributeState(payload, group: updated, excluding: [identity.fingerprint], type: .keyUpdate) + + return .success(message: String( + format: String(localized: "system.group.invited", defaultValue: "invited %1$@ to '%2$@'", comment: "System message after inviting someone; placeholders are the nickname and the group name"), + locale: .current, + nickname, + updated.name + )) + } + + /// Creator-side removal: rotates the group key (epoch + 1) and sends the + /// new state to every remaining member so the removed member's key stops + /// decrypting future traffic. + func removeMember(nickname rawNickname: String) -> CommandResult { + let nickname = normalizedNickname(rawNickname) + guard !nickname.isEmpty else { + return .error(message: String(localized: "system.group.usage_remove", defaultValue: "usage: /group remove @name", comment: "Usage hint for /group remove")) + } + guard let group = selectedGroup() else { + return .error(message: String(localized: "system.group.not_in_group", defaultValue: "open a group chat first", comment: "Error when a group command requires an open group chat")) + } + guard group.creatorFingerprint == context.myNoiseFingerprint() else { + return .error(message: String(localized: "system.group.creator_only", defaultValue: "only the group creator can do that", comment: "Error when a non-creator attempts a creator-only group action")) + } + guard let member = group.members.first(where: { $0.nickname.caseInsensitiveCompare(nickname) == .orderedSame }) else { + return .error(message: String( + format: String(localized: "system.group.member_not_found", defaultValue: "'%@' is not a member of this group", comment: "Error when the member to remove is not in the roster; placeholder is the nickname"), + locale: .current, + nickname + )) + } + guard member.fingerprint != group.creatorFingerprint else { + return .error(message: String(localized: "system.group.cannot_remove_creator", defaultValue: "the creator cannot be removed", comment: "Error when the creator tries to remove themselves")) + } + + let remaining = group.members.filter { $0.fingerprint != member.fingerprint } + guard let (rotated, newKey) = context.groupStore.rotateKey(groupID: group.groupID, members: remaining), + let payload = signedStatePayload(for: rotated, key: newKey) else { + return .error(message: String(localized: "system.group.rotate_failed", defaultValue: "could not rotate the group key", comment: "Error when rotating the group key fails")) + } + + distributeState(payload, group: rotated, excluding: [], type: .keyUpdate) + notifyRemovedMember(member, rotated: rotated) + + return .success(message: String( + format: String(localized: "system.group.removed_member", defaultValue: "removed %@ and rotated the group key", comment: "System message after removing a member and rotating the key; placeholder is the nickname"), + locale: .current, + member.nickname + )) + } + + func leaveGroup() -> CommandResult { + guard let group = selectedGroup() else { + return .error(message: String(localized: "system.group.not_in_group", defaultValue: "open a group chat first", comment: "Error when a group command requires an open group chat")) + } + // Close the chat window first so the confirmation message doesn't + // resurrect the conversation we're about to remove. + context.endPrivateChat() + context.removePrivateChat(group.peerID) + context.groupStore.removeGroup(withID: group.groupID) + context.notifyUIChanged() + return .success(message: String( + format: String(localized: "system.group.left", defaultValue: "left group '%@'", comment: "System message after leaving a group; placeholder is the group name"), + locale: .current, + group.name + )) + } + + func listGroups() -> CommandResult { + let groups = context.groupStore.groups + guard !groups.isEmpty else { + return .success(message: String(localized: "system.group.none", defaultValue: "you are not in any groups — /group create to start one", comment: "System message when the user is in no groups")) + } + let myFingerprint = context.myNoiseFingerprint() + let lines = groups.map { group -> String in + let role = group.creatorFingerprint == myFingerprint ? " (creator)" : "" + return "#\(group.name)\(role) — \(group.members.count)/\(BitchatGroup.maxMembers)" + } + return .success(message: String(localized: "system.group.list_header", defaultValue: "your groups:", comment: "Header line for the /group list output") + "\n" + lines.joined(separator: "\n")) + } + + // MARK: - Sending + + /// Fire-and-flood send: local echo goes straight to `.sent` because group + /// messages have no per-member acknowledgments in v1. + func sendGroupMessage(_ content: String, to groupPeerID: PeerID) { + guard !content.isEmpty, content.count <= InputValidator.Limits.maxMessageLength else { return } + guard let group = context.groupStore.group(for: groupPeerID), + let key = context.groupStore.key(forGroupID: group.groupID) else { + context.addSystemMessage(String(localized: "system.group.unknown", defaultValue: "you are no longer in this group", comment: "System message when sending into an unknown group")) + return + } + + let messageID = UUID().uuidString + let timestamp = Date() + let payload: Data + do { + payload = try GroupCrypto.sealMessage( + content: content, + messageID: messageID, + senderNickname: context.nickname, + senderSigningKey: context.mySigningPublicKey(), + timestampMs: UInt64(timestamp.timeIntervalSince1970 * 1000), + groupID: group.groupID, + epoch: group.epoch, + key: key, + sign: { [weak context] data in context?.signWithNoiseKey(data) } + ) + } catch { + SecureLogger.error("Failed to seal group message: \(error)", category: .encryption) + context.addLocalPrivateSystemMessage( + String(localized: "system.group.send_failed", defaultValue: "could not encrypt the message", comment: "System message when sealing a group message fails"), + to: groupPeerID + ) + return + } + + let message = BitchatMessage( + id: messageID, + sender: context.nickname, + content: content, + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: group.name, + senderPeerID: context.myPeerID, + mentions: nil, + deliveryStatus: .sent + ) + context.appendPrivateMessage(message, to: groupPeerID) + context.broadcastGroupMessagePayload(payload) + context.notifyUIChanged() + } + + // MARK: - Receiving + + /// Decrypt-verify path for an incoming 0x25 broadcast. Drops silently for + /// unknown groups (non-members relay but never read), wrong epochs, bad + /// sender signatures, and senders missing from the pinned roster. + func handleGroupMessagePayload(_ payload: Data, timestamp: Date) { + guard let envelope = GroupMessageEnvelope.decode(payload) else { return } + guard let group = context.groupStore.group(withID: envelope.groupID) else { return } + guard envelope.epoch == group.epoch else { + SecureLogger.debug("Dropping group message with epoch \(envelope.epoch) (current \(group.epoch))", category: .encryption) + return + } + guard let key = context.groupStore.key(forGroupID: group.groupID) else { return } + + let plaintext: GroupMessagePlaintext + do { + plaintext = try GroupCrypto.openMessage(envelope, key: key) + } catch { + SecureLogger.debug("Failed to open group message: \(error)", category: .encryption) + return + } + + // Sender must be pinned in the creator-signed roster; key possession + // alone is not authorship. + guard let member = group.member(withSigningKey: plaintext.senderSigningKey) else { + SecureLogger.warning("Dropping group message from non-roster sender", category: .security) + return + } + // Our own broadcast echoed back via relay or sync replay. + guard plaintext.senderSigningKey != context.mySigningPublicKey() else { return } + // Honor /block inside groups too: drop display + notification for a + // blocked member, consistent with every other inbound path. + guard !context.isFingerprintBlocked(member.fingerprint) else { + SecureLogger.debug("Dropping group message from blocked member", category: .security) + return + } + + let groupPeerID = group.peerID + // Trust the authenticated inner timestamp (clamped so a future-dated + // message cannot pin itself to the bottom of the timeline). + let messageDate = min(Date(timeIntervalSince1970: TimeInterval(plaintext.timestampMs) / 1000), Date()) + let senderName = member.nickname.isEmpty ? plaintext.senderNickname : member.nickname + let senderPeerID = PeerID(str: String(member.fingerprint.prefix(16))) + let message = BitchatMessage( + id: plaintext.messageID, + sender: senderName, + content: plaintext.content, + timestamp: messageDate, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: group.name, + senderPeerID: senderPeerID, + mentions: nil + ) + + guard context.appendPrivateMessage(message, to: groupPeerID) else { return } + + let isViewing = context.selectedPrivateChatPeer == groupPeerID + if !isViewing { + context.markPrivateChatUnread(groupPeerID) + let isRecent = Date().timeIntervalSince(messageDate) < 30 + if isRecent { + context.notifyPrivateMessage( + from: "\(senderName) @ \(group.name)", + message: plaintext.content, + peerID: groupPeerID + ) + } + } + context.notifyUIChanged() + } + + /// Accepts creator-signed group state arriving as an invite. The Noise + /// session peer must BE the creator, the signature must verify against + /// the creator key pinned in the roster, and we must be in the roster. + func handleGroupInvitePayload(from peerID: PeerID, payload: Data) { + applyGroupState(from: peerID, payload: payload, isInvite: true) + } + + /// Accepts creator-signed state updates (rotation/roster). A state whose + /// roster no longer includes us means we were removed: drop the group. + func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) { + applyGroupState(from: peerID, payload: payload, isInvite: false) + } +} + +private extension ChatGroupCoordinator { + enum StateSendType { + case invite + case keyUpdate + } + + func normalizedNickname(_ raw: String) -> String { + let trimmed = raw.trimmed + return trimmed.hasPrefix("@") ? String(trimmed.dropFirst()) : trimmed + } + + func selectedGroup() -> BitchatGroup? { + guard let selected = context.selectedPrivateChatPeer, selected.isGroup else { return nil } + return context.groupStore.group(for: selected) + } + + func signedStatePayload(for group: BitchatGroup, key: Data) -> Data? { + GroupStatePayload.makeSigned(group: group, key: key) { [weak context] data in + context?.signWithNoiseKey(data) + }?.encode() + } + + /// Sends the state payload to every connected roster member except us and + /// the excluded fingerprints. Offline members catch up the next time the + /// creator sends them state (v1 limitation, documented in the PR). + func distributeState(_ payload: Data, group: BitchatGroup, excluding excludedFingerprints: Set, type: StateSendType) { + let myFingerprint = context.myNoiseFingerprint() + for member in group.members { + guard member.fingerprint != myFingerprint, + !excludedFingerprints.contains(member.fingerprint), + let peerID = context.connectedPeerID(forFingerprint: member.fingerprint) else { continue } + switch type { + case .invite: + context.sendGroupInvitePayload(payload, to: peerID) + case .keyUpdate: + context.sendGroupKeyUpdatePayload(payload, to: peerID) + } + } + } + + /// Tells a just-removed member they're out so their client can deactivate + /// the group instead of silently going dark (dropping every message under + /// the epoch it no longer has the key for). The notice is a creator-signed + /// state whose roster excludes the removee — their `applyGroupState` + /// removal branch fires on the missing-self roster and surfaces the + /// "removed from group" system message. + /// + /// It carries a throwaway all-zero key, never the rotated key, so the + /// removee cannot decrypt post-removal traffic. State is sent 1:1 over + /// authenticated Noise, so no remaining member ever receives this blob + /// (and even if one did, its own missing-self check would not match). + /// If the removee is offline the notice can't be delivered — same v1 + /// limitation as any other missed key update, documented in the PR. + func notifyRemovedMember(_ removed: GroupMember, rotated: BitchatGroup) { + guard let peerID = context.connectedPeerID(forFingerprint: removed.fingerprint) else { return } + let throwawayKey = Data(count: BitchatGroup.keyLength) + guard let payload = signedStatePayload(for: rotated, key: throwawayKey) else { return } + context.sendGroupKeyUpdatePayload(payload, to: peerID) + } + + func applyGroupState(from peerID: PeerID, payload: Data, isInvite: Bool) { + guard let state = GroupStatePayload.decode(payload) else { + SecureLogger.warning("Malformed group state payload from \(peerID.id.prefix(8))…", category: .security) + return + } + // The Noise session already authenticated `peerID`; require that the + // authenticated peer IS the creator whose key signed the state, so a + // member can't re-invite or rotate on the creator's behalf. + guard let senderFingerprint = context.meshFingerprint(for: peerID), + senderFingerprint == state.creatorFingerprint else { + SecureLogger.warning("Dropping group state from non-creator \(peerID.id.prefix(8))…", category: .security) + return + } + guard state.verifyCreatorSignature() else { + SecureLogger.warning("Dropping group state with invalid creator signature", category: .security) + return + } + + let myFingerprint = context.myNoiseFingerprint() + let existing = context.groupStore.group(withID: state.groupID) + + // A creator-signed roster that no longer includes us is a removal. + guard state.members.contains(where: { $0.fingerprint == myFingerprint }) else { + if let existing { + if context.selectedPrivateChatPeer == existing.peerID { + context.endPrivateChat() + } + context.removePrivateChat(existing.peerID) + context.groupStore.removeGroup(withID: existing.groupID) + context.addSystemMessage(String( + format: String(localized: "system.group.removed_from", defaultValue: "you were removed from group '%@'", comment: "System message when removed from a group; placeholder is the group name"), + locale: .current, + existing.name + )) + context.notifyUIChanged() + } + return + } + + // Never regress the epoch: state travels over live Noise sessions, + // so an older epoch here is a stale (or misbehaving) creator device. + if let existing, state.epoch < existing.epoch { + SecureLogger.warning("Dropping stale group state (epoch \(state.epoch) < \(existing.epoch))", category: .security) + return + } + + let isNewMembership = existing == nil + guard context.groupStore.upsert(state.asGroup, key: state.key) else { + SecureLogger.error("Failed to store group state for \(state.name)", category: .session) + return + } + + if isNewMembership { + let inviter = state.members.first { $0.fingerprint == state.creatorFingerprint }?.nickname + ?? context.peerNickname(for: peerID) + ?? "?" + let notice = String( + format: String(localized: "system.group.joined", defaultValue: "you were added to group '%1$@' by %2$@ — it now appears in your people list", comment: "System message when added to a group; placeholders are the group name and the inviter"), + locale: .current, + state.name, + inviter + ) + context.addSystemMessage(notice) + context.markPrivateChatUnread(state.asGroup.peerID) + context.notifyPrivateMessage(from: inviter, message: notice, peerID: state.asGroup.peerID) + } else if isInvite == false, let existing, state.epoch > existing.epoch { + SecureLogger.info("Group '\(state.name)' rotated to epoch \(state.epoch)", category: .session) + } + context.notifyUIChanged() + } +} diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index bea1b5b2..0117bf60 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -185,6 +185,13 @@ final class ChatLifecycleCoordinator { func markPrivateMessagesAsRead(from peerID: PeerID) { context.markChatAsRead(from: peerID) + // Group chats are keyed under a virtual group_ peerID; no member IS the + // conversation peer, so the receipt loops below (which gate on + // senderPeerID == peerID) must never emit a read/delivered receipt for + // one. This guard makes that explicit so a future refactor of the + // receipt matching can't silently start leaking receipts into groups. + guard !peerID.isGroup else { return } + if peerID.isGeoDM, let recipientHex = context.nostrKeyMapping[peerID], case .location(let channel) = context.activeChannel, @@ -324,7 +331,7 @@ private extension ChatLifecycleCoordinator { do { let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) - let event = try NostrProtocol.createEphemeralGeohashEvent( + let event = try await NostrProtocol.createMinedEphemeralGeohashEvent( content: message, geohash: channel.geohash, senderIdentity: identity, @@ -343,7 +350,7 @@ private extension ChatLifecycleCoordinator { } catch { SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session) context.addSystemMessage( - String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") + String(localized: "system.location.send_failed", defaultValue: "failed to send to location channel", comment: "System message when a location channel send fails") ) } } diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index bf0009da..43e90291 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -117,13 +117,13 @@ final class ChatMediaTransferCoordinator { try? FileManager.default.removeItem(at: url) await MainActor.run { [weak self] in guard let self else { return } - self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit")) + self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", defaultValue: "voice note too large", comment: "Failure reason shown when a voice note exceeds the size limit")) } } catch { SecureLogger.error("Voice note send failed: \(error)", category: .session) await MainActor.run { [weak self] in guard let self else { return } - self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent")) + self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", defaultValue: "voice note failed to send", comment: "Failure reason shown when a voice note could not be sent")) } } } diff --git a/bitchat/ViewModels/ChatOutgoingCoordinator.swift b/bitchat/ViewModels/ChatOutgoingCoordinator.swift index 915ea7fa..4d1b4c56 100644 --- a/bitchat/ViewModels/ChatOutgoingCoordinator.swift +++ b/bitchat/ViewModels/ChatOutgoingCoordinator.swift @@ -68,10 +68,22 @@ extension ChatViewModel: ChatOutgoingContext { final class ChatOutgoingCoordinator { private unowned let context: any ChatOutgoingContext + /// In-flight NIP-13 mining for the most recent geohash send. A newer send + /// (or leaving the channel) cancels it, which only expedites the mining — + /// the message still goes out at the difficulty already reached. + /// (Read access is internal so tests can await the send's completion.) + private(set) var geohashMiningTask: Task? + init(context: any ChatOutgoingContext) { self.context = context } + /// Finish any in-flight geohash PoW mining early (the pending message + /// still sends, at whatever committed difficulty it reached). + func expeditePendingGeohashMining() { + geohashMiningTask?.cancel() + } + func sendMessage(_ content: String) { guard let trimmed = content.trimmedOrNilIfEmpty else { return } @@ -92,120 +104,117 @@ final class ChatOutgoingCoordinator { } let mentions = context.parseMentions(from: content) - let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions) - guard let preparedMessage else { return } - appendLocalEcho(preparedMessage.message) - routePublicMessage( - originalContent: content, - mentions: mentions, - geoContext: preparedMessage.geoContext, - messageID: preparedMessage.message.id, - timestamp: preparedMessage.message.timestamp - ) + switch context.activeChannel { + case .mesh: + sendMeshPublicMessage(originalContent: content, trimmed: trimmed, mentions: mentions) + case .location(let channel): + sendGeohashPublicMessage(trimmed, mentions: mentions, channel: channel) + } } } private extension ChatOutgoingCoordinator { - func preparePublicMessage( - content: String, - trimmed: String, - mentions: [String] - ) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? { - var geoContext: ChatViewModel.GeoOutgoingContext? - var displaySender = context.nickname - var localSenderPeerID = context.myPeerID - var messageID: String? - var messageTimestamp = Date() + func sendMeshPublicMessage(originalContent: String, trimmed: String, mentions: [String]) { + let message = BitchatMessage( + sender: context.nickname, + content: trimmed, + timestamp: Date(), + isRelay: false, + senderPeerID: context.myPeerID, + mentions: mentions.isEmpty ? nil : mentions + ) - switch context.activeChannel { - case .mesh: - break + appendLocalEcho(message, to: .mesh) + context.recordPublicActivity(forChannelKey: "mesh") + context.sendMeshMessage( + originalContent, + mentions: mentions, + messageID: message.id, + timestamp: message.timestamp + ) + } - case .location(let channel): + /// Geohash sends mine a NIP-13 nonce tag first (off the main actor, see + /// `NostrPoW`), so the whole echo-and-send runs in a task once the signed + /// event — whose ID is also the local message ID — exists. Typical mining + /// at the default target is well under 100 ms and hard-capped at + /// `NostrPoW.miningTimeCap`, so sending is never meaningfully delayed. + func sendGeohashPublicMessage(_ trimmed: String, mentions: [String], channel: GeohashChannel) { + let identity: NostrIdentity + do { + identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) + } catch { + SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session) + context.addSystemMessage( + String(localized: "system.location.send_failed", defaultValue: "failed to send to location channel", comment: "System message when a location channel send fails") + ) + return + } + + let displaySender = context.nickname + "#" + String(identity.publicKeyHex.suffix(4)) + let senderPeerID = PeerID(nostr: identity.publicKeyHex) + let teleported = context.isTeleported + let nickname = context.nickname + + // Serialize geohash sends: each send awaits the previous send's task + // before it appends + relays, so user-visible order always matches + // send order even when an earlier message mines longer than a later + // one. Cancelling the previous task only *expedites* its mining (the + // NIP-13 target is polled, not aborted), so it still finishes and + // sends — and it finishes fast, so awaiting it never stacks mining + // delays or blocks a send beyond `NostrPoW.miningTimeCap`. + let previousSend = geohashMiningTask + previousSend?.cancel() + geohashMiningTask = Task { @MainActor [weak context = self.context] in + await previousSend?.value + + let event: NostrEvent do { - let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) - let suffix = String(identity.publicKeyHex.suffix(4)) - displaySender = context.nickname + "#" + suffix - localSenderPeerID = PeerID(nostr: identity.publicKeyHex) - - let teleported = context.isTeleported - let event = try NostrProtocol.createEphemeralGeohashEvent( + event = try await NostrProtocol.createMinedEphemeralGeohashEvent( content: trimmed, geohash: channel.geohash, senderIdentity: identity, - nickname: context.nickname, - teleported: teleported - ) - - messageID = event.id - messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - geoContext = ( - channel: channel, - event: event, - identity: identity, + nickname: nickname, teleported: teleported ) } catch { SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session) - context.addSystemMessage( - String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") + context?.addSystemMessage( + String(localized: "system.location.send_failed", defaultValue: "failed to send to location channel", comment: "System message when a location channel send fails") ) - return nil + return } + guard let context else { return } + + let message = BitchatMessage( + id: event.id, + sender: displaySender, + content: trimmed, + timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at)), + isRelay: false, + senderPeerID: senderPeerID, + mentions: mentions.isEmpty ? nil : mentions + ) + + context.appendPublicMessage(message, to: ConversationID(channelID: .location(channel))) + let contentKey = context.normalizedContentKey(message.content) + context.recordContentKey(contentKey, timestamp: message.timestamp) + + context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)") + context.sendGeohash(context: ( + channel: channel, + event: event, + identity: identity, + teleported: teleported + )) } - - let message = BitchatMessage( - id: messageID, - sender: displaySender, - content: trimmed, - timestamp: messageTimestamp, - isRelay: false, - senderPeerID: localSenderPeerID, - mentions: mentions.isEmpty ? nil : mentions - ) - - return (message, geoContext) } - func appendLocalEcho(_ message: BitchatMessage) { - context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel)) + func appendLocalEcho(_ message: BitchatMessage, to conversationID: ConversationID) { + context.appendPublicMessage(message, to: conversationID) let contentKey = context.normalizedContentKey(message.content) context.recordContentKey(contentKey, timestamp: message.timestamp) } - - func routePublicMessage( - originalContent: String, - mentions: [String], - geoContext: ChatViewModel.GeoOutgoingContext?, - messageID: String, - timestamp: Date - ) { - switch context.activeChannel { - case .mesh: - context.recordPublicActivity(forChannelKey: "mesh") - context.sendMeshMessage( - originalContent, - mentions: mentions, - messageID: messageID, - timestamp: timestamp - ) - - case .location(let channel): - context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)") - - guard let geoContext, geoContext.channel.geohash == channel.geohash else { - SecureLogger.error("Geo: missing send context for \(channel.geohash)", category: .session) - context.addSystemMessage( - String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") - ) - return - } - - Task { @MainActor [weak context = self.context] in - context?.sendGeohash(context: geoContext) - } - } - } } diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 46e0787a..30d566b8 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -323,6 +323,15 @@ final class ChatPeerIdentityCoordinator { func startPrivateChat(with peerID: PeerID) { guard peerID != context.myPeerID else { return } + // Group chats are virtual conversations: no peer identity, favorites, + // handshake, or message consolidation applies — just select the chat. + if peerID.isGroup { + context.selectedPrivateChatFingerprint = nil + context.beginPrivateChatSession(with: peerID) + context.markPrivateChatRead(peerID) + return + } + let peerNickname = context.peerNickname(for: peerID) ?? "unknown" if context.unifiedIsBlocked(peerID) { diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index e577e406..0347f32e 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -238,7 +238,7 @@ final class ChatPrivateConversationCoordinator { let nickname = context.peerNickname(for: peerID) ?? "user" context.addSystemMessage( String( - format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"), + format: String(localized: "system.dm.blocked_recipient", defaultValue: "cannot send message to %@: person is blocked.", comment: "System message when attempting to message a blocked user"), locale: .current, nickname ) @@ -298,7 +298,7 @@ final class ChatPrivateConversationCoordinator { } else { context.setPrivateDeliveryStatus( .failed( - reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable") + reason: String(localized: "content.delivery.reason.unreachable", defaultValue: "peer not reachable", comment: "Failure reason when a peer is unreachable") ), forMessageID: messageID, peerID: peerID @@ -306,7 +306,7 @@ final class ChatPrivateConversationCoordinator { let name = recipientNickname ?? "user" context.addSystemMessage( String( - format: String(localized: "system.dm.unreachable", comment: "System message when a recipient is unreachable"), + format: String(localized: "system.dm.unreachable", defaultValue: "cannot send message to %@ - peer is not reachable via mesh or nostr.", comment: "System message when a recipient is unreachable"), locale: .current, name ) @@ -317,7 +317,7 @@ final class ChatPrivateConversationCoordinator { func sendGeohashDM(_ content: String, to peerID: PeerID) { guard case .location(let channel) = context.activeChannel else { context.addSystemMessage( - String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel") + String(localized: "system.location.not_in_channel", defaultValue: "cannot send: not in a location channel", comment: "System message when attempting to send without being in a location channel") ) return } @@ -341,7 +341,7 @@ final class ChatPrivateConversationCoordinator { guard let recipientHex = context.nostrKeyMapping[peerID] else { context.setPrivateDeliveryStatus( .failed( - reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown") + reason: String(localized: "content.delivery.reason.unknown_recipient", defaultValue: "unknown recipient", comment: "Failure reason when the recipient is unknown") ), forMessageID: messageID, peerID: peerID @@ -352,13 +352,13 @@ final class ChatPrivateConversationCoordinator { if context.isNostrBlocked(pubkeyHexLowercased: recipientHex) { context.setPrivateDeliveryStatus( .failed( - reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked") + reason: String(localized: "content.delivery.reason.blocked", defaultValue: "user is blocked", comment: "Failure reason when the user is blocked") ), forMessageID: messageID, peerID: peerID ) context.addSystemMessage( - String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked") + String(localized: "system.dm.blocked_generic", defaultValue: "cannot send message: person is blocked.", comment: "System message when sending fails because user is blocked") ) return } @@ -368,7 +368,7 @@ final class ChatPrivateConversationCoordinator { if recipientHex.lowercased() == identity.publicKeyHex.lowercased() { context.setPrivateDeliveryStatus( .failed( - reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself") + reason: String(localized: "content.delivery.reason.self", defaultValue: "cannot message yourself", comment: "Failure reason when attempting to message yourself") ), forMessageID: messageID, peerID: peerID @@ -390,7 +390,7 @@ final class ChatPrivateConversationCoordinator { } catch { context.setPrivateDeliveryStatus( .failed( - reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error") + reason: String(localized: "content.delivery.reason.send_error", defaultValue: "send error", comment: "Failure reason for a generic send error") ), forMessageID: messageID, peerID: peerID diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index d6777a71..c5f19c44 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -82,7 +82,9 @@ protocol ChatPublicConversationContext: AnyObject { // MARK: Inbound public message processing func processActionMessage(_ message: BitchatMessage) -> BitchatMessage func isMessageBlocked(_ message: BitchatMessage) -> Bool - func allowPublicMessage(senderKey: String, contentKey: String) -> Bool + /// `powBits` is the validated NIP-13 difficulty of the source Nostr event + /// (0 for mesh messages); sufficient PoW relaxes the per-sender bucket. + func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool /// Buffers a visible-channel message for the batched (~80 ms) pipeline /// flush, which commits it to `conversationID` in the store. func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) @@ -137,8 +139,8 @@ extension ChatViewModel: ChatPublicConversationContext { meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp) } - func allowPublicMessage(senderKey: String, contentKey: String) -> Bool { - publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) + func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool { + publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey, powBits: powBits) } func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) { @@ -290,7 +292,17 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { func clearCurrentPublicTimeline() { context.clearPublicConversation(ConversationID(channelID: context.activeChannel)) + // The SPM test process shares the real Application Support tree, so this + // detached deletion can land mid-test under parallel scheduling and flake + // a file-dependent test. Tests never need the on-disk media cleared. + guard !TestEnvironment.isRunningTests else { return } + Task.detached(priority: .utility) { + // Skipped under tests: the test process shares the user's real + // ~/Library/Application Support/files tree, and this detached + // wipe fires at a nondeterministic time — racing tests that + // write media there (see the same guard in panicClearAllData). + guard !TestEnvironment.isRunningTests else { return } do { let base = try FileManager.default.url( for: .applicationSupportDirectory, @@ -367,7 +379,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { guard let context else { return } do { let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) - let event = try NostrProtocol.createEphemeralGeohashEvent( + let event = try await NostrProtocol.createMinedEphemeralGeohashEvent( content: content, geohash: channel.geohash, senderIdentity: identity, @@ -395,7 +407,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { ) } - func handlePublicMessage(_ message: BitchatMessage) { + /// - Parameter powBits: validated NIP-13 difficulty of the source Nostr + /// event (0 for mesh messages). Sufficient PoW relaxes the per-sender + /// rate limit; low/no-PoW events keep the strict limits so old clients + /// still get through at normal rates. + func handlePublicMessage(_ message: BitchatMessage, powBits: Int = 0) { let finalMessage = context.processActionMessage(message) if context.isMessageBlocked(finalMessage) { return } @@ -405,7 +421,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { if shouldRateLimit { let senderKey = normalizedSenderKey(for: finalMessage) let contentKey = context.normalizedContentKey(finalMessage.content) - if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey) { + if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey, powBits: powBits) { return } } diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index 75571b2e..4a02fac1 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -71,6 +71,11 @@ protocol ChatTransportEventContext: AnyObject { // MARK: Verification payloads func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) + func handleVouchPayload(from peerID: PeerID, payload: Data) + + // MARK: Group payloads (creator-signed state over Noise) + func handleGroupInvitePayload(from peerID: PeerID, payload: Data) + func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) } extension ChatViewModel: ChatTransportEventContext { @@ -129,6 +134,18 @@ extension ChatViewModel: ChatTransportEventContext { func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) { verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload) } + + func handleVouchPayload(from peerID: PeerID, payload: Data) { + vouchCoordinator.handleVouchPayload(from: peerID, payload: payload) + } + + func handleGroupInvitePayload(from peerID: PeerID, payload: Data) { + groupCoordinator.handleGroupInvitePayload(from: peerID, payload: payload) + } + + func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) { + groupCoordinator.handleGroupKeyUpdatePayload(from: peerID, payload: payload) + } } final class ChatTransportEventCoordinator { @@ -371,6 +388,20 @@ private extension ChatTransportEventCoordinator { case .verifyResponse: context.handleVerifyResponsePayload(from: peerID, payload: payload) + + case .vouch: + context.handleVouchPayload(from: peerID, payload: payload) + + case .groupInvite: + context.handleGroupInvitePayload(from: peerID, payload: payload) + + case .groupKeyUpdate: + context.handleGroupKeyUpdatePayload(from: peerID, payload: payload) + + case .bulkTransferOffer, .bulkTransferResponse: + // Wi-Fi bulk negotiation is consumed inside the mesh transport + // (BLEService); it never reaches the UI layer. + break } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 578b6509..358e0c38 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -102,7 +102,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele @MainActor var canSendMediaInCurrentContext: Bool { if let peer = selectedPrivateChatPeer { - return !(peer.isGeoDM || peer.isGeoChat) + // Media transfer is not wired for groups in v1 (sendFilePrivate + // rejects the virtual group_ recipient), so keep the affordance off. + return !(peer.isGeoDM || peer.isGeoChat || peer.isGroup) } switch activeChannel { case .mesh: return true @@ -177,6 +179,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele lazy var nostrCoordinator = ChatNostrCoordinator(context: self) lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self) lazy var verificationCoordinator = ChatVerificationCoordinator(context: self) + lazy var vouchCoordinator = ChatVouchCoordinator(context: self) + lazy var groupCoordinator = ChatGroupCoordinator(context: self) // Computed properties for compatibility @MainActor @@ -305,12 +309,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele var nostrRelayManager: NostrRelayManager? private let userDefaults = UserDefaults.standard let keychain: KeychainManagerProtocol + /// Private group membership: keys in the keychain, metadata on disk. + let groupStore: GroupStore private let nicknameKey = "bitchat.nickname" // Location channel state (macOS supports manual geohash selection) var activeChannel: ChannelID { get { conversations.activeChannel } set { guard conversations.activeChannel != newValue else { return } + // Leaving a channel expedites any in-flight NIP-13 mining: the + // pending message still sends, at the difficulty already reached. + outgoingCoordinator.expeditePendingGeohashMining() conversations.setActiveChannel(newValue) visibleMessagesCache = nil objectWillChange.send() @@ -809,6 +818,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele ) self.keychain = keychain + self.groupStore = GroupStore(keychain: keychain) self.idBridge = idBridge self.identityManager = identityManager self.conversations = conversations @@ -1205,6 +1215,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele GossipMessageArchive.wipeDefault() StoreAndForwardMetrics.shared.reset() + // Drop cached peers' prekey bundles (who we could write to is + // metadata too). Our own prekey privates are keychain-backed and go + // with deleteAllKeychainData above plus the identity reset below. + PrekeyBundleStore.shared.wipe() + + // Drop private group keys and rosters (keychain + disk) + groupStore.wipe() + + // Drop bulletin-board posts and tombstones (memory and disk); board + // posts are signed with our identity key and persist for days. + BoardStore.shared.wipe() + // Identity manager has cleared persisted identity data above // Clear autocomplete state @@ -1273,6 +1295,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Delete ALL media files (incoming and outgoing) in background Task.detached(priority: .utility) { + // The SPM test process shares the real Application Support tree, so + // this detached tree-delete can land mid-test under parallel + // scheduling and flake a file-dependent test; tests never need the + // on-disk media wiped. + guard !TestEnvironment.isRunningTests else { return } do { let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let filesDir = base.appendingPathComponent("files", isDirectory: true) @@ -1485,6 +1512,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele func setupNoiseCallbacks() { verificationCoordinator.setupNoiseCallbacks() + vouchCoordinator.setupNoiseCallbacks() + } + + /// Whether the fingerprint currently counts as vouched (≥1 valid vouch + /// from a voucher I verified, and no explicit verification of mine). + @MainActor + func isVouchedFingerprint(_ fingerprint: String) -> Bool { + identityManager.isVouched(fingerprint: fingerprint) } // MARK: - BitchatDelegate Methods @@ -1524,6 +1559,33 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele } } + /// Origin conversation for deferred command output, captured when the + /// command is issued (before any async work starts). + @MainActor + func currentCommandDestination() -> CommandOutputDestination { + if let peerID = selectedPrivateChatPeer { + return .privateChat(peerID) + } + // Deferring commands (/ping) are rejected in geohash channels, so a + // non-DM origin is always the #mesh timeline. + return .meshTimeline + } + + /// Routes deferred command output (async /ping results) into the + /// conversation captured at issue time, immune to chat switches in the + /// meantime. A DM result lands in the origin chat's history even if that + /// chat is no longer selected (or was cleared — it then reappears as the + /// first message when the chat is reopened). + @MainActor + func addCommandOutput(_ content: String, to destination: CommandOutputDestination) { + switch destination { + case .privateChat(let peerID): + addLocalPrivateSystemMessage(content, to: peerID) + case .meshTimeline: + publicConversationCoordinator.addMeshOnlySystemMessage(content) + } + } + // MARK: - Message Reception @MainActor @@ -1555,6 +1617,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele ) } + func didReceiveGroupMessage(payload: Data, timestamp: Date) { + Task { @MainActor [weak self] in + self?.groupCoordinator.handleGroupMessagePayload(payload, timestamp: timestamp) + } + } + // MARK: - QR Verification API @MainActor func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool { @@ -1677,12 +1745,27 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele publicConversationCoordinator.sendPublicRaw(content) } + // Send a normal public message (with local echo) to the active channel. + // CommandContextProvider hook for commands that post real messages + // (`/pay`); only called when no private chat is selected. + @MainActor + func sendPublicMessage(_ content: String) { + sendMessage(content) + } + /// Handle incoming public message @MainActor func handlePublicMessage(_ message: BitchatMessage) { publicConversationCoordinator.handlePublicMessage(message) } + /// Handle an incoming public Nostr message with its validated NIP-13 + /// difficulty; sufficient PoW relaxes the per-sender rate limit. + @MainActor + func handlePublicMessage(_ message: BitchatMessage, powBits: Int) { + publicConversationCoordinator.handlePublicMessage(message, powBits: powBits) + } + /// Check for mentions and send notifications func checkForMentions(_ message: BitchatMessage) { publicConversationCoordinator.checkForMentions(message) diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 35bc0ef4..722cd177 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -72,6 +72,7 @@ final class ChatViewModelBootstrapper { configureNoiseCallbacks() bindTransferProgress() configureGeoChannels() + configureGateway() bindTeleportState() requestNotifications() registerObservers() @@ -106,7 +107,7 @@ private extension ChatViewModelBootstrapper { category: .session ) viewModel.conversations.setDeliveryStatus( - .failed(reason: String(localized: "content.delivery.reason.not_delivered", comment: "Failure reason shown when the router gave up delivering a message")), + .failed(reason: String(localized: "content.delivery.reason.not_delivered", defaultValue: "not delivered", comment: "Failure reason shown when the router gave up delivering a message")), forMessageID: messageID ) } @@ -244,6 +245,72 @@ private extension ChatViewModelBootstrapper { ) } + /// Wires the gateway-mode policy layer (`GatewayService`) to the mesh + /// transport, the relay manager, and the inbound Nostr pipeline. All + /// dependencies are closures so the service stays unit-testable with + /// fakes. + func configureGateway() { + // Gateway mode bridges BLE mesh <-> Nostr; a mock transport (tests) + // has no carrier packets to bridge. + guard let bleService = viewModel.meshService as? BLEService else { return } + let gateway = GatewayService.shared + + gateway.publishToRelays = { event, geohash in + let relays = GeoRelayDirectory.shared.closestRelays( + toGeohash: geohash, + count: TransportConfig.nostrGeoRelayCount + ) + // Symmetric with the local send path (GeohashSubscriptionManager + // .sendGeohash): with no known geo relay, refuse rather than + // publish to default relays no geo subscriber reads — that would + // be silent dead traffic, not delivery. + guard !relays.isEmpty else { + SecureLogger.warning("🌐 Gateway: no geo relays for #\(geohash); not publishing carried event", category: .session) + return + } + NostrRelayManager.shared.sendEvent(event, to: relays) + } + gateway.broadcastToMesh = { [weak bleService] payload in + bleService?.broadcastNostrCarrier(payload) + } + gateway.sendToGatewayPeer = { [weak bleService] payload, peer in + bleService?.sendNostrCarrier(payload, to: peer) ?? false + } + gateway.availableGatewayPeers = { [weak bleService] in + bleService?.reachableGatewayPeers() ?? [] + } + gateway.relaysConnected = { NostrRelayManager.shared.isConnected } + gateway.currentGeohash = { [weak viewModel] in viewModel?.currentGeohash } + // Carried events enter the same pipeline as relay-received events so + // blocking, rate limits, dedup, and rendering behave identically. + gateway.injectInbound = { [weak viewModel] event in + viewModel?.handleNostrEvent(event) + } + // The capability bit is advertised ONLY while the toggle is on; a + // change forces a re-announce so peers learn promptly. + gateway.onEnabledChanged = { [weak bleService] enabled in + bleService?.setLocalCapability(.gateway, enabled: enabled) + } + bleService.onNostrCarrierPacket = { payload, from, directedToUs in + GatewayService.shared.handleMeshCarrier(payload, from: from, directedToUs: directedToUs) + } + + // Uplinks deposited while relays were unreachable flush on reconnect. + NostrRelayManager.shared.$isConnected + .receive(on: DispatchQueue.main) + .sink { connected in + if connected { + GatewayService.shared.flushQueuedUplinks() + } + } + .store(in: &viewModel.cancellables) + + // Apply the persisted toggle at launch. + if gateway.isEnabled { + bleService.setLocalCapability(.gateway, enabled: true) + } + } + func bindTeleportState() { viewModel.locationManager.$teleported .receive(on: DispatchQueue.main) diff --git a/bitchat/ViewModels/ChatVouchCoordinator.swift b/bitchat/ViewModels/ChatVouchCoordinator.swift new file mode 100644 index 00000000..fc13e07f --- /dev/null +++ b/bitchat/ViewModels/ChatVouchCoordinator.swift @@ -0,0 +1,210 @@ +import BitFoundation +import BitLogger +import Foundation + +/// The narrow surface `ChatVouchCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatVouchCoordinatorContextTests`) and makes its true +/// dependencies explicit. +@MainActor +protocol ChatVouchContext: AnyObject { + // MARK: Identity & trust state + func getFingerprint(for peerID: PeerID) -> String? + func isVerifiedFingerprint(_ fingerprint: String) -> Bool + /// The peer's announce-bound Ed25519 signing key, if known this session. + func signingKey(forFingerprint fingerprint: String) -> Data? + /// Verified fingerprints ordered most recently verified first. + func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] + /// Stores an accepted vouch (identity manager enforces the storage gates). + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool + func lastVouchBatchSent(to fingerprint: String) -> Date? + func markVouchBatchSent(to fingerprint: String, at date: Date) + + // MARK: Transport + func peerCapabilities(for peerID: PeerID) -> PeerCapabilities + /// Appends a session-established observer (additive; never displaces the + /// verification coordinator's callbacks). + func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) + /// Signs `data` with our Noise (Ed25519) signing key. + func noiseSignData(_ data: Data) -> Data? + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) + + // MARK: UI refresh + /// Signals that derived trust state changed so peer list / fingerprint + /// views recompute badges. + func notifyPeerTrustChanged() +} + +extension ChatViewModel: ChatVouchContext { + // `getFingerprint(for:)` and `isVerifiedFingerprint(_:)` are shared + // requirements with the verification context and satisfied by existing + // `ChatViewModel` members. The members below flatten nested service + // accesses into intent-named calls. + + func signingKey(forFingerprint fingerprint: String) -> Data? { + identityManager.signingPublicKey(forFingerprint: fingerprint) + } + + func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] { + identityManager.mostRecentlyVerifiedFingerprints(limit: limit, excluding: fingerprint) + } + + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool { + identityManager.recordVouch( + voucheeFingerprint: voucheeFingerprint, + voucherFingerprint: voucherFingerprint, + timestamp: timestamp + ) + } + + func lastVouchBatchSent(to fingerprint: String) -> Date? { + identityManager.lastVouchBatchSent(to: fingerprint) + } + + func markVouchBatchSent(to fingerprint: String, at date: Date) { + identityManager.markVouchBatchSent(to: fingerprint, at: date) + } + + func peerCapabilities(for peerID: PeerID) -> PeerCapabilities { + meshService.peerCapabilities(peerID) + } + + func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) { + meshService.addPeerAuthenticatedObserver(handler) + } + + func noiseSignData(_ data: Data) -> Data? { + meshService.noiseSignData(data) + } + + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) { + meshService.sendVouchAttestations(payload, to: peerID) + } + + func notifyPeerTrustChanged() { + // PeerListModel refreshes on this notification; the view-model change + // covers FingerprintView / VerificationModel consumers. + NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil) + notifyUIChanged() + } +} + +/// Transitive verification ("vouching"): when a Noise session comes up with a +/// peer I verified, I attest — over that authenticated, encrypted session — +/// to the other identities I have verified. Receivers accept such vouches +/// only from peers *they* verified, giving a serverless +/// verified-by-people-you-verified tier (`TrustLevel.vouched`). +@MainActor +final class ChatVouchCoordinator { + /// Minimum spacing between vouch batches to the same peer (persisted). + static let batchInterval: TimeInterval = 24 * 60 * 60 + + private unowned let context: any ChatVouchContext + + init(context: any ChatVouchContext) { + self.context = context + } + + /// Registers the session-established hook. Additive alongside the + /// verification coordinator's callbacks; call once at bootstrap. + func setupNoiseCallbacks() { + context.addPeerAuthenticatedObserver { [weak self] peerID, fingerprint in + DispatchQueue.main.async { [weak self] in + self?.peerAuthenticated(peerID, fingerprint: fingerprint) + } + } + } + + /// Exchange policy: on session establishment with a peer I verified that + /// advertises the `.vouch` capability, send attestations for up to + /// `VouchAttestation.maxBatchCount` *other* verified fingerprints (most + /// recently verified first), at most once per peer per `batchInterval`. + func peerAuthenticated(_ peerID: PeerID, fingerprint: String, now: Date = Date()) { + guard context.isVerifiedFingerprint(fingerprint) else { return } + guard context.peerCapabilities(for: peerID).contains(.vouch) else { return } + if let lastSent = context.lastVouchBatchSent(to: fingerprint), + now.timeIntervalSince(lastSent) < Self.batchInterval { + return + } + + let candidates = context.recentlyVerifiedFingerprints( + limit: VouchAttestation.maxBatchCount, + excluding: fingerprint + ) + var attestations: [VouchAttestation] = [] + for candidate in candidates { + // Only fingerprints whose announce-bound signing key we know can + // be anchored to a concrete identity; skip the rest. + guard let fingerprintData = Data(hexString: candidate), + fingerprintData.count == VouchAttestation.fingerprintSize, + let signingKey = context.signingKey(forFingerprint: candidate), + signingKey.count == VouchAttestation.signingKeySize, + let attestation = VouchAttestation.build( + voucheeFingerprint: fingerprintData, + voucheeSigningKey: signingKey, + timestampMs: UInt64(now.timeIntervalSince1970 * 1000), + sign: context.noiseSignData + ) else { + continue + } + attestations.append(attestation) + } + + guard !attestations.isEmpty, + let payload = VouchAttestation.encodeList(attestations) else { return } + context.sendVouchAttestations(payload, to: peerID) + context.markVouchBatchSent(to: fingerprint, at: now) + SecureLogger.debug( + "🪪 Sent \(attestations.count) vouch attestation(s) to \(peerID.id.prefix(8))…", + category: .security + ) + } + + /// Accept policy: process inbound vouches only from a sender I verified, + /// only with a valid Ed25519 signature under the sender's announce-bound + /// signing key, and only within the validity window. Self-vouches and + /// vouches for already-verified peers are dropped by the identity + /// manager's storage gates. + func handleVouchPayload(from peerID: PeerID, payload: Data, now: Date = Date()) { + guard let senderFingerprint = context.getFingerprint(for: peerID), + context.isVerifiedFingerprint(senderFingerprint) else { + SecureLogger.debug( + "🪪 Ignoring vouch payload from unverified peer \(peerID.id.prefix(8))…", + category: .security + ) + return + } + guard let senderSigningKey = context.signingKey(forFingerprint: senderFingerprint) else { + SecureLogger.debug( + "🪪 No signing key for vouching peer \(peerID.id.prefix(8))…; dropping batch", + category: .security + ) + return + } + + var acceptedCount = 0 + for attestation in VouchAttestation.decodeList(from: payload) { + guard attestation.verifySignature(voucherSigningKey: senderSigningKey), + !attestation.isExpired(now: now) else { continue } + let stored = context.recordVouch( + voucheeFingerprint: attestation.voucheeFingerprintHex, + voucherFingerprint: senderFingerprint, + timestamp: attestation.timestamp + ) + if stored { acceptedCount += 1 } + } + + if acceptedCount > 0 { + SecureLogger.info( + "🪪 Accepted \(acceptedCount) vouch(es) from \(senderFingerprint.prefix(8))…", + category: .security + ) + context.notifyPeerTrustChanged() + } + } +} diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift index af8d06aa..939adf6a 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift @@ -13,6 +13,12 @@ extension ChatViewModel { @MainActor func sendPrivateMessage(_ content: String, to peerID: PeerID) { + // Group chats reuse the private-chat surface but broadcast a sealed + // envelope instead of routing to a single peer. + if peerID.isGroup { + groupCoordinator.sendGroupMessage(content, to: peerID) + return + } privateConversationCoordinator.sendPrivateMessage(content, to: peerID) } diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift index 443849db..3221445f 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift @@ -19,7 +19,7 @@ extension ChatViewModel { self.torStatusAnnounced = true // Post only in geohash channels (queue if not active) self.addGeohashOnlySystemMessage( - String(localized: "system.tor.starting", comment: "System message when Tor is starting") + String(localized: "system.tor.starting", defaultValue: "starting tor...", comment: "System message when Tor is starting") ) } } @@ -30,7 +30,7 @@ extension ChatViewModel { self.torRestartPending = true // Post only in geohash channels (queue if not active) self.addGeohashOnlySystemMessage( - String(localized: "system.tor.restarting", comment: "System message when Tor is restarting") + String(localized: "system.tor.restarting", defaultValue: "tor restarting to recover connectivity...", comment: "System message when Tor is restarting") ) } } @@ -41,13 +41,13 @@ extension ChatViewModel { if self.torRestartPending { // Post only in geohash channels (queue if not active) self.addGeohashOnlySystemMessage( - String(localized: "system.tor.restarted", comment: "System message when Tor has restarted") + String(localized: "system.tor.restarted", defaultValue: "tor restarted. network routing restored.", comment: "System message when Tor has restarted") ) self.torRestartPending = false } else if TorManager.shared.torEnforced && !self.torInitialReadyAnnounced { // Initial start completed self.addGeohashOnlySystemMessage( - String(localized: "system.tor.started", comment: "System message when Tor has started") + String(localized: "system.tor.started", defaultValue: "tor started. routing all chats via tor for IP privacy.", comment: "System message when Tor has started") ) self.torInitialReadyAnnounced = true } diff --git a/bitchat/ViewModels/GeohashSubscriptionManager.swift b/bitchat/ViewModels/GeohashSubscriptionManager.swift index 4e267c59..ce4c9448 100644 --- a/bitchat/ViewModels/GeohashSubscriptionManager.swift +++ b/bitchat/ViewModels/GeohashSubscriptionManager.swift @@ -115,6 +115,9 @@ final class GeohashSubscriptionManager { private weak var context: (any GeohashSubscriptionContext)? private let inbound: NostrInboundPipeline private let presence: GeoPresenceTracker + /// Geohashes already told "sent via mesh gateway" this session, so the + /// notice appears once per channel instead of once per message. + private var gatewayNoticeGeohashes = Set() init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) { self.context = context @@ -145,6 +148,9 @@ final class GeohashSubscriptionManager { NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in Task { @MainActor [weak self] in self?.inbound.subscribeNostrEvent(event) + // Gateway downlink: rebroadcast relay events for the viewed + // channel onto the mesh (no-op unless gateway mode is on). + GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash) } } @@ -235,6 +241,9 @@ final class GeohashSubscriptionManager { NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in Task { @MainActor [weak self] in self?.inbound.handleNostrEvent(event) + // Gateway downlink: rebroadcast relay events for the viewed + // channel onto the mesh (no-op unless gateway mode is on). + GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash) } } @@ -280,6 +289,23 @@ final class GeohashSubscriptionManager { NostrRelayManager.shared.sendEvent(event, to: targetRelays) } + // Mesh gateway uplink: with no working relay connection, hand the + // locally signed event to a mesh peer advertising the gateway + // capability (keys never leave this device — only the finished, + // signed event travels). Uplink is only ever attempted here, for a + // freshly composed event, never for received carrier events (loop + // rule 3 in GatewayService). + if GatewayService.shared.uplinkViaMesh(event: event, geohash: channel.geohash), + gatewayNoticeGeohashes.insert(channel.geohash).inserted { + context.addPublicSystemMessage( + String( + localized: "system.gateway.sent_via_mesh", + defaultValue: "sent via mesh gateway", + comment: "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable" + ) + ) + } + context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex)) SecureLogger.debug( diff --git a/bitchat/ViewModels/MessageRateLimiter.swift b/bitchat/ViewModels/MessageRateLimiter.swift index 5bca9a6d..a0309447 100644 --- a/bitchat/ViewModels/MessageRateLimiter.swift +++ b/bitchat/ViewModels/MessageRateLimiter.swift @@ -48,15 +48,25 @@ struct MessageRateLimiter { self.contentRefill = contentRefillPerSec } - mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool { - var senderBucket = senderBuckets[senderKey] ?? TokenBucket( - capacity: senderCapacity, - tokens: senderCapacity, - refillPerSec: senderRefill, - lastRefill: now - ) - let senderAllowed = senderBucket.allow(now: now) - senderBuckets[senderKey] = senderBucket + /// - Parameter powBits: validated NIP-13 difficulty of the event + /// (`NostrPoW.validatedDifficulty`; 0 for mesh or no-PoW events). + /// At or above `NostrPoW.rateLimitBypassBits` the per-sender bucket is + /// skipped entirely — each such message paid for itself with work — but + /// the per-content flood bucket still applies. + mutating func allow(senderKey: String, contentKey: String, powBits: Int = 0, now: Date = Date()) -> Bool { + let senderAllowed: Bool + if powBits >= NostrPoW.rateLimitBypassBits { + senderAllowed = true + } else { + var senderBucket = senderBuckets[senderKey] ?? TokenBucket( + capacity: senderCapacity, + tokens: senderCapacity, + refillPerSec: senderRefill, + lastRefill: now + ) + senderAllowed = senderBucket.allow(now: now) + senderBuckets[senderKey] = senderBucket + } var contentBucket = contentBuckets[contentKey] ?? TokenBucket( capacity: contentCapacity, diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index 337fa474..f70cd891 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -32,7 +32,10 @@ protocol NostrInboundPipelineContext: AnyObject { func recordGeoParticipant(pubkeyHex: String) // MARK: Inbound public messages - func handlePublicMessage(_ message: BitchatMessage) + /// `powBits` is the validated NIP-13 difficulty of the source event + /// (`NostrPoW.validatedDifficulty`); it relaxes the per-sender rate limit + /// downstream. + func handlePublicMessage(_ message: BitchatMessage, powBits: Int) func checkForMentions(_ message: BitchatMessage) func sendHapticFeedback(for message: BitchatMessage) func parseMentions(from content: String) -> [String] @@ -152,6 +155,7 @@ final class NostrInboundPipeline { let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let timestamp = min(rawTs, Date()) let mentions = context.parseMentions(from: content) + let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags) let message = BitchatMessage( id: event.id, sender: senderName, @@ -165,7 +169,7 @@ final class NostrInboundPipeline { Task { @MainActor [weak context] in guard let context else { return } let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) - context.handlePublicMessage(message) + context.handlePublicMessage(message, powBits: powBits) if !isBlocked { context.checkForMentions(message) context.sendHapticFeedback(for: message) @@ -187,10 +191,12 @@ final class NostrInboundPipeline { guard event.isValidSignature() else { return } context.recordProcessedNostrEvent(event.id) + let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags) + // Sampled: fires for every geo event and floods dev logs in busy geohashes. geoEventLogCount += 1 if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) { - SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session) + SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session) } if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { @@ -255,7 +261,7 @@ final class NostrInboundPipeline { Task { @MainActor [weak context] in guard let context else { return } - context.handlePublicMessage(message) + context.handlePublicMessage(message, powBits: powBits) context.checkForMentions(message) context.sendHapticFeedback(for: message) } @@ -297,7 +303,11 @@ final class NostrInboundPipeline { context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey) case .readReceipt: context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey) - case .verifyChallenge, .verifyResponse: + // Group state travels only over mesh Noise sessions in v1; anything + // claiming to be group traffic over Nostr is ignored. Wi-Fi bulk + // negotiation is mesh-proximity only; it never rides Nostr either. + case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate, + .bulkTransferOffer, .bulkTransferResponse: break } } @@ -349,7 +359,11 @@ final class NostrInboundPipeline { context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) case .readReceipt: context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) - case .verifyChallenge, .verifyResponse: + // Group state travels only over mesh Noise sessions in v1; anything + // claiming to be group traffic over Nostr is ignored. Wi-Fi bulk + // negotiation is mesh-proximity only; it never rides Nostr either. + case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate, + .bulkTransferOffer, .bulkTransferResponse: break } } @@ -428,7 +442,12 @@ final class NostrInboundPipeline { context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID) case .readReceipt: context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) - case .verifyChallenge, .verifyResponse: + // Group state travels only over mesh Noise sessions + // in v1; group traffic over Nostr is ignored. Wi-Fi + // bulk negotiation is mesh-proximity only; it never + // rides Nostr either. + case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate, + .bulkTransferOffer, .bulkTransferResponse: break } } diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index 152c09df..496f0230 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -1,10 +1,31 @@ import SwiftUI +#if DEBUG +import BitLogger +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif +#endif struct AppInfoView: View { @Environment(\.dismiss) var dismiss @ThemedPalette private var palette @AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue + /// Supplies the mesh topology map data. Nil (previews, missing wiring) + /// hides the topology row entirely. + var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)? + @State private var showTopology = false + + #if DEBUG + // Opt-in LAN log streaming + in-app export. DEBUG-only; empty host = off. + @AppStorage(LogNetworkSink.hostDefaultsKey) private var logSinkHost = "" + @AppStorage(LogNetworkSink.portDefaultsKey) private var logSinkPort = LogNetworkSink.defaultPort + @State private var exportedLogURL: URL? + @State private var isPresentingLogShare = false + #endif + private var selectedTheme: AppTheme { AppTheme(rawValue: appThemeRawValue) ?? .matrix } @@ -75,6 +96,15 @@ struct AppInfoView: View { ] } + enum Network { + static let title: LocalizedStringKey = "app_info.network.title" + static let topology = AppInfoFeatureInfo( + icon: "point.3.connected.trianglepath.dotted", + title: "app_info.network.topology.title", + description: "app_info.network.topology.description" + ) + } + enum Privacy { static let title: LocalizedStringKey = "app_info.privacy.title" static let noTracking = AppInfoFeatureInfo( @@ -114,7 +144,7 @@ struct AppInfoView: View { // Custom header for macOS HStack { Spacer() - Button("app_info.done") { + Button(String(localized: "app_info.done", defaultValue: "DONE")) { dismiss() } .buttonStyle(.plain) @@ -129,6 +159,11 @@ struct AppInfoView: View { .themedSheetBackground() } .frame(width: 600, height: 700) + .sheet(isPresented: $showTopology) { + if let topologyProvider { + MeshTopologyView(provider: topologyProvider) + } + } #else NavigationView { ScrollView { @@ -143,6 +178,11 @@ struct AppInfoView: View { } } } + .sheet(isPresented: $showTopology) { + if let topologyProvider { + MeshTopologyView(provider: topologyProvider) + } + } #endif } @@ -199,6 +239,27 @@ struct AppInfoView: View { .foregroundColor(textColor) } + // Network diagnostics + if topologyProvider != nil { + VStack(alignment: .leading, spacing: 16) { + SectionHeader(Strings.Network.title) + + Button { + showTopology = true + } label: { + HStack(spacing: 0) { + FeatureRow(info: Strings.Network.topology) + Image(systemName: "chevron.right") + .font(.bitchatSystem(size: 12)) + .foregroundColor(secondaryTextColor) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityHint(Text(String(localized: "app_info.network.topology.hint", defaultValue: "opens the mesh topology map"))) + } + } + // Features VStack(alignment: .leading, spacing: 16) { SectionHeader(Strings.Features.title) @@ -248,11 +309,122 @@ struct AppInfoView: View { FeatureRow(info: Strings.Privacy.panic) } + + #if DEBUG + debugSection + #endif } .padding() } + + #if DEBUG + // Tester-only observability panel: live LAN log streaming + a logs export. + // Never compiled into release (privacy-first: release ships no such UI). + @ViewBuilder + private var debugSection: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader("debug & logs") + + Text("Stream sanitized logs over the LAN to a collector (nc -lu \(logSinkPort)). Empty host = off.") + .bitchatFont(size: 12) + .foregroundColor(secondaryTextColor) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 8) { + TextField("collector host (e.g. 192.168.1.5)", text: $logSinkHost) + .textFieldStyle(.roundedBorder) + .bitchatFont(size: 13) + #if os(iOS) + .keyboardType(.numbersAndPunctuation) + .autocapitalization(.none) + .disableAutocorrection(true) + #endif + .accessibilityLabel(Text("log collector host")) + + TextField("port", value: $logSinkPort, format: .number.grouping(.never)) + .textFieldStyle(.roundedBorder) + .bitchatFont(size: 13) + .frame(width: 70) + #if os(iOS) + .keyboardType(.numberPad) + #endif + .accessibilityLabel(Text("log collector port")) + } + .onChange(of: logSinkHost) { _ in LogNetworkSink.shared.reloadConfiguration() } + .onChange(of: logSinkPort) { _ in LogNetworkSink.shared.reloadConfiguration() } + + Button(action: exportLogs) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "square.and.arrow.up") + .font(.bitchatSystem(size: 20)) + .foregroundColor(textColor) + .frame(width: 30) + VStack(alignment: .leading, spacing: 4) { + Text("Export Logs") + .bitchatFont(size: 14, weight: .semibold) + .foregroundColor(textColor) + Text("Share the recent in-memory log buffer as a .txt file.") + .bitchatFont(size: 12) + .foregroundColor(secondaryTextColor) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("Export logs")) + .accessibilityHint(Text("Shares the recent log buffer as a text file")) + } + #if os(iOS) + .sheet(isPresented: $isPresentingLogShare) { + if let exportedLogURL { + LogShareSheet(activityItems: [exportedLogURL]) + } + } + #endif + } + + /// Write the buffered log to a temp `.txt` and present the platform share. + private func exportLogs() { + let text = LogExportBuffer.shared.snapshot() + let stamp = ISO8601DateFormatter().string(from: Date()) + .replacingOccurrences(of: ":", with: "-") + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("bitchat-logs-\(stamp).txt") + do { + try text.data(using: .utf8)?.write(to: url) + } catch { + return + } + #if os(iOS) + exportedLogURL = url + isPresentingLogShare = true + #elseif os(macOS) + let panel = NSSavePanel() + panel.nameFieldStringValue = url.lastPathComponent + panel.allowedContentTypes = [.plainText] + if panel.runModal() == .OK, let dest = panel.url { + try? text.data(using: .utf8)?.write(to: dest) + } + #endif + } + #endif } +#if DEBUG && os(iOS) +/// Minimal UIActivityViewController bridge for the Export Logs share sheet. +private struct LogShareSheet: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + + func updateUIViewController(_ controller: UIActivityViewController, context: Context) {} +} +#endif + struct AppInfoFeatureInfo { let icon: String let title: LocalizedStringKey diff --git a/bitchat/Views/BoardView.swift b/bitchat/Views/BoardView.swift new file mode 100644 index 00000000..8c443847 --- /dev/null +++ b/bitchat/Views/BoardView.swift @@ -0,0 +1,270 @@ +// +// BoardView.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +/// The bulletin board for one context: a geohash channel, or the mesh-local +/// board when `geohash` is empty. Urgent posts pin to the top; own posts can +/// be swipe-deleted, which broadcasts a signed tombstone. +struct BoardView: View { + /// Empty string = mesh-local board. + let geohash: String + let senderNickname: String + @ObservedObject var board: BoardManager + + @ThemedPalette private var palette + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.dismiss) private var dismiss + @State private var draft: String = "" + @State private var urgent = false + @State private var expiryDays = 7 + + private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } + private var posts: [BoardPostPacket] { board.posts(forGeohash: geohash) } + + private enum Strings { + static let boardName = String(localized: "board.title", defaultValue: "board", comment: "Title prefix of the bulletin board sheet") + static let description = String(localized: "board.description", defaultValue: "persistent notices carried by the mesh. posts are signed, spread device-to-device, and expire on their own.", comment: "Explainer under the board sheet title") + static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts") + static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts") + static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts") + static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer") + static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field") + static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button") + static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts") + static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker") + static let closeHint = String(localized: "board.accessibility.close", defaultValue: "Close board", comment: "Accessibility label for the board close button") + + static func expiryDaysOption(_ days: Int) -> String { + String( + format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"), + locale: .current, + days + ) + } + + static func postAccessibilityLabel(author: String, content: String, urgent: Bool) -> String { + let base = String( + format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"), + locale: .current, + author, content + ) + return urgent ? "\(urgentBadge), \(base)" : base + } + } + + var body: some View { + VStack(spacing: 0) { + headerSection + postList + composer + } + .themedSurface() + #if os(macOS) + .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680) + #endif + .themedSheetBackground() + } + + private var headerSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Text(verbatim: geohash.isEmpty ? "\(Strings.boardName) @ #mesh" : "\(Strings.boardName) @ #\(geohash)") + .bitchatFont(size: 18) + Spacer() + SheetCloseButton { dismiss() } + .accessibilityLabel(Strings.closeHint) + } + Text(Strings.description) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 12) + .themedSurface() + } + + private var postList: some View { + Group { + if posts.isEmpty { + ScrollView { + VStack(alignment: .leading, spacing: 4) { + Text(Strings.emptyTitle) + .bitchatFont(size: 13, weight: .semibold) + Text(Strings.emptySubtitle) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + } else { + List { + ForEach(posts, id: \.postID) { post in + postRow(post) + .listRowBackground(palette.background) + .listRowSeparatorTint(palette.divider) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .themedSurface() + } + + private func postRow(_ post: BoardPostPacket) -> some View { + let isOwn = board.isOwnPost(post) + let author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon" + return VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + if post.isUrgent { + Image(systemName: "exclamationmark.triangle.fill") + .font(.bitchatSystem(size: 11)) + .foregroundColor(palette.alertRed) + Text(Strings.urgentBadge) + .bitchatFont(size: 11, weight: .semibold) + .foregroundColor(palette.alertRed) + } + Text(verbatim: "@\(author)") + .bitchatFont(size: 12, weight: .semibold) + Text(timestampText(forMs: post.createdAt)) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary) + Spacer() + if isOwn { + Button { + board.deletePost(post) + } label: { + Image(systemName: "trash") + .font(.bitchatSystem(size: 12)) + .foregroundColor(palette.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(Strings.deleteAction) + } + } + Text(post.content) + .bitchatFont(size: 14) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Strings.postAccessibilityLabel(author: author, content: post.content, urgent: post.isUrgent)) + .accessibilityActions { + if isOwn { + Button(Strings.deleteAction) { board.deletePost(post) } + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if isOwn { + Button(role: .destructive) { + board.deletePost(post) + } label: { + Label(Strings.deleteAction, systemImage: "trash") + } + } + } + } + + private var composer: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 10) { + TextField(Strings.placeholder, text: $draft, axis: .vertical) + .textFieldStyle(.plain) + .bitchatFont(size: 14) + .lineLimit(maxDraftLines, reservesSpace: true) + .padding(.vertical, 6) + Button(action: send) { + Image(systemName: "arrow.up.circle.fill") + .font(.bitchatSystem(size: 20)) + .foregroundColor(sendEnabled ? palette.accent : .secondary) + } + .padding(.top, 2) + .buttonStyle(.plain) + .disabled(!sendEnabled) + .accessibilityLabel(Strings.send) + } + HStack(spacing: 12) { + Toggle(isOn: $urgent) { + Text(Strings.urgentToggle) + .bitchatFont(size: 12) + .foregroundColor(urgent ? palette.alertRed : palette.secondary) + } + .toggleStyle(.switch) + .fixedSize() + .accessibilityLabel(Strings.urgentToggle) + Spacer() + Text(Strings.expiryLabel) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + Picker(Strings.expiryLabel, selection: $expiryDays) { + ForEach([1, 3, 7], id: \.self) { days in + Text(Strings.expiryDaysOption(days)).tag(days) + } + } + .pickerStyle(.segmented) + .fixedSize() + .accessibilityLabel(Strings.expiryLabel) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .themedSurface() + .overlay(Divider(), alignment: .top) + } + + private var sendEnabled: Bool { + let trimmed = draft.trimmed + return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes + } + + private func send() { + guard let content = draft.trimmedOrNilIfEmpty else { return } + let sent = board.createPost( + content: content, + geohash: geohash, + urgent: urgent, + expiryDays: expiryDays, + nickname: senderNickname + ) + if sent { + draft = "" + urgent = false + } + } + + private func timestampText(forMs ms: UInt64) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000) + let now = Date() + if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 { + let rel = Self.relativeFormatter.string(from: date, to: now) ?? "" + return rel.isEmpty ? "" : "\(rel) ago" + } + return Self.absDateFormatter.string(from: date) + } + + private static let relativeFormatter: DateComponentsFormatter = { + let f = DateComponentsFormatter() + f.allowedUnits = [.day, .hour, .minute] + f.maximumUnitCount = 1 + f.unitsStyle = .abbreviated + f.collapsesLargestUnit = true + return f + }() + + private static let absDateFormatter: DateFormatter = { + let f = DateFormatter() + f.setLocalizedDateFormatFromTemplate("MMM d") + return f + }() +} diff --git a/bitchat/Views/Components/DeliveryStatusView.swift b/bitchat/Views/Components/DeliveryStatusView.swift index e106d2c1..f54ee88b 100644 --- a/bitchat/Views/Components/DeliveryStatusView.swift +++ b/bitchat/Views/Components/DeliveryStatusView.swift @@ -16,32 +16,32 @@ extension DeliveryStatus { var bitchatDescription: String { switch self { case .sending: - return String(localized: "content.delivery.sending", comment: "Delivery status description while a private message is being sent") + return String(localized: "content.delivery.sending", defaultValue: "sending...", comment: "Delivery status description while a private message is being sent") case .sent: - return String(localized: "content.delivery.sent", comment: "Delivery status description for a sent but not yet confirmed private message") + return String(localized: "content.delivery.sent", defaultValue: "sent — no delivery confirmation yet", comment: "Delivery status description for a sent but not yet confirmed private message") case .carried: return String(localized: "content.delivery.carried", defaultValue: "Carried by a friend who may meet them", comment: "Delivery status description for messages handed to a courier for physical delivery") case .delivered(let nickname, _): return String( - format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"), + format: String(localized: "content.delivery.delivered_to", defaultValue: "delivered to %@", comment: "Tooltip for delivered private messages"), locale: .current, nickname ) case .read(let nickname, _): return String( - format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"), + format: String(localized: "content.delivery.read_by", defaultValue: "read by %@", comment: "Tooltip for read private messages"), locale: .current, nickname ) case .failed(let reason): return String( - format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"), + format: String(localized: "content.delivery.failed", defaultValue: "failed: %@", comment: "Tooltip for failed message delivery"), locale: .current, reason ) case .partiallyDelivered(let reached, let total): return String( - format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"), + format: String(localized: "content.delivery.delivered_members", defaultValue: "delivered to %1$d of %2$d members", comment: "Tooltip for partially delivered messages"), locale: .current, reached, total diff --git a/bitchat/Views/Components/PaymentChipView.swift b/bitchat/Views/Components/PaymentChipView.swift index e01887d0..5e09e0b3 100644 --- a/bitchat/Views/Components/PaymentChipView.swift +++ b/bitchat/Views/Components/PaymentChipView.swift @@ -7,12 +7,17 @@ // import SwiftUI +#if os(iOS) +import UIKit +#else +import AppKit +#endif struct PaymentChipView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.openURL) private var openURL @ThemedPalette private var palette - + enum PaymentType { case cashu(String) case lightning(String) @@ -35,44 +40,92 @@ struct PaymentChipView: View { return URL(string: link) } } - + + /// The bare `cashuA…`/`cashuB…` bearer string, when this is a Cashu chip. + var cashuToken: String? { + if case .cashu(let link) = self { + return CashuTokenDecoder.bareToken(from: link) + } + return nil + } + + /// Web fallback for redemption when no wallet handles `cashu:` URLs. + /// The token only reaches the site the user's browser loads; the app + /// itself never contacts a mint. + var cashuWebRedeemURL: URL? { + guard let token = cashuToken, + let enc = token.addingPercentEncoding(withAllowedCharacters: Self.cashuAllowedCharacters) else { + return nil + } + return URL(string: "https://redeem.cashu.me/?token=\(enc)") + } + var emoji: String { switch self { case .cashu: "🥜" case .lightning: "⚡" } } - + var label: String { switch self { case .cashu: - String(localized: "content.payment.cashu", comment: "Label for Cashu payment chip") + String(localized: "content.payment.cashu", defaultValue: "pay via cashu", comment: "Label for Cashu payment chip") case .lightning: - String(localized: "content.payment.lightning", comment: "Label for Lightning payment chip") + String(localized: "content.payment.lightning", defaultValue: "pay via lightning", comment: "Label for Lightning payment chip") } } } - + let paymentType: PaymentType - + /// Decoded once at construction; tokens are capped in size so this is + /// cheap, and rows re-render often enough that lazy decode in `body` + /// would just repeat the work. + private let cashuInfo: CashuTokenDecoder.TokenInfo? + + init(paymentType: PaymentType) { + self.paymentType = paymentType + if case .cashu(let link) = paymentType { + self.cashuInfo = CashuTokenDecoder.decode(link) + } else { + self.cashuInfo = nil + } + } + private var fgColor: Color { palette.primary } private var bgColor: Color { palette.secondary.opacity(colorScheme == .dark ? 0.18 : 0.12) } private var border: Color { fgColor.opacity(0.25) } - + + /// "500 sat · mint.example.com", degrading to the generic label when the + /// token didn't decode (V4 payloads we can't walk, malformed input…). + private var primaryLabel: String { + guard let info = cashuInfo else { return paymentType.label } + var parts: [String] = [] + if let amount = info.displayAmount { parts.append(amount) } + if let host = info.mintHost { parts.append(host) } + return parts.isEmpty ? paymentType.label : parts.joined(separator: " · ") + } + + private var memoLabel: String? { cashuInfo?.memo } + var body: some View { Button { - #if os(iOS) - if let url = paymentType.url { openURL(url) } - #else - if let url = paymentType.url { NSWorkspace.shared.open(url) } - #endif + primaryAction() } label: { HStack(spacing: 6) { Text(paymentType.emoji) - Text(paymentType.label) - .bitchatFont(size: 12, weight: .semibold) + VStack(alignment: .leading, spacing: 1) { + Text(primaryLabel) + .bitchatFont(size: 12, weight: .semibold) + if let memoLabel { + Text(memoLabel) + .bitchatFont(size: 10) + .opacity(0.7) + .lineLimit(1) + } + } } .padding(.vertical, 6) .padding(.horizontal, 12) @@ -87,13 +140,102 @@ struct PaymentChipView: View { .foregroundColor(fgColor) } .buttonStyle(.plain) + .contextMenu { + if let token = paymentType.cashuToken { + Button { + copyToPasteboard(token) + } label: { + Label(String(localized: "content.payment.copy_token", defaultValue: "copy token", comment: "Context menu action copying a Cashu token to the pasteboard"), systemImage: "doc.on.doc") + } + Button { + redeemCashu() + } label: { + Label(String(localized: "content.payment.redeem_wallet", defaultValue: "redeem in wallet", comment: "Context menu action opening a Cashu token in an ecash wallet app"), systemImage: "wallet.pass") + } + if let webURL = paymentType.cashuWebRedeemURL { + Button { + openExternalURL(webURL) + } label: { + Label(String(localized: "content.payment.redeem_web", defaultValue: "redeem on web", comment: "Context menu action opening a Cashu token in the web redemption page"), systemImage: "safari") + } + } + } + } + .accessibilityLabel(Text(verbatim: accessibilityText)) + } + + private var accessibilityText: String { + var text = "\(paymentType.label): \(primaryLabel)" + if let memoLabel { text += ", \(memoLabel)" } + return text + } + + // MARK: - Actions + + private func primaryAction() { + switch paymentType { + case .cashu: + redeemCashu() + case .lightning: + #if os(iOS) + if let url = paymentType.url { openURL(url) } + #else + if let url = paymentType.url { NSWorkspace.shared.open(url) } + #endif + } + } + + /// Redemption is delegated: try a wallet registered for `cashu:` URLs + /// first, then fall back to the web redemption page. Uses the platform + /// opener directly (not the `openURL` environment) because the message + /// list overrides that action for cashu/lightning schemes without a + /// fallback path. + private func redeemCashu() { + let walletURL = paymentType.url + let webURL = paymentType.cashuWebRedeemURL + #if os(iOS) + if let walletURL { + UIApplication.shared.open(walletURL, options: [:]) { accepted in + if !accepted, let webURL { + UIApplication.shared.open(webURL) + } + } + } else if let webURL { + UIApplication.shared.open(webURL) + } + #else + if let walletURL, NSWorkspace.shared.urlForApplication(toOpen: walletURL) != nil { + NSWorkspace.shared.open(walletURL) + } else if let webURL { + NSWorkspace.shared.open(webURL) + } else if let walletURL { + NSWorkspace.shared.open(walletURL) + } + #endif + } + + private func openExternalURL(_ url: URL) { + #if os(iOS) + UIApplication.shared.open(url) + #else + NSWorkspace.shared.open(url) + #endif + } + + private func copyToPasteboard(_ string: String) { + #if os(iOS) + UIPasteboard.general.string = string + #else + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(string, forType: .string) + #endif } } #Preview { let cashuLink = "https://example.com/cashu" let lightningLink = "https://example.com/lightning" - + List { HStack { PaymentChipView(paymentType: .cashu(cashuLink)) diff --git a/bitchat/Views/Components/SheetCloseButton.swift b/bitchat/Views/Components/SheetCloseButton.swift index e2891e35..a1dee0bc 100644 --- a/bitchat/Views/Components/SheetCloseButton.swift +++ b/bitchat/Views/Components/SheetCloseButton.swift @@ -24,6 +24,6 @@ struct SheetCloseButton: View { .contentShape(Rectangle().inset(by: -6)) } .buttonStyle(.plain) - .accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons")) + .accessibilityLabel(String(localized: "common.close", defaultValue: "close", comment: "Accessibility label for close buttons")) } } diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index 4c96b201..36f5ab81 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -69,7 +69,7 @@ struct TextMessageView: View { } .buttonStyle(.plain) .accessibilityHint( - String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details") + String(localized: "content.accessibility.delivery_detail_hint", defaultValue: "tap to show delivery details", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details") ) } } diff --git a/bitchat/Views/ContentComposerView.swift b/bitchat/Views/ContentComposerView.swift index a3ec8d0f..bc154792 100644 --- a/bitchat/Views/ContentComposerView.swift +++ b/bitchat/Views/ContentComposerView.swift @@ -118,17 +118,17 @@ private extension ContentComposerView { let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true let target = isGeoDM ? header.displayName : "@\(header.displayName)" return String( - format: String(localized: "content.input.placeholder.private", comment: "Composer placeholder inside a private chat, naming the conversation partner"), + format: String(localized: "content.input.placeholder.private", defaultValue: "message %@ — private", comment: "Composer placeholder inside a private chat, naming the conversation partner"), locale: .current, target ) } switch locationChannelsModel.selectedChannel { case .mesh: - return String(localized: "content.input.placeholder.mesh", comment: "Composer placeholder for the public mesh channel") + return String(localized: "content.input.placeholder.mesh", defaultValue: "message #mesh — public, nearby", comment: "Composer placeholder for the public mesh channel") case .location(let channel): return String( - format: String(localized: "content.input.placeholder.location", comment: "Composer placeholder for a public geohash channel, naming it"), + format: String(localized: "content.input.placeholder.location", defaultValue: "message #%@ — public", comment: "Composer placeholder for a public geohash channel, naming it"), locale: .current, channel.geohash ) @@ -184,15 +184,15 @@ private extension ContentComposerView { showImagePicker = true } .accessibilityLabel( - String(localized: "content.accessibility.attach_photo", comment: "Accessibility label for the photo attachment button") + String(localized: "content.accessibility.attach_photo", defaultValue: "attach photo", comment: "Accessibility label for the photo attachment button") ) .accessibilityHint( - String(localized: "content.accessibility.attach_photo_hint", comment: "Accessibility hint explaining the attachment button opens the photo library") + String(localized: "content.accessibility.attach_photo_hint", defaultValue: "opens the photo library; use the take photo action for the camera", comment: "Accessibility hint explaining the attachment button opens the photo library") ) .accessibilityAddTraits(.isButton) // The long-press → camera path is unreachable for VoiceOver users; // mirror it as a named action. - .accessibilityAction(named: Text("content.accessibility.take_photo", comment: "Accessibility action name for taking a photo with the camera")) { + .accessibilityAction(named: Text(String(localized: "content.accessibility.take_photo", defaultValue: "take photo with camera", comment: "Accessibility action name for taking a photo with the camera"))) { imagePickerSourceType = .camera showImagePicker = true } @@ -205,7 +205,7 @@ private extension ContentComposerView { } .buttonStyle(.plain) .accessibilityLabel( - String(localized: "content.accessibility.choose_photo", comment: "Accessibility label for the macOS photo picker button") + String(localized: "content.accessibility.choose_photo", defaultValue: "choose photo", comment: "Accessibility label for the macOS photo picker button") ) #endif } @@ -249,15 +249,15 @@ private extension ContentComposerView { ) ) .accessibilityLabel( - String(localized: "content.accessibility.record_voice_note", comment: "Accessibility label for the voice note button") + String(localized: "content.accessibility.record_voice_note", defaultValue: "record voice note", comment: "Accessibility label for the voice note button") ) .accessibilityValue( voiceRecordingVM.state.isActive - ? String(localized: "content.accessibility.recording", comment: "Accessibility value announced while a voice note is recording") + ? String(localized: "content.accessibility.recording", defaultValue: "recording", comment: "Accessibility value announced while a voice note is recording") : "" ) .accessibilityHint( - String(localized: "content.accessibility.record_voice_hint", comment: "Accessibility hint explaining double-tap toggles voice recording") + String(localized: "content.accessibility.record_voice_hint", defaultValue: "double-tap to start recording, double-tap again to send", comment: "Accessibility hint explaining double-tap toggles voice recording") ) .accessibilityAddTraits(.isButton) // Press-and-hold drag gestures can't be activated by VoiceOver; @@ -282,12 +282,12 @@ private extension ContentComposerView { .buttonStyle(.plain) .disabled(!enabled) .accessibilityLabel( - String(localized: "content.accessibility.send_message", comment: "Accessibility label for the send message button") + String(localized: "content.accessibility.send_message", defaultValue: "send message", comment: "Accessibility label for the send message button") ) .accessibilityHint( enabled - ? String(localized: "content.accessibility.send_hint_ready", comment: "Hint prompting the user to send the message") - : String(localized: "content.accessibility.send_hint_empty", comment: "Hint prompting the user to enter a message") + ? String(localized: "content.accessibility.send_hint_ready", defaultValue: "double tap to send", comment: "Hint prompting the user to send the message") + : String(localized: "content.accessibility.send_hint_empty", defaultValue: "enter a message to send", comment: "Hint prompting the user to enter a message") ) } } diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index eb188ed7..e61f06af 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -25,6 +25,9 @@ struct ContentHeaderView: View { /// Courier envelopes this device is carrying for offline third parties. @State private var carriedMailCount = 0 + /// Bulletin board sheet for the current channel context. + @State private var showBoard = false + var body: some View { HStack(spacing: 0) { Text(verbatim: "bitchat/") @@ -41,7 +44,7 @@ struct ContentHeaderView: View { // stays undiscoverable on purpose — it's destructive.) .accessibilityAddTraits(.isButton) .accessibilityHint( - String(localized: "content.accessibility.app_info_hint", comment: "Accessibility hint on the bitchat/ logo explaining a tap opens app info") + String(localized: "content.accessibility.app_info_hint", defaultValue: "shows app info", comment: "Accessibility hint on the bitchat/ logo explaining a tap opens app info") ) .accessibilityAction { appChromeModel.presentAppInfo() @@ -53,7 +56,7 @@ struct ContentHeaderView: View { .foregroundColor(palette.secondary) TextField( - "content.input.nickname_placeholder", + String(localized: "content.input.nickname_placeholder", defaultValue: "nickname"), text: Binding( get: { appChromeModel.nickname }, set: { appChromeModel.setNickname($0) } @@ -91,6 +94,19 @@ struct ContentHeaderView: View { }() HStack(spacing: 2) { + if locationChannelsModel.gatewayEnabled { + Image(systemName: "globe") + .font(.bitchatSystem(size: 12)) + .foregroundColor(palette.secondary.opacity(0.8)) + .headerTapTarget() + .accessibilityLabel( + String(localized: "content.accessibility.gateway_active", defaultValue: "Internet gateway active, sharing your connection with the mesh", comment: "Accessibility label for the internet gateway indicator") + ) + .help( + String(localized: "content.header.gateway_active", defaultValue: "Sharing your internet connection with nearby mesh peers", comment: "Tooltip for the internet gateway indicator") + ) + } + if carriedMailCount > 0 { Image(systemName: "figure.walk") .font(.bitchatSystem(size: 12)) @@ -117,7 +133,7 @@ struct ContentHeaderView: View { } .buttonStyle(.plain) .accessibilityLabel( - String(localized: "content.accessibility.open_unread_private_chat", comment: "Accessibility label for the unread private chat button") + String(localized: "content.accessibility.open_unread_private_chat", defaultValue: "open unread private chat", comment: "Accessibility label for the unread private chat button") ) } @@ -135,10 +151,24 @@ struct ContentHeaderView: View { } .buttonStyle(.plain) .accessibilityLabel( - String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button") + String(localized: "content.accessibility.location_notes", defaultValue: "location notes for this place", comment: "Accessibility label for location notes button") ) } + Button(action: { showBoard = true }) { + Image(systemName: "pin") + .font(.bitchatSystem(size: 12)) + .foregroundColor(palette.secondary.opacity(0.9)) + .headerTapTarget() + } + .buttonStyle(.plain) + .accessibilityLabel( + String(localized: "content.accessibility.board", defaultValue: "Bulletin board", comment: "Accessibility label for the bulletin board button") + ) + .help( + String(localized: "content.header.board", defaultValue: "Bulletin board: persistent notices for this channel", comment: "Tooltip for the bulletin board button") + ) + if case .location(let channel) = locationChannelsModel.selectedChannel { Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) { Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark") @@ -148,7 +178,7 @@ struct ContentHeaderView: View { .buttonStyle(.plain) .accessibilityLabel( String( - format: String(localized: "content.accessibility.toggle_bookmark", comment: "Accessibility label for toggling a geohash bookmark"), + format: String(localized: "content.accessibility.toggle_bookmark", defaultValue: "toggle bookmark for #%@", comment: "Accessibility label for toggling a geohash bookmark"), locale: .current, channel.geohash ) @@ -181,7 +211,7 @@ struct ContentHeaderView: View { .frame(maxHeight: .infinity) .contentShape(Rectangle()) .accessibilityLabel( - String(localized: "content.accessibility.location_channels", comment: "Accessibility label for the location channels button") + String(localized: "content.accessibility.location_channels", defaultValue: "location channels", comment: "Accessibility label for the location channels button") ) } .buttonStyle(.plain) @@ -208,7 +238,7 @@ struct ContentHeaderView: View { .buttonStyle(.plain) .accessibilityLabel( String( - format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"), + format: String(localized: "content.accessibility.people_count", defaultValue: "%#@people@", comment: "Accessibility label announcing number of people in header"), locale: .current, headerOtherPeersCount ) @@ -217,8 +247,8 @@ struct ContentHeaderView: View { // color; say it. .accessibilityValue( headerPeersReachable - ? String(localized: "content.accessibility.peers_connected", comment: "Accessibility value when peers are reachable") - : String(localized: "content.accessibility.peers_none", comment: "Accessibility value when no peers are reachable") + ? String(localized: "content.accessibility.peers_connected", defaultValue: "connected", comment: "Accessibility value when peers are reachable") + : String(localized: "content.accessibility.peers_none", defaultValue: "no one reachable", comment: "Accessibility value when no peers are reachable") ) } .layoutPriority(3) @@ -242,6 +272,13 @@ struct ContentHeaderView: View { .environmentObject(locationChannelsModel) .environmentObject(peerListModel) } + .sheet(isPresented: $showBoard) { + BoardView( + geohash: boardGeohash, + senderNickname: appChromeModel.nickname, + board: appChromeModel.boardManager + ) + } .sheet(isPresented: $showLocationNotes, onDismiss: { notesGeohash = nil }) { @@ -288,10 +325,10 @@ struct ContentHeaderView: View { .onChange(of: locationChannelsModel.permissionState) { _ in locationChannelsModel.refreshMeshChannelsIfNeeded() } - .alert("content.alert.screenshot.title", isPresented: $appChromeModel.showScreenshotPrivacyWarning) { - Button("common.ok", role: .cancel) {} + .alert(String(localized: "content.alert.screenshot.title", defaultValue: "heads up"), isPresented: $appChromeModel.showScreenshotPrivacyWarning) { + Button(String(localized: "common.ok", defaultValue: "OK"), role: .cancel) {} } message: { - Text("content.alert.screenshot.message") + Text(String(localized: "content.alert.screenshot.message", defaultValue: "screenshots of location channels will reveal your location. think before sharing publicly.")) } .themedChromePanel(edge: .top) } @@ -311,6 +348,15 @@ private extension ContentHeaderView { dynamicTypeSize.isAccessibilitySize ? 2 : 1 } + /// The board scope for the current channel: the geohash channel's board, + /// or the mesh-local board ("") in mesh chat. + var boardGeohash: String { + if case .location(let channel) = locationChannelsModel.selectedChannel { + return channel.geohash + } + return "" + } + /// Whether anyone is actually reachable on the current channel — the /// state the count icon's color encodes visually. var headerPeersReachable: Bool { @@ -346,7 +392,7 @@ private struct ContentLocationNotesUnavailableView: View { var body: some View { VStack(spacing: 12) { HStack { - Text("content.notes.title") + Text(String(localized: "content.notes.title", defaultValue: "notes")) .bitchatFont(size: 16, weight: .bold) Spacer() SheetCloseButton { showLocationNotes = false } @@ -355,10 +401,10 @@ private struct ContentLocationNotesUnavailableView: View { .frame(minHeight: headerHeight) .padding(.horizontal, 12) .themedChromePanel(edge: .top) - Text("content.notes.location_unavailable") + Text(String(localized: "content.notes.location_unavailable", defaultValue: "location unavailable")) .bitchatFont(size: 14) .foregroundColor(palette.secondary) - Button("content.location.enable") { + Button(String(localized: "content.location.enable", defaultValue: "enable location")) { locationChannelsModel.enableAndRefresh() } .buttonStyle(.bordered) diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index dd72d8b6..62a4c0ec 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -163,10 +163,10 @@ private struct ContentPeopleListView: View { // .help maps to the accessibility *hint* on iOS, so the // button still needs a spoken name. .accessibilityLabel( - String(localized: "content.accessibility.verification", comment: "Accessibility label for the verification QR button") + String(localized: "content.accessibility.verification", defaultValue: "verify encryption", comment: "Accessibility label for the verification QR button") ) .help( - String(localized: "content.help.verification", comment: "Help text for verification button") + String(localized: "content.help.verification", defaultValue: "verification: show my QR or scan a friend", comment: "Help text for verification button") ) } SheetCloseButton { @@ -221,6 +221,13 @@ private struct ContentPeopleListView: View { } ) } else { + GroupChatList( + groups: peerListModel.groupRows, + onTapGroup: { peerID in + peerListModel.startConversation(with: peerID) + showSidebar = true + } + ) MeshPeerList( onTapPeer: { peerID in peerListModel.startConversation(with: peerID) @@ -255,7 +262,7 @@ private struct ContentPeopleListView: View { private extension ContentPeopleListView { var peopleSheetTitle: String { - String(localized: "content.header.people", comment: "Title for the people list sheet").lowercased() + String(localized: "content.header.people", defaultValue: "PEOPLE", comment: "Title for the people list sheet").lowercased() } var peopleSheetSubtitle: String? { @@ -322,7 +329,7 @@ private struct ContentPrivateChatSheetView: View { } .buttonStyle(.plain) .accessibilityLabel( - String(localized: "content.accessibility.back_to_main_chat", comment: "Accessibility label for returning to main chat") + String(localized: "content.accessibility.back_to_main_chat", defaultValue: "back to main chat", comment: "Accessibility label for returning to main chat") ) Spacer(minLength: 0) @@ -347,8 +354,8 @@ private struct ContentPrivateChatSheetView: View { .buttonStyle(.plain) .accessibilityLabel( headerState.isFavorite - ? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite") - : String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite") + ? String(localized: "content.accessibility.remove_favorite", defaultValue: "remove from favorites", comment: "Accessibility label to remove a favorite") + : String(localized: "content.accessibility.add_favorite", defaultValue: "add to favorites", comment: "Accessibility label to add a favorite") ) } } @@ -455,6 +462,10 @@ private struct ContentPrivateChatSheetView: View { } private var privacyCaptionText: String { + // Group chats are ChaCha20-Poly1305 sealed to the roster's shared key. + if privateConversationModel.selectedPeerID?.isGroup == true { + return String(localized: "content.private.caption_group", defaultValue: "encrypted group · members only", comment: "Caption above the group chat composer noting messages are encrypted to group members") + } // Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted, // even though they carry no Noise session status. Mesh DMs earn the // "encrypted" claim only once the Noise handshake has secured. @@ -466,9 +477,9 @@ private struct ContentPrivateChatSheetView: View { } }() if isGeoDM || noiseSecured { - return String(localized: "content.private.caption_encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted") + return String(localized: "content.private.caption_encrypted", defaultValue: "private · end-to-end encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted") } - return String(localized: "content.private.caption", comment: "Caption above the private chat composer before encryption is established") + return String(localized: "content.private.caption", defaultValue: "private conversation", comment: "Caption above the private chat composer before encryption is established") } } @@ -503,30 +514,39 @@ private struct ContentPrivateHeaderInfoButton: View { var body: some View { Button(action: { + // A group has no single fingerprint to show. + guard !headerState.isGroupConversation else { return } appChromeModel.showFingerprint(for: headerState.headerPeerID) }) { HStack(spacing: 6) { - switch headerState.availability { - case .bluetoothConnected: - Image(systemName: "dot.radiowaves.left.and.right") + if headerState.isGroupConversation { + Image(systemName: "person.3.fill") .font(.bitchatSystem(size: 14)) .foregroundColor(palette.primary) - .accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")) - case .meshReachable: - Image(systemName: "point.3.filled.connected.trianglepath.dotted") - .font(.bitchatSystem(size: 14)) - .foregroundColor(palette.primary) - .accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")) - case .nostrAvailable: - Image(systemName: "globe") - .font(.bitchatSystem(size: 14)) - .foregroundColor(.purple) - .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")) - case .offline: - // Absence of a glyph was the only offline signal; say it. - Text("mesh_peers.state.offline") - .bitchatFont(size: 11) - .foregroundColor(palette.secondary) + .accessibilityLabel(String(localized: "content.accessibility.group_chat", defaultValue: "Group chat", comment: "Accessibility label for the group chat indicator")) + } else { + switch headerState.availability { + case .bluetoothConnected: + Image(systemName: "dot.radiowaves.left.and.right") + .font(.bitchatSystem(size: 14)) + .foregroundColor(palette.primary) + .accessibilityLabel(String(localized: "content.accessibility.connected_mesh", defaultValue: "connected via mesh", comment: "Accessibility label for mesh-connected peer indicator")) + case .meshReachable: + Image(systemName: "point.3.filled.connected.trianglepath.dotted") + .font(.bitchatSystem(size: 14)) + .foregroundColor(palette.primary) + .accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", defaultValue: "reachable via mesh", comment: "Accessibility label for mesh-reachable peer indicator")) + case .nostrAvailable: + Image(systemName: "globe") + .font(.bitchatSystem(size: 14)) + .foregroundColor(.purple) + .accessibilityLabel(String(localized: "content.accessibility.available_nostr", defaultValue: "available via Nostr", comment: "Accessibility label for Nostr-available peer indicator")) + case .offline: + // Absence of a glyph was the only offline signal; say it. + Text(String(localized: "mesh_peers.state.offline", defaultValue: "offline")) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary) + } } Text(headerState.displayName) @@ -554,7 +574,7 @@ private struct ContentPrivateHeaderInfoButton: View { ) .accessibilityLabel( String( - format: String(localized: "content.accessibility.encryption_status", comment: "Accessibility label announcing encryption status"), + format: String(localized: "content.accessibility.encryption_status", defaultValue: "encryption status: %@", comment: "Accessibility label announcing encryption status"), locale: .current, encryptionStatus.accessibilityDescription ) @@ -565,13 +585,15 @@ private struct ContentPrivateHeaderInfoButton: View { .buttonStyle(.plain) .accessibilityLabel( String( - format: String(localized: "content.accessibility.private_chat_header", comment: "Accessibility label describing the private chat header"), + format: String(localized: "content.accessibility.private_chat_header", defaultValue: "private chat with %@", comment: "Accessibility label describing the private chat header"), locale: .current, headerState.displayName ) ) .accessibilityHint( - String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint") + headerState.isGroupConversation + ? "" + : String(localized: "content.accessibility.view_fingerprint_hint", defaultValue: "tap to view encryption fingerprint", comment: "Accessibility hint for viewing encryption fingerprint") ) .frame(minHeight: headerHeight) } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 86bb31ad..ce0819de 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -149,7 +149,7 @@ struct ContentView: View { #endif } .sheet(isPresented: $appChromeModel.isAppInfoPresented) { - AppInfoView() + AppInfoView(topologyProvider: { appChromeModel.meshTopologyDisplayModel() }) } .sheet(isPresented: Binding( get: { appChromeModel.showingFingerprintFor != nil && !showSidebar && selectedPrivatePeerID == nil }, @@ -204,20 +204,20 @@ struct ContentView: View { } } .alert("Recording Error", isPresented: $voiceRecordingVM.showAlert, actions: { - Button("common.ok", role: .cancel) {} + Button(String(localized: "common.ok", defaultValue: "OK"), role: .cancel) {} if voiceRecordingVM.state == .permissionDenied { - Button("location_channels.action.open_settings") { + Button(String(localized: "location_channels.action.open_settings", defaultValue: "open settings")) { SystemSettings.microphone.open() } } }, message: { Text(voiceRecordingVM.state.alertMessage) }) - .alert("content.alert.bluetooth_required.title", isPresented: $appChromeModel.showBluetoothAlert) { - Button("content.alert.bluetooth_required.settings") { + .alert(String(localized: "content.alert.bluetooth_required.title", defaultValue: "bluetooth required"), isPresented: $appChromeModel.showBluetoothAlert) { + Button(String(localized: "content.alert.bluetooth_required.settings", defaultValue: "settings")) { SystemSettings.bluetooth.open() } - Button("common.ok", role: .cancel) {} + Button(String(localized: "common.ok", defaultValue: "OK"), role: .cancel) {} } message: { Text(appChromeModel.bluetoothAlertMessage) } diff --git a/bitchat/Views/FingerprintView.swift b/bitchat/Views/FingerprintView.swift index 06fc865f..aff63985 100644 --- a/bitchat/Views/FingerprintView.swift +++ b/bitchat/Views/FingerprintView.swift @@ -30,15 +30,23 @@ struct FingerprintView: View { static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified" static func verifyHint(_ nickname: String) -> String { String( - format: String(localized: "fingerprint.message.verify_hint", comment: "Instruction to compare fingerprints with a named peer"), + format: String(localized: "fingerprint.message.verify_hint", defaultValue: "compare these fingerprints with %@ using a secure channel.", comment: "Instruction to compare fingerprints with a named peer"), locale: .current, nickname ) } static let markVerified: LocalizedStringKey = "fingerprint.action.mark_verified" static let removeVerification: LocalizedStringKey = "fingerprint.action.remove_verification" + static let vouchedBadge: LocalizedStringKey = "fingerprint.badge.vouched" + static func vouchedBy(_ count: Int) -> String { + String( + format: String(localized: "fingerprint.message.vouched_by", defaultValue: "vouched for by %#@people@ you verified", comment: "How many people the user verified have vouched for this peer"), + locale: .current, + count + ) + } static func unknownPeer() -> String { - String(localized: "common.unknown", comment: "Label for an unknown peer") + String(localized: "common.unknown", defaultValue: "unknown", comment: "Label for an unknown peer") } } @@ -146,6 +154,41 @@ struct FingerprintView: View { } } + // Vouched (transitively verified) status: shown whenever the + // peer isn't explicitly verified but people I verified vouch + // for them, independent of the current session state. + if fingerprintState.isVouched && !fingerprintState.isVerified { + VStack(spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "checkmark.seal") + .font(.bitchatSystem(size: 14)) + .foregroundColor(.teal) + Text(Strings.vouchedBadge) + .bitchatFont(size: 14, weight: .bold) + .foregroundColor(.teal) + } + .frame(maxWidth: .infinity) + + Text(Strings.vouchedBy(fingerprintState.voucherCount)) + .bitchatFont(size: 12) + .foregroundColor(textColor.opacity(0.7)) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + + if !fingerprintState.voucherNames.isEmpty { + Text(fingerprintState.voucherNames.joined(separator: ", ")) + .bitchatFont(size: 12) + .foregroundColor(textColor.opacity(0.7)) + .multilineTextAlignment(.center) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + } + } + .padding(.top, 8) + .accessibilityElement(children: .combine) + } + // Verification status if fingerprintState.canToggleVerification { VStack(spacing: 12) { diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift index 9613f227..42034944 100644 --- a/bitchat/Views/GeohashPeopleList.swift +++ b/bitchat/Views/GeohashPeopleList.swift @@ -10,16 +10,16 @@ struct GeohashPeopleList: View { private enum Strings { static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby" static let youSuffix: LocalizedStringKey = "geohash_people.you_suffix" - static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels") + static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", defaultValue: "blocked in geohash", comment: "Tooltip shown next to users blocked in geohash channels") static let unblock: LocalizedStringKey = "geohash_people.action.unblock" static let block: LocalizedStringKey = "geohash_people.action.block" - static let unblockText = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person") - static let blockText = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person") - static let teleported = String(localized: "geohash_people.state.teleported", comment: "State label for someone who joined the location channel from elsewhere") - static let nearby = String(localized: "geohash_people.state.nearby", comment: "State label for someone physically in the location channel's area") - static let blockedState = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer") - static let youState = String(localized: "geohash_people.state.you", comment: "State label marking your own row in the people list") - static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat") + static let unblockText = String(localized: "geohash_people.action.unblock", defaultValue: "unblock", comment: "Context menu action to unblock a person") + static let blockText = String(localized: "geohash_people.action.block", defaultValue: "block", comment: "Context menu action to block a person") + static let teleported = String(localized: "geohash_people.state.teleported", defaultValue: "teleported from elsewhere", comment: "State label for someone who joined the location channel from elsewhere") + static let nearby = String(localized: "geohash_people.state.nearby", defaultValue: "in this area", comment: "State label for someone physically in the location channel's area") + static let blockedState = String(localized: "mesh_peers.state.blocked", defaultValue: "blocked", comment: "State label for a blocked peer") + static let youState = String(localized: "geohash_people.state.you", defaultValue: "you", comment: "State label marking your own row in the people list") + static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", defaultValue: "opens a private chat", comment: "Accessibility hint on a peer row explaining activation opens a private chat") } var body: some View { diff --git a/bitchat/Views/GroupChatList.swift b/bitchat/Views/GroupChatList.swift new file mode 100644 index 00000000..c4aef7a1 --- /dev/null +++ b/bitchat/Views/GroupChatList.swift @@ -0,0 +1,86 @@ +import BitFoundation +import SwiftUI + +/// Compact "groups" section for the people sheet: one row per private group +/// this device belongs to, tappable to open the group chat window. +struct GroupChatList: View { + @ThemedPalette private var palette + + let groups: [GroupChatRow] + let onTapGroup: (PeerID) -> Void + + private enum Strings { + static let header = String(localized: "groups.section.header", defaultValue: "groups", comment: "Section header above the private groups list") + static let creator = String(localized: "groups.state.creator", defaultValue: "Creator", comment: "State label for a group the user created") + static let unread = String(localized: "mesh_peers.state.unread", defaultValue: "new messages", comment: "State label for a peer with unread private messages") + static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", defaultValue: "new messages", comment: "Tooltip for the unread messages indicator") + static let openGroupHint = String(localized: "groups.accessibility.open_group_hint", defaultValue: "Opens the group chat", comment: "Accessibility hint on a group row explaining activation opens the group chat") + static let memberCountFormat = String(localized: "groups.member_count %@", defaultValue: "(%@)", comment: "Member count shown next to a group name; placeholder is the count") + } + + var body: some View { + if !groups.isEmpty { + VStack(alignment: .leading, spacing: 0) { + Text(Strings.header) + .bitchatFont(size: 11, weight: .medium) + .foregroundColor(palette.secondary) + .padding(.horizontal) + .padding(.top, 10) + .padding(.bottom, 2) + .accessibilityAddTraits(.isHeader) + + ForEach(groups) { group in + HStack(spacing: 4) { + Image(systemName: "person.3.fill") + .font(.bitchatSystem(size: 10)) + .foregroundColor(palette.primary) + + Text("#\(group.name)") + .bitchatFont(size: 14) + .foregroundColor(palette.primary) + .lineLimit(1) + .truncationMode(.tail) + + Text(String(format: Strings.memberCountFormat, locale: .current, "\(group.memberCount)")) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + + if group.isCreator { + Image(systemName: "crown.fill") + .font(.bitchatSystem(size: 9)) + .foregroundColor(.yellow) + .help(Strings.creator) + } + + Spacer() + + if group.hasUnread { + Image(systemName: "envelope.fill") + .font(.bitchatSystem(size: 10)) + .foregroundColor(.orange) + .help(Strings.newMessagesTooltip) + } + } + .padding(.horizontal) + .padding(.vertical, 6) + .contentShape(Rectangle()) + .onTapGesture { onTapGroup(group.peerID) } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityDescription(for: group)) + .accessibilityAddTraits(.isButton) + .accessibilityHint(Strings.openGroupHint) + } + } + } + } + + private func accessibilityDescription(for group: GroupChatRow) -> String { + var parts: [String] = [ + group.name, + String(format: Strings.memberCountFormat, locale: .current, "\(group.memberCount)") + ] + if group.isCreator { parts.append(Strings.creator) } + if group.hasUnread { parts.append(Strings.unread) } + return parts.joined(separator: ", ") + } +} diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 77b45a21..6e9e5b95 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -27,16 +27,18 @@ struct LocationChannelsSheet: View { static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access" static let torTitle: LocalizedStringKey = "location_channels.tor.title" static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle" + static let gatewayTitle: LocalizedStringKey = "location_channels.gateway.title" + static let gatewaySubtitle: LocalizedStringKey = "location_channels.gateway.subtitle" static let toggleOn: LocalizedStringKey = "common.toggle.on" static let toggleOff: LocalizedStringKey = "common.toggle.off" - static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid") - static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it") - static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel") - static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", comment: "Accessibility action name for removing a channel bookmark") + static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", defaultValue: "invalid geohash", comment: "Error shown when a custom geohash is invalid") + static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", defaultValue: "switches to this channel", comment: "Accessibility hint on a channel row explaining activation switches to it") + static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", defaultValue: "bookmark channel", comment: "Accessibility action name for bookmarking a channel") + static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", defaultValue: "remove bookmark", comment: "Accessibility action name for removing a channel bookmark") static func meshTitle(_ count: Int) -> String { - let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row") + let label = String(localized: "location_channels.mesh_label", defaultValue: "mesh", comment: "Label for the mesh channel row") return rowTitle(label: label, count: count) } @@ -71,7 +73,7 @@ struct LocationChannelsSheet: View { static func subtitlePrefix(geohash: String, coverage: String) -> String { String( - format: String(localized: "location_channels.subtitle_prefix", comment: "Subtitle prefix showing geohash and coverage"), + format: String(localized: "location_channels.subtitle_prefix", defaultValue: "#%1$@ • %2$@", comment: "Subtitle prefix showing geohash and coverage"), locale: .current, geohash, coverage ) @@ -80,7 +82,7 @@ struct LocationChannelsSheet: View { static func subtitle(prefix: String, name: String?) -> String { guard let name, !name.isEmpty else { return prefix } return String( - format: String(localized: "location_channels.subtitle_with_name", comment: "Subtitle combining prefix and resolved location name"), + format: String(localized: "location_channels.subtitle_with_name", defaultValue: "%1$@ • %2$@", comment: "Subtitle combining prefix and resolved location name"), locale: .current, prefix, name ) @@ -88,7 +90,7 @@ struct LocationChannelsSheet: View { private static func rowTitle(label: String, count: Int) -> String { String( - format: String(localized: "location_channels.row_title", comment: "List row title with participant count"), + format: String(localized: "location_channels.row_title", defaultValue: "%1$@ [%2$#@people_count@]", comment: "List row title with participant count"), locale: .current, label, count ) @@ -244,6 +246,8 @@ struct LocationChannelsSheet: View { sectionDivider torToggleSection .padding(.top, 12) + gatewayToggleSection + .padding(.top, 8) Button(action: SystemSettings.location.open) { Text(Strings.removeAccess) .bitchatFont(size: 12) @@ -508,6 +512,32 @@ extension LocationChannelsSheet { .cornerRadius(8) } + private var gatewayToggleBinding: Binding { + Binding( + get: { locationChannelsModel.gatewayEnabled }, + set: { locationChannelsModel.setGatewayEnabled($0) } + ) + } + + private var gatewayToggleSection: some View { + VStack(alignment: .leading, spacing: 8) { + Toggle(isOn: gatewayToggleBinding) { + VStack(alignment: .leading, spacing: 2) { + Text(Strings.gatewayTitle) + .bitchatFont(size: 12, weight: .semibold) + .foregroundColor(palette.primary) + Text(Strings.gatewaySubtitle) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary) + } + } + .toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff)) + } + .padding(12) + .background(palette.secondary.opacity(0.12)) + .cornerRadius(8) + } + private var standardGreen: Color { palette.primary } private var standardBlue: Color { palette.accentBlue } } diff --git a/bitchat/Views/LocationNotesView.swift b/bitchat/Views/LocationNotesView.swift index d2cd3f16..581cec8f 100644 --- a/bitchat/Views/LocationNotesView.swift +++ b/bitchat/Views/LocationNotesView.swift @@ -135,7 +135,7 @@ struct LocationNotesView: View { private func headerTitle(for count: Int) -> String { String( - format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"), + format: String(localized: "location_notes.header", defaultValue: "#%1$@ • %2$#@note_count@", comment: "Header displaying the geohash and localized note count"), locale: .current, "\(geohash) ± 1", count ) diff --git a/bitchat/Views/Media/BlockRevealImageView.swift b/bitchat/Views/Media/BlockRevealImageView.swift index 8e5d8bd2..0da74568 100644 --- a/bitchat/Views/Media/BlockRevealImageView.swift +++ b/bitchat/Views/Media/BlockRevealImageView.swift @@ -25,20 +25,20 @@ struct BlockRevealImageView: View { @State private var loadFailed = false private enum Strings { - static let tapToReveal = String(localized: "media.image.tap_to_reveal", comment: "Caption on a blurred incoming image inviting a tap to reveal it") - static let open = String(localized: "media.image.action.open", comment: "Context menu action that opens an image full screen") - static let reveal = String(localized: "media.image.action.reveal", comment: "Context menu action that reveals a blurred image") - static let hide = String(localized: "media.image.action.hide", comment: "Context menu action that re-blurs a revealed image") - static let delete = String(localized: "media.image.action.delete", comment: "Context menu action that deletes a received image") - static let deleteConfirmTitle = String(localized: "media.image.delete_confirm_title", comment: "Title of the confirmation dialog before deleting a received image") - static let deleteConfirmMessage = String(localized: "media.image.delete_confirm_message", comment: "Body of the confirmation dialog before deleting a received image") - static let hiddenImage = String(localized: "media.image.accessibility.hidden", comment: "Accessibility label for a blurred incoming image") - static let revealedImage = String(localized: "media.image.accessibility.revealed", comment: "Accessibility label for a revealed image") - static let revealHint = String(localized: "media.image.accessibility.hint.reveal", comment: "Accessibility hint for a blurred image; activating it reveals the image") - static let openHint = String(localized: "media.image.accessibility.hint.open", comment: "Accessibility hint for a revealed image; activating it opens the image full screen") - static let sendingImage = String(localized: "media.image.accessibility.sending", comment: "Accessibility label for an image that is still sending") - static let unavailableImage = String(localized: "media.image.accessibility.unavailable", comment: "Accessibility label for an image whose file could not be loaded") - static let cancelSend = String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send") + static let tapToReveal = String(localized: "media.image.tap_to_reveal", defaultValue: "tap to reveal", comment: "Caption on a blurred incoming image inviting a tap to reveal it") + static let open = String(localized: "media.image.action.open", defaultValue: "open image", comment: "Context menu action that opens an image full screen") + static let reveal = String(localized: "media.image.action.reveal", defaultValue: "reveal image", comment: "Context menu action that reveals a blurred image") + static let hide = String(localized: "media.image.action.hide", defaultValue: "hide image", comment: "Context menu action that re-blurs a revealed image") + static let delete = String(localized: "media.image.action.delete", defaultValue: "delete image", comment: "Context menu action that deletes a received image") + static let deleteConfirmTitle = String(localized: "media.image.delete_confirm_title", defaultValue: "delete this image?", comment: "Title of the confirmation dialog before deleting a received image") + static let deleteConfirmMessage = String(localized: "media.image.delete_confirm_message", defaultValue: "this cannot be undone — the sender may not be in range to send it again.", comment: "Body of the confirmation dialog before deleting a received image") + static let hiddenImage = String(localized: "media.image.accessibility.hidden", defaultValue: "hidden image", comment: "Accessibility label for a blurred incoming image") + static let revealedImage = String(localized: "media.image.accessibility.revealed", defaultValue: "image", comment: "Accessibility label for a revealed image") + static let revealHint = String(localized: "media.image.accessibility.hint.reveal", defaultValue: "reveals the image", comment: "Accessibility hint for a blurred image; activating it reveals the image") + static let openHint = String(localized: "media.image.accessibility.hint.open", defaultValue: "opens the image full screen", comment: "Accessibility hint for a revealed image; activating it opens the image full screen") + static let sendingImage = String(localized: "media.image.accessibility.sending", defaultValue: "sending image", comment: "Accessibility label for an image that is still sending") + static let unavailableImage = String(localized: "media.image.accessibility.unavailable", defaultValue: "image unavailable", comment: "Accessibility label for an image whose file could not be loaded") + static let cancelSend = String(localized: "media.accessibility.cancel_send", defaultValue: "cancel sending", comment: "Accessibility label for the cancel button on an in-flight media send") } init( @@ -154,7 +154,7 @@ struct BlockRevealImageView: View { Button(Strings.delete, role: .destructive) { onDelete?() } - Button("common.cancel", role: .cancel) {} + Button(String(localized: "common.cancel", defaultValue: "cancel"), role: .cancel) {} } message: { Text(verbatim: Strings.deleteConfirmMessage) } diff --git a/bitchat/Views/Media/MediaMessageView.swift b/bitchat/Views/Media/MediaMessageView.swift index 90fd13c4..116826be 100644 --- a/bitchat/Views/Media/MediaMessageView.swift +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -67,7 +67,7 @@ struct MediaMessageView: View { } .buttonStyle(.plain) .accessibilityHint( - String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details") + String(localized: "content.accessibility.delivery_detail_hint", defaultValue: "tap to show delivery details", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details") ) } } diff --git a/bitchat/Views/Media/VoiceNoteView.swift b/bitchat/Views/Media/VoiceNoteView.swift index d2a63e6e..2687ac07 100644 --- a/bitchat/Views/Media/VoiceNoteView.swift +++ b/bitchat/Views/Media/VoiceNoteView.swift @@ -54,8 +54,8 @@ struct VoiceNoteView: View { .buttonStyle(.plain) .accessibilityLabel( playback.isPlaying - ? String(localized: "media.voice.accessibility.pause", comment: "Accessibility label for pausing voice note playback") - : String(localized: "media.voice.accessibility.play", comment: "Accessibility label for playing a voice note") + ? String(localized: "media.voice.accessibility.pause", defaultValue: "pause voice note", comment: "Accessibility label for pausing voice note playback") + : String(localized: "media.voice.accessibility.play", defaultValue: "play voice note", comment: "Accessibility label for playing a voice note") ) .accessibilityValue(playbackLabel) @@ -83,7 +83,7 @@ struct VoiceNoteView: View { } .buttonStyle(.plain) .accessibilityLabel( - String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send") + String(localized: "media.accessibility.cancel_send", defaultValue: "cancel sending", comment: "Accessibility label for the cancel button on an in-flight media send") ) } } diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 244cd6dc..de29474b 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -16,22 +16,24 @@ struct MeshPeerList: View { private enum Strings { static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby" - static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to a blocked peer indicator") - static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator") - static let connected = String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator") - static let reachable = String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator") - static let nostr = String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator") - static let offline = String(localized: "mesh_peers.state.offline", comment: "State label for a peer that is not currently reachable") - static let favorite = String(localized: "mesh_peers.state.favorite", comment: "State label for a favorited peer") - static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages") - static let blocked = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer") - static let addFavorite = String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite") - static let removeFavorite = String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite") - static let showFingerprint = String(localized: "mesh_peers.action.fingerprint", comment: "Context menu action that shows a peer's fingerprint/verification screen") - static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat") - static let directMessage = String(localized: "content.actions.direct_message", comment: "Action that opens a private chat with the person") - static let block = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person") - static let unblock = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person") + static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", defaultValue: "blocked in geohash", comment: "Tooltip shown next to a blocked peer indicator") + static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", defaultValue: "new messages", comment: "Tooltip for the unread messages indicator") + static let connected = String(localized: "content.accessibility.connected_mesh", defaultValue: "connected via mesh", comment: "Accessibility label for mesh-connected peer indicator") + static let reachable = String(localized: "content.accessibility.reachable_mesh", defaultValue: "reachable via mesh", comment: "Accessibility label for mesh-reachable peer indicator") + static let nostr = String(localized: "content.accessibility.available_nostr", defaultValue: "available via Nostr", comment: "Accessibility label for Nostr-available peer indicator") + static let offline = String(localized: "mesh_peers.state.offline", defaultValue: "offline", comment: "State label for a peer that is not currently reachable") + static let favorite = String(localized: "mesh_peers.state.favorite", defaultValue: "favorite", comment: "State label for a favorited peer") + static let unread = String(localized: "mesh_peers.state.unread", defaultValue: "new messages", comment: "State label for a peer with unread private messages") + static let blocked = String(localized: "mesh_peers.state.blocked", defaultValue: "blocked", comment: "State label for a blocked peer") + static let vouched = String(localized: "mesh_peers.state.vouched", defaultValue: "vouched", comment: "State label for a peer vouched for by someone the user verified") + static let vouchedTooltip = String(localized: "mesh_peers.tooltip.vouched", defaultValue: "vouched for by someone you verified", comment: "Tooltip for the vouched (unfilled seal) badge next to a peer") + static let addFavorite = String(localized: "content.accessibility.add_favorite", defaultValue: "add to favorites", comment: "Accessibility label to add a favorite") + static let removeFavorite = String(localized: "content.accessibility.remove_favorite", defaultValue: "remove from favorites", comment: "Accessibility label to remove a favorite") + static let showFingerprint = String(localized: "mesh_peers.action.fingerprint", defaultValue: "show fingerprint", comment: "Context menu action that shows a peer's fingerprint/verification screen") + static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", defaultValue: "opens a private chat", comment: "Accessibility hint on a peer row explaining activation opens a private chat") + static let directMessage = String(localized: "content.actions.direct_message", defaultValue: "direct message", comment: "Action that opens a private chat with the person") + static let block = String(localized: "geohash_people.action.block", defaultValue: "block", comment: "Context menu action to block a person") + static let unblock = String(localized: "geohash_people.action.unblock", defaultValue: "unblock", comment: "Context menu action to unblock a person") } var body: some View { @@ -134,6 +136,16 @@ struct MeshPeerList: View { .foregroundColor(baseColor) } } + + // Vouched (transitively verified): unfilled seal, + // deliberately distinct from verified's filled one. + // Never shown alongside a verified badge. + if peer.showsVouchedBadge { + Image(systemName: "checkmark.seal") + .font(.bitchatSystem(size: 10)) + .foregroundColor(baseColor) + .help(Strings.vouchedTooltip) + } } Spacer() @@ -241,6 +253,7 @@ struct MeshPeerList: View { parts.append(Strings.offline) } } + if peer.showsVouchedBadge { parts.append(Strings.vouched) } if peer.isFavorite { parts.append(Strings.favorite) } if peer.hasUnread { parts.append(Strings.unread) } if peer.isBlocked { parts.append(Strings.blocked) } diff --git a/bitchat/Views/MeshTopologyView.swift b/bitchat/Views/MeshTopologyView.swift new file mode 100644 index 00000000..148efde2 --- /dev/null +++ b/bitchat/Views/MeshTopologyView.swift @@ -0,0 +1,217 @@ +// +// MeshTopologyView.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +/// Display model for the mesh topology map: nodes are known mesh peers, +/// edges are gossiped `directNeighbors` claims. Built on the main actor from +/// a `MeshTopologySnapshot` plus the current nickname table. +struct MeshTopologyDisplayModel { + struct Node: Identifiable, Equatable { + let id: String + let label: String + let isSelf: Bool + } + + let nodes: [Node] + /// Pairs of `Node.id`; every id is present in `nodes`. + let edges: [(String, String)] + + static let empty = MeshTopologyDisplayModel(nodes: [], edges: []) +} + +/// Minimal diagnostics sheet: the mesh graph on a circular layout (self in +/// the center), drawn with Canvas so it stays cheap at any peer count. +struct MeshTopologyView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.appTheme) private var appTheme + @ThemedPalette private var palette + + /// Fetches a fresh model; called on appear and on manual refresh. + let provider: @MainActor () -> MeshTopologyDisplayModel + @State private var model: MeshTopologyDisplayModel = .empty + + var body: some View { + #if os(macOS) + VStack(spacing: 0) { + HStack { + Text(String(localized: "topology.title", defaultValue: "mesh topology")) + .bitchatFont(size: 16, weight: .bold) + .foregroundColor(palette.primary) + Spacer() + refreshButton + Button(String(localized: "app_info.done", defaultValue: "DONE")) { + dismiss() + } + .buttonStyle(.plain) + .foregroundColor(palette.primary) + } + .padding() + .themedSurface(opacity: 0.95) + + content + } + .frame(width: 500, height: 520) + .themedSheetBackground() + #else + NavigationView { + content + .themedSheetBackground() + .navigationTitle(Text(String(localized: "topology.title", defaultValue: "mesh topology"))) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + refreshButton + } + ToolbarItem(placement: .navigationBarTrailing) { + SheetCloseButton { dismiss() } + .foregroundColor(palette.primary) + } + } + } + #endif + } + + private var refreshButton: some View { + Button { + model = provider() + } label: { + Image(systemName: "arrow.clockwise") + .font(.bitchatSystem(size: 14)) + .foregroundColor(palette.primary) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(String(localized: "topology.refresh", defaultValue: "refresh topology"))) + } + + @ViewBuilder + private var content: some View { + VStack(spacing: 12) { + if model.nodes.count <= 1 { + Spacer() + Text(String(localized: "topology.empty", defaultValue: "no mesh links yet — the map fills in as peer announces arrive")) + .bitchatFont(size: 14) + .foregroundColor(palette.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + Spacer() + } else { + graphCanvas + .padding(.horizontal, 8) + } + + VStack(spacing: 4) { + Text(summaryText) + .bitchatFont(size: 13, weight: .semibold) + .foregroundColor(palette.primary) + Text(String(localized: "topology.caption", defaultValue: "estimated from gossiped neighbor lists (up to 10 per peer) — your device is highlighted")) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal) + .padding(.bottom, 16) + } + .onAppear { model = provider() } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(summaryText)) + } + + private var summaryText: String { + String( + format: String( + localized: "topology.summary", + comment: "Topology map summary: number of peers and links" + ), + locale: .current, + model.nodes.count, + model.edges.count + ) + } + + private var graphCanvas: some View { + Canvas { context, size in + let positions = Self.layout(nodes: model.nodes, in: size) + let fontDesign = appTheme.bodyFontDesign + + // Edges first so nodes draw on top. + for (fromID, toID) in model.edges { + guard let from = positions[fromID], let to = positions[toID] else { continue } + var path = Path() + path.move(to: from) + path.addLine(to: to) + context.stroke(path, with: .color(palette.secondary.opacity(0.45)), lineWidth: 1) + } + + for node in model.nodes { + guard let center = positions[node.id] else { continue } + let radius: CGFloat = node.isSelf ? 7 : 5 + let dot = Path(ellipseIn: CGRect( + x: center.x - radius, + y: center.y - radius, + width: radius * 2, + height: radius * 2 + )) + context.fill(dot, with: .color(node.isSelf ? palette.accent : palette.primary)) + if node.isSelf { + let ring = Path(ellipseIn: CGRect( + x: center.x - radius - 3, + y: center.y - radius - 3, + width: (radius + 3) * 2, + height: (radius + 3) * 2 + )) + context.stroke(ring, with: .color(palette.accent.opacity(0.6)), lineWidth: 1) + } + context.draw( + Text(node.label) + .font(.system(size: 10, design: fontDesign)) + .foregroundColor(node.isSelf ? palette.accent : palette.secondary), + at: CGPoint(x: center.x, y: center.y + radius + 4), + anchor: .top + ) + } + } + .accessibilityHidden(true) // The combined summary label narrates the graph. + } + + /// Circular layout: self in the center, everyone else evenly spaced on a + /// ring. Deterministic (nodes arrive sorted), so refreshes don't shuffle. + static func layout(nodes: [MeshTopologyDisplayModel.Node], in size: CGSize) -> [String: CGPoint] { + let center = CGPoint(x: size.width / 2, y: size.height / 2) + // Leave room for the label row under each ring node. + let radius = max(20, min(size.width, size.height) / 2 - 36) + var positions: [String: CGPoint] = [:] + + let ringNodes = nodes.filter { !$0.isSelf } + for node in nodes where node.isSelf { + positions[node.id] = center + } + for (index, node) in ringNodes.enumerated() { + let angle = (2 * CGFloat.pi * CGFloat(index)) / CGFloat(max(1, ringNodes.count)) - CGFloat.pi / 2 + positions[node.id] = CGPoint( + x: center.x + radius * cos(angle), + y: center.y + radius * sin(angle) + ) + } + return positions + } +} + +#Preview("Topology") { + MeshTopologyView(provider: { + MeshTopologyDisplayModel( + nodes: [ + .init(id: "self", label: "me", isSelf: true), + .init(id: "a", label: "alice", isSelf: false), + .init(id: "b", label: "bob", isSelf: false), + .init(id: "c", label: "carol", isSelf: false) + ], + edges: [("self", "a"), ("a", "b"), ("self", "c")] + ) + }) +} diff --git a/bitchat/Views/MessageListView.swift b/bitchat/Views/MessageListView.swift index 1506c1da..b8a8cde4 100644 --- a/bitchat/Views/MessageListView.swift +++ b/bitchat/Views/MessageListView.swift @@ -109,11 +109,11 @@ struct MessageListView: View { // mentioning the only other participant is noise, and "DM" // would just reopen the conversation that is already open. if privatePeer == nil { - Button("content.actions.mention") { + Button(String(localized: "content.actions.mention", defaultValue: "mention")) { insertMention(message.sender) } if let peerID = message.senderPeerID { - Button("content.actions.direct_message") { + Button(String(localized: "content.actions.direct_message", defaultValue: "direct message")) { privateConversationModel.openConversation(for: peerID) withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { showSidebar = true @@ -121,14 +121,14 @@ struct MessageListView: View { } } } - Button("content.actions.hug") { + Button(String(localized: "content.actions.hug", defaultValue: "hug")) { conversationUIModel.sendHug(to: message.sender) } - Button("content.actions.slap") { + Button(String(localized: "content.actions.slap", defaultValue: "slap")) { conversationUIModel.sendSlap(to: message.sender) } } - Button("content.message.copy") { + Button(String(localized: "content.message.copy", defaultValue: "copy message")) { #if os(iOS) UIPasteboard.general.string = message.content #else @@ -138,12 +138,12 @@ struct MessageListView: View { #endif } if isResendableFailedMessage(message) { - Button("content.actions.resend") { + Button(String(localized: "content.actions.resend", defaultValue: "resend")) { conversationUIModel.resendFailedPrivateMessage(message) } } if showsUserActions { - Button("content.actions.block", role: .destructive) { + Button(String(localized: "content.actions.block", defaultValue: "block"), role: .destructive) { conversationUIModel.block(peerID: message.senderPeerID, displayName: message.sender) } } @@ -165,14 +165,14 @@ struct MessageListView: View { showClearConfirmation = true } .confirmationDialog( - "content.clear.confirm_title", + String(localized: "content.clear.confirm_title", defaultValue: "clear this chat?"), isPresented: $showClearConfirmation, titleVisibility: .visible ) { - Button("content.clear.confirm_action", role: .destructive) { + Button(String(localized: "content.clear.confirm_action", defaultValue: "clear chat"), role: .destructive) { conversationUIModel.clearCurrentConversation() } - Button("common.cancel", role: .cancel) {} + Button(String(localized: "common.cancel", defaultValue: "cancel"), role: .cancel) {} } .onAppear { scrollToBottom(on: proxy) @@ -190,17 +190,17 @@ struct MessageListView: View { onSelectedChannelChange(newChannel, proxy: proxy) } .confirmationDialog( - selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"), + selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", defaultValue: "actions", comment: "Fallback title for the message action sheet"), isPresented: $showMessageActions, titleVisibility: .visible ) { - Button("content.actions.mention") { + Button(String(localized: "content.actions.mention", defaultValue: "mention")) { if let sender = selectedMessageSender { insertMention(sender) } } - Button("content.actions.direct_message") { + Button(String(localized: "content.actions.direct_message", defaultValue: "direct message")) { if let peerID = selectedMessageSenderID { privateConversationModel.openConversation(for: peerID) withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { @@ -209,23 +209,23 @@ struct MessageListView: View { } } - Button("content.actions.hug") { + Button(String(localized: "content.actions.hug", defaultValue: "hug")) { if let sender = selectedMessageSender { conversationUIModel.sendHug(to: sender) } } - Button("content.actions.slap") { + Button(String(localized: "content.actions.slap", defaultValue: "slap")) { if let sender = selectedMessageSender { conversationUIModel.sendSlap(to: sender) } } - Button("content.actions.block", role: .destructive) { + Button(String(localized: "content.actions.block", defaultValue: "block"), role: .destructive) { conversationUIModel.block(peerID: selectedMessageSenderID, displayName: selectedMessageSender) } - Button("common.cancel", role: .cancel) {} + Button(String(localized: "common.cancel", defaultValue: "cancel"), role: .cancel) {} } .onAppear { // Also check when view appears @@ -277,18 +277,18 @@ private extension MessageListView { VStack(alignment: .leading, spacing: 6) { switch locationChannelsModel.selectedChannel { case .mesh: - emptyStateLine(String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is")) - emptyStateLine(String(localized: "content.empty.mesh_waiting", comment: "Second line of the empty mesh timeline saying no peers are in range yet")) - emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen")) + emptyStateLine(String(localized: "content.empty.mesh_intro", defaultValue: "you're on #mesh — reaches people within bluetooth range", comment: "First line of the empty mesh timeline explaining what the mesh channel is")) + emptyStateLine(String(localized: "content.empty.mesh_waiting", defaultValue: "nobody in range yet... messages appear here", comment: "Second line of the empty mesh timeline saying no peers are in range yet")) + emptyStateLine(String(localized: "content.empty.switch_hint", defaultValue: "tap the channel name above to switch · tap bitchat/ for help", comment: "Empty timeline hint pointing at the channel switcher and the help screen")) case .location(let channel): emptyStateLine( String( - format: String(localized: "content.empty.location_intro", comment: "First line of an empty geohash timeline naming the channel"), + format: String(localized: "content.empty.location_intro", defaultValue: "you're in #%@ — a public location channel over the internet", comment: "First line of an empty geohash timeline naming the channel"), locale: .current, channel.geohash ) ) - emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen")) + emptyStateLine(String(localized: "content.empty.switch_hint", defaultValue: "tap the channel name above to switch · tap bitchat/ for help", comment: "Empty timeline hint pointing at the channel switcher and the help screen")) } } .padding(.horizontal, 12) @@ -368,7 +368,7 @@ private extension MessageListView { if unseenCount > 0 { Text( String( - format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"), + format: String(localized: "content.jump.new_count", defaultValue: "%lld new", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"), locale: .current, unseenCount ) @@ -389,10 +389,10 @@ private extension MessageListView { } var jumpToLatestAccessibilityLabel: String { - let base = String(localized: "content.accessibility.jump_to_latest", comment: "Accessibility label for the jump to latest messages button") + let base = String(localized: "content.accessibility.jump_to_latest", defaultValue: "jump to latest messages", comment: "Accessibility label for the jump to latest messages button") guard unseenCount > 0 else { return base } let count = String( - format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"), + format: String(localized: "content.jump.new_count", defaultValue: "%lld new", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"), locale: .current, unseenCount ) diff --git a/bitchat/Views/MessageTextHelpers.swift b/bitchat/Views/MessageTextHelpers.swift index bd653ec9..c684d3f1 100644 --- a/bitchat/Views/MessageTextHelpers.swift +++ b/bitchat/Views/MessageTextHelpers.swift @@ -21,17 +21,20 @@ extension String { return current >= threshold } - // Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths. + // Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare + // bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI + // form matches too — the token embedded after the scheme is the match. func extractCashuLinks(max: Int = 3) -> [String] { let regex = MessageFormattingEngine.Patterns.cashu let ns = self as NSString let range = NSRange(location: 0, length: ns.length) var found: [String] = [] - for m in regex.matches(in: self, range: range) { - if m.numberOfRanges > 0 { - let token = ns.substring(with: m.range(at: 0)) - let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token - found.append("cashu:\(enc)") + for m in regex.matches(in: self, range: range) where m.numberOfRanges > 0 { + let token = ns.substring(with: m.range(at: 0)) + // Dedup: repeated tokens are one bearer instrument (and duplicate + // ForEach IDs) — one chip is enough. + if !found.contains(token) { + found.append(token) if found.count >= max { break } } } diff --git a/bitchat/Views/VerificationViews.swift b/bitchat/Views/VerificationViews.swift index 1fba4f2d..87288122 100644 --- a/bitchat/Views/VerificationViews.swift +++ b/bitchat/Views/VerificationViews.swift @@ -18,7 +18,7 @@ struct MyQRView: View { private enum Strings { static let title: LocalizedStringKey = "verification.my_qr.title" - static let accessibilityLabel = String(localized: "verification.my_qr.accessibility_label", comment: "Accessibility label describing the verification QR code") + static let accessibilityLabel = String(localized: "verification.my_qr.accessibility_label", defaultValue: "verification QR code", comment: "Accessibility label describing the verification QR code") } var body: some View { @@ -124,13 +124,13 @@ struct QRScanView: View { static let validate: LocalizedStringKey = "verification.scan.validate" static func requested(_ nickname: String) -> String { String( - format: String(localized: "verification.scan.status.requested", comment: "Status text when verification is requested for a nickname"), + format: String(localized: "verification.scan.status.requested", defaultValue: "verification requested for %@", comment: "Status text when verification is requested for a nickname"), locale: .current, nickname ) } - static let notFound = String(localized: "verification.scan.status.no_peer", comment: "Status when no matching peer is found for a verification request") - static let invalid = String(localized: "verification.scan.status.invalid", comment: "Status when a scanned QR payload is invalid") + static let notFound = String(localized: "verification.scan.status.no_peer", defaultValue: "could not find matching peer", comment: "Status when no matching peer is found for a verification request") + static let invalid = String(localized: "verification.scan.status.invalid", defaultValue: "invalid or expired QR payload", comment: "Status when a scanned QR payload is invalid") } var body: some View { @@ -296,7 +296,7 @@ struct VerificationSheetView: View { VStack(spacing: 0) { // Top header (always at top) HStack { - Text("verification.sheet.title") + Text(String(localized: "verification.sheet.title", defaultValue: "VERIFY")) .bitchatFont(size: 14, weight: .bold) .foregroundColor(accentColor) Spacer() @@ -316,7 +316,7 @@ struct VerificationSheetView: View { Group { if showingScanner { VStack(alignment: .leading, spacing: 12) { - Text("verification.scan.prompt_friend") + Text(String(localized: "verification.scan.prompt_friend", defaultValue: "scan a friend's QR")) .bitchatFont(size: 16, weight: .bold) .frame(maxWidth: .infinity) .multilineTextAlignment(.center) diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 0b70f0e4..69b7fc89 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -591,6 +591,45 @@ struct AppArchitectureTests { #expect(!verificationModel.isVerified(peerID: peerID)) } + @Test("VerificationModel refreshes when peer trust changes (vouch accepted)") + @MainActor + func verificationModelRefreshesOnPeerTrustChange() async { + let viewModel = makeArchitectureViewModel() + var privateConversationModel: PrivateConversationModel? = PrivateConversationModel( + chatViewModel: viewModel, + conversations: viewModel.conversations, + locationChannelsModel: LocationChannelsModel(manager: makeArchitectureLocationManager()) + ) + let verificationModel = VerificationModel( + chatViewModel: viewModel, + privateConversationModel: privateConversationModel! + ) + + // PrivateConversationModel happens to observe the same notification + // and re-assign its published selection, which would ripple into + // VerificationModel; release it so this test pins VerificationModel's + // own subscription rather than that incidental chain. + privateConversationModel = nil + + // The bound @Published sources replay their current values on + // subscription; let those initial main-queue emissions settle so the + // sink below observes only the trust-change signal. + try? await Task.sleep(nanoseconds: 100_000_000) + + // ChatVouchCoordinator.notifyPeerTrustChanged() signals accepted + // vouches via "peerStatusUpdated"; an open fingerprint sheet must + // re-render its vouched badge from that signal alone. + var refreshed = false + let cancellable = verificationModel.objectWillChange.sink { _ in + refreshed = true + } + defer { cancellable.cancel() } + + NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil) + await waitUntil { refreshed } + #expect(refreshed) + } + @Test("PeerListModel publishes mesh and geohash directory state") @MainActor func peerListModelPublishesDirectoryState() async { diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index fe7624ec..1b783235 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -216,6 +216,114 @@ struct BLEServiceCoreTests { cachedServiceUUIDs: [BLEService.serviceUUID, otherService] )) } + + // Regression: a stable/verified private chat addresses the peer by its + // full 64-hex Noise key, but the Noise session (and capabilities/ + // connection) are keyed by the short routing ID. The Wi-Fi bulk send path + // must normalize the ID first, or `hasEstablishedSession` misses and + // Wi-Fi is never offered for a large payload to a direct `.wifiBulk` peer. + @Test + func wifiBulkEligibility_resolvesWhenAddressedBy64HexNoiseKey() throws { + let ble = makeService() + + let noiseKey = Data((0..<32).map { UInt8(($0 &* 7) &+ 3) }) + let fullKey = PeerID(str: noiseKey.hexEncodedString()) // 64-hex Noise key + #expect(fullKey.noiseKey != nil) + let shortID = fullKey.toShort() // 16-hex routing ID + #expect(shortID != fullKey) + + // Establish a real Noise session in the service's noise engine, keyed + // by the short routing ID (exactly as a completed handshake would). + let peer = NoiseEncryptionService(keychain: MockKeychain()) + let noise = ble._test_noiseService + let m1 = try noise.initiateHandshake(with: shortID) + let m2 = try #require(try peer.processHandshakeMessage(from: shortID, message: m1)) + let m3 = try #require(try noise.processHandshakeMessage(from: shortID, message: m2)) + _ = try peer.processHandshakeMessage(from: shortID, message: m3) + + #expect(noise.hasEstablishedSession(with: shortID)) + // The bug in raw form: querying by the 64-hex key misses the session. + #expect(!noise.hasEstablishedSession(with: fullKey)) + + // Register the peer as a directly-connected `.wifiBulk` neighbor. + ble._test_registerConnectedPeer(fullKey, capabilities: [.wifiBulk]) + + let bigPayload = FileTransferLimits.maxWifiBulkPayloadBytes + + // The send path normalizes first, so eligibility + offer resolve for + // both the short ID and the full 64-hex Noise key. + let viaShort = ble._test_wifiBulkSendCandidate(payloadBytes: bigPayload, to: shortID) + let viaFull = ble._test_wifiBulkSendCandidate(payloadBytes: bigPayload, to: fullKey) + #expect(viaShort.hasEstablishedNoiseSession) + #expect(viaFull.hasEstablishedNoiseSession) + #expect(viaFull.isDirectlyConnected) + #expect(viaFull.peerCapabilities.contains(.wifiBulk)) + #expect(WifiBulkPolicy.shouldOffer(viaShort, enabled: true)) + #expect(WifiBulkPolicy.shouldOffer(viaFull, enabled: true)) + } + + /// Pings are unsigned, so their claimed sender is attacker-controlled. + /// The pong budget must be keyed on the ingress link (the directly + /// connected peer that delivered the packet): rotating forged sender IDs + /// over one link exhausts one budget instead of resetting it, so a single + /// malicious link cannot turn /ping into an amplification primitive. + @Test + func meshPingResponseBudget_isPerIngressLinkNotClaimedSender() async throws { + let ble = makeService() + let outbound = OutboundPacketTap() + ble._test_onOutboundPacket = outbound.record + + let link = PeerID(str: "1122334455667788") + let budget = TransportConfig.meshPingInboundMaxPerLink + let myRecipientData = try #require(Data(hexString: ble.myPeerID.id)) + + for i in 0..<(budget * 2) { + // A fresh forged sender for every ping, all arriving on one link. + let forgedSender = PeerID(str: String(format: "%016x", 0xA0_0000 + i)) + var nonce = Data(repeating: 0, count: MeshPingPayload.nonceLength) + nonce[0] = UInt8(i) + let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 7)) + let packet = BitchatPacket( + type: MessageType.ping.rawValue, + senderID: Data(hexString: forgedSender.id) ?? Data(), + recipientID: myRecipientData, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload.encode(), + signature: nil, + ttl: 7 + ) + ble._test_handlePacket(packet, fromPeerID: link, preseedPeer: false) + } + + let reachedBudget = await TestHelpers.waitUntil( + { outbound.count(ofType: .pong) >= budget }, + timeout: TestConstants.defaultTimeout + ) + #expect(reachedBudget) + // Give any over-budget pong a chance to surface, then confirm the + // rotated sender IDs never bought a sixth response. + let exceededBudget = await TestHelpers.waitUntil( + { outbound.count(ofType: .pong) > budget }, + timeout: TestConstants.shortTimeout + ) + #expect(!exceededBudget) + #expect(outbound.count(ofType: .pong) == budget) + } +} + +/// Thread-safe capture of packets leaving the service under test. +private final class OutboundPacketTap { + private let lock = NSLock() + private var packets: [BitchatPacket] = [] + + func record(_ packet: BitchatPacket) { + lock.lock(); packets.append(packet); lock.unlock() + } + + func count(ofType type: MessageType) -> Int { + lock.lock(); defer { lock.unlock() } + return packets.filter { $0.type == type.rawValue }.count + } } private func makeService() -> BLEService { diff --git a/bitchatTests/CashuTokenDecoderTests.swift b/bitchatTests/CashuTokenDecoderTests.swift new file mode 100644 index 00000000..890a7eeb --- /dev/null +++ b/bitchatTests/CashuTokenDecoderTests.swift @@ -0,0 +1,350 @@ +// +// CashuTokenDecoderTests.swift +// bitchatTests +// +// Tests for the Cashu token summary decoder: V3 JSON decode, minimal V4 +// CBOR traversal, URI normalization, detection ranges, and adversarial +// (truncated / garbage / huge) input. The decoder renders attacker-controlled +// message content, so "never crash" matters as much as "decode correctly". +// This is free and unencumbered software released into the public domain. +// + +import Foundation +import Testing +@testable import bitchat + +struct CashuTokenDecoderTests { + + // MARK: - Token Builders + + private func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private func makeV3Token( + entries: [(mint: String, amounts: [Int])], + unit: String? = "sat", + memo: String? = nil + ) -> String { + var json: [String: Any] = [ + "token": entries.map { entry in + [ + "mint": entry.mint, + "proofs": entry.amounts.map { + ["amount": $0, "id": "009a1f293253e41e", "secret": "s", "C": "02c"] as [String: Any] + } + ] as [String: Any] + } + ] + if let unit { json["unit"] = unit } + if let memo { json["memo"] = memo } + let data = try! JSONSerialization.data(withJSONObject: json) + return "cashuA" + base64URL(data) + } + + /// Tiny deterministic CBOR encoder (definite lengths only) for building + /// V4 test tokens without depending on the decoder under test. + private enum CBOREncode { + static func head(_ major: UInt8, _ value: UInt64) -> [UInt8] { + switch value { + case 0...23: + return [(major << 5) | UInt8(value)] + case 24...0xFF: + return [(major << 5) | 24, UInt8(value)] + case 0x100...0xFFFF: + return [(major << 5) | 25, UInt8(value >> 8), UInt8(value & 0xFF)] + default: + return [(major << 5) | 26, + UInt8((value >> 24) & 0xFF), UInt8((value >> 16) & 0xFF), + UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF)] + } + } + static func uint(_ v: UInt64) -> [UInt8] { head(0, v) } + static func bytes(_ b: [UInt8]) -> [UInt8] { head(2, UInt64(b.count)) + b } + static func text(_ s: String) -> [UInt8] { + let utf8 = Array(s.utf8) + return head(3, UInt64(utf8.count)) + utf8 + } + static func array(_ items: [[UInt8]]) -> [UInt8] { + head(4, UInt64(items.count)) + items.flatMap { $0 } + } + static func map(_ pairs: [(String, [UInt8])]) -> [UInt8] { + head(5, UInt64(pairs.count)) + pairs.flatMap { text($0.0) + $0.1 } + } + } + + private func makeV4Token( + mint: String = "https://mint.example.com", + unit: String = "sat", + memo: String? = nil, + amounts: [UInt64] = [1, 4] + ) -> String { + var pairs: [(String, [UInt8])] = [ + ("m", CBOREncode.text(mint)), + ("u", CBOREncode.text(unit)) + ] + if let memo { pairs.append(("d", CBOREncode.text(memo))) } + let proofs = amounts.map { amount in + CBOREncode.map([ + ("a", CBOREncode.uint(amount)), + ("s", CBOREncode.text("secret")), + ("c", CBOREncode.bytes([0x02, 0xAB, 0xCD])) + ]) + } + pairs.append(("t", CBOREncode.array([ + CBOREncode.map([ + ("i", CBOREncode.bytes([0x00, 0xAD, 0x26, 0x8C])), + ("p", CBOREncode.array(proofs)) + ]) + ]))) + return "cashuB" + base64URL(Data(CBOREncode.map(pairs))) + } + + // MARK: - V3 Decode + + @Test func v3DecodeValidToken() { + let token = makeV3Token( + entries: [("https://mint.example.com", [2, 8])], + unit: "sat", + memo: "thanks!" + ) + let info = CashuTokenDecoder.decode(token) + #expect(info != nil) + #expect(info?.version == "A") + #expect(info?.amount == 10) + #expect(info?.unit == "sat") + #expect(info?.mintHost == "mint.example.com") + #expect(info?.memo == "thanks!") + #expect(info?.displayAmount == "10 sat") + } + + @Test func v3AmountSumsAcrossEntriesAndProofs() { + let token = makeV3Token(entries: [ + ("https://a.mint.example", [1, 2, 4]), + ("https://b.mint.example", [8, 16]) + ]) + let info = CashuTokenDecoder.decode(token) + #expect(info?.amount == 31) + // First mint wins for the display host + #expect(info?.mintHost == "a.mint.example") + } + + @Test func v3MissingUnitDefaultsToSatForDisplay() { + let token = makeV3Token(entries: [("https://mint.example.com", [5])], unit: nil) + let info = CashuTokenDecoder.decode(token) + #expect(info?.unit == nil) + #expect(info?.displayAmount == "5 sat") + } + + @Test func v3RejectsNonsenseAmounts() { + // Negative and absurd amounts must not poison the sum + let json: [String: Any] = [ + "token": [[ + "mint": "https://mint.example.com", + "proofs": [ + ["amount": -5, "id": "x", "secret": "s", "C": "c"], + ["amount": 3, "id": "x", "secret": "s", "C": "c"] + ] + ] as [String: Any]] + ] + let token = "cashuA" + base64URL(try! JSONSerialization.data(withJSONObject: json)) + #expect(CashuTokenDecoder.decode(token)?.amount == 3) + } + + @Test func v3MemoIsSanitizedForDisplay() { + let token = makeV3Token( + entries: [("https://mint.example.com", [1])], + memo: "line1\nline2\u{0007}" + String(repeating: "x", count: 300) + ) + let memo = CashuTokenDecoder.decode(token)?.memo + #expect(memo != nil) + #expect(memo?.contains("\n") == false) + #expect(memo?.contains("\u{0007}") == false) + #expect((memo?.count ?? 0) <= 80) + } + + // MARK: - V4 (CBOR) Decode + + @Test func v4DecodeValidToken() { + let token = makeV4Token(memo: "Thank you", amounts: [1, 4, 16]) + let info = CashuTokenDecoder.decode(token) + #expect(info?.version == "B") + #expect(info?.amount == 21) + #expect(info?.unit == "sat") + #expect(info?.mintHost == "mint.example.com") + #expect(info?.memo == "Thank you") + } + + @Test func v4UnparseableCBORDegradesToGenericToken() { + // Valid base64 payload, but not CBOR we can walk: still a token, + // rendered as a generic chip with no amount. + let token = "cashuB" + base64URL(Data([0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0x02])) + let info = CashuTokenDecoder.decode(token) + #expect(info?.version == "B") + #expect(info?.amount == nil) + #expect(info?.mintHost == nil) + } + + // MARK: - Strict Mode (used by the /pay SEND path) + + @Test func strictAcceptsValidV3WithPositiveAmount() { + let token = makeV3Token(entries: [("https://mint.example.com", [2, 8])]) + let info = CashuTokenDecoder.decode(token, strict: true) + #expect(info?.version == "A") + #expect(info?.amount == 10) + } + + @Test func strictAcceptsValidDefiniteLengthV4() { + let token = makeV4Token(amounts: [1, 4, 16]) + let info = CashuTokenDecoder.decode(token, strict: true) + #expect(info?.version == "B") + #expect(info?.amount == 21) + } + + @Test func strictRejectsUnwalkableV4() { + // Valid base64, but not CBOR we can walk: permissive mode returns a + // generic chip, strict mode refuses it. + let token = "cashuB" + base64URL(Data([0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0x02])) + #expect(CashuTokenDecoder.decode(token)?.version == "B") + #expect(CashuTokenDecoder.decode(token, strict: true) == nil) + } + + @Test func strictRejectsTruncatedV4() { + let token = makeV4Token(amounts: [1, 4, 16]) + // Lop off the tail of the base64 payload — CBOR can no longer be walked. + let truncated = String(token.prefix(token.count - 12)) + #expect(CashuTokenDecoder.decode(truncated, strict: true) == nil) + } + + @Test func strictRejectsAmountlessToken() { + // A well-formed V3 token that carries no positive proof amount. + let json: [String: Any] = [ + "token": [[ + "mint": "https://mint.example.com", + "proofs": [["amount": 0, "id": "x", "secret": "s", "C": "c"] as [String: Any]] + ] as [String: Any]] + ] + let token = "cashuA" + base64URL(try! JSONSerialization.data(withJSONObject: json)) + #expect(CashuTokenDecoder.decode(token)?.amount == nil) + #expect(CashuTokenDecoder.decode(token, strict: true) == nil) + } + + // MARK: - URI Form and Normalization + + @Test func uriFormsDecode() { + let token = makeV3Token(entries: [("https://mint.example.com", [7])]) + for wrapped in ["cashu:\(token)", "cashu://\(token)", "CASHU:\(token)"] { + #expect(CashuTokenDecoder.bareToken(from: wrapped) == token, "failed for \(wrapped)") + #expect(CashuTokenDecoder.decode(wrapped)?.amount == 7) + } + } + + @Test func percentEncodedURIDecodes() { + let token = makeV3Token(entries: [("https://mint.example.com", [7])]) + let encoded = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! + #expect(CashuTokenDecoder.decode("cashu:\(encoded)")?.amount == 7) + } + + @Test func bareTokenRejectsNonTokens() { + #expect(CashuTokenDecoder.bareToken(from: "hello world") == nil) + #expect(CashuTokenDecoder.bareToken(from: "cashuC" + String(repeating: "a", count: 50)) == nil) + #expect(CashuTokenDecoder.bareToken(from: "cashuA{not-base64!}") == nil) + #expect(CashuTokenDecoder.bareToken(from: "cashuA") == nil) // too short + } + + // MARK: - Adversarial Input (never crash, fail closed) + + @Test func truncatedTokensNeverCrash() { + let v3 = makeV3Token(entries: [("https://mint.example.com", [1, 2, 4, 8])], memo: "memo") + let v4 = makeV4Token(memo: "memo", amounts: [1, 2, 4, 8]) + for token in [v3, v4] { + for length in stride(from: 0, to: token.count, by: 3) { + _ = CashuTokenDecoder.decode(String(token.prefix(length))) + } + } + // Truncating the payload must not produce a phantom V3 summary + #expect(CashuTokenDecoder.decode(String(v3.prefix(v3.count - 10))) == nil) + } + + @Test func garbagePayloadsNeverCrash() { + var rng = SystemRandomNumberGenerator() + for _ in 0..<200 { + let length = Int.random(in: 0..<600, using: &rng) + let junk = Data((0.. [String] { [] } diff --git a/bitchatTests/ChatOutgoingCoordinatorContextTests.swift b/bitchatTests/ChatOutgoingCoordinatorContextTests.swift index 6ed7c6da..25aa8300 100644 --- a/bitchatTests/ChatOutgoingCoordinatorContextTests.swift +++ b/bitchatTests/ChatOutgoingCoordinatorContextTests.swift @@ -192,6 +192,9 @@ struct ChatOutgoingCoordinatorContextTests { context.isTeleported = true coordinator.sendMessage("hello geo") + // Geohash sends mine a NIP-13 nonce tag off-main before echoing and + // sending; await the send task, then drain the main queue. + await coordinator.geohashMiningTask?.value await drainMainActorTasks() // Local echo carries the geohash sender suffix (#last-4-of-pubkey) and @@ -215,4 +218,35 @@ struct ChatOutgoingCoordinatorContextTests { #expect(context.appendedPublicMessages.count == 1) #expect(context.sentGeohashContexts.count == 1) } + + @Test @MainActor + func sendMessage_onLocationChannel_serializesRapidSendsInSendOrder() async { + let context = MockChatOutgoingContext() + let coordinator = ChatOutgoingCoordinator(context: context) + let channel = GeohashChannel(level: .city, geohash: "u4pruydq") + context.activeChannel = .location(channel) + + // Two back-to-back sends. The first carries much larger content, so + // its NIP-13 mining hashes a bigger event per attempt and runs longer + // than the second's. Without serialization the second (faster) task + // could finish first and reorder both the local timeline and the + // relayed events. The coordinator chains the mining tasks — each send + // awaits the previous send's task before it echoes and relays — so the + // visible order must always match the send order. + let first = "first " + String(repeating: "x", count: 4000) + let second = "second" + coordinator.sendMessage(first) + coordinator.sendMessage(second) + + // The stored task is the second send, which awaits the first. + await coordinator.geohashMiningTask?.value + await drainMainActorTasks() + + // Local echoes land in send order… + #expect(context.appendedPublicMessages.map(\.message.content) == [first, second]) + // …and so do the relayed events (IDs match the echoes 1:1, in order). + #expect(context.sentGeohashContexts.count == 2) + #expect(context.sentGeohashContexts.map(\.event.id) + == context.appendedPublicMessages.map(\.message.id)) + } } diff --git a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift index 9bcd2b93..be76fe82 100644 --- a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift @@ -186,7 +186,7 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon // Inbound public message processing var blockedMessageIDs: Set = [] var rateLimitAllowed = true - private(set) var rateLimitChecks: [(senderKey: String, contentKey: String)] = [] + private(set) var rateLimitChecks: [(senderKey: String, contentKey: String, powBits: Int)] = [] private(set) var enqueuedMessages: [(messageID: String, conversationID: ConversationID)] = [] var enqueuedMessageIDs: [String] { enqueuedMessages.map(\.messageID) } var stablePeerIDs: [PeerID: PeerID] = [:] @@ -199,8 +199,8 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon blockedMessageIDs.contains(message.id) } - func allowPublicMessage(senderKey: String, contentKey: String) -> Bool { - rateLimitChecks.append((senderKey, contentKey)) + func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool { + rateLimitChecks.append((senderKey, contentKey, powBits)) return rateLimitAllowed } diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift index 1c1fc46f..fed3aa1b 100644 --- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift +++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift @@ -156,6 +156,24 @@ private final class MockChatTransportEventContext: ChatTransportEventContext { func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) { verifyResponsePayloads.append((peerID, payload)) } + + private(set) var vouchPayloads: [(peerID: PeerID, payload: Data)] = [] + + func handleVouchPayload(from peerID: PeerID, payload: Data) { + vouchPayloads.append((peerID, payload)) + } + + // Group payloads + private(set) var groupInvitePayloads: [(peerID: PeerID, payload: Data)] = [] + private(set) var groupKeyUpdatePayloads: [(peerID: PeerID, payload: Data)] = [] + + func handleGroupInvitePayload(from peerID: PeerID, payload: Data) { + groupInvitePayloads.append((peerID, payload)) + } + + func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) { + groupKeyUpdatePayloads.append((peerID, payload)) + } } // MARK: - Helpers diff --git a/bitchatTests/ChatVouchCoordinatorContextTests.swift b/bitchatTests/ChatVouchCoordinatorContextTests.swift new file mode 100644 index 00000000..0602b7ef --- /dev/null +++ b/bitchatTests/ChatVouchCoordinatorContextTests.swift @@ -0,0 +1,285 @@ +// +// ChatVouchCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatVouchCoordinator` against a mock `ChatVouchContext` — +// proving the exchange policy (verified + capable peers only, batch cap, +// 24h rate limit) and the accept policy (verified senders only, real +// Ed25519 signature verification, expiry) without a `ChatViewModel`. +// Storage-level gates (self-vouch, already-verified vouchee, per-vouchee +// cap) are covered by `SecureIdentityStateManagerVouchTests`. +// + +import CryptoKit +import Foundation +import BitFoundation +import Testing + +@testable import bitchat + +// MARK: - Mock Context + +@MainActor +private final class MockChatVouchContext: ChatVouchContext { + // Identity & trust state + var fingerprintsByPeerID: [PeerID: String] = [:] + var verifiedFingerprints: Set = [] + var signingKeysByFingerprint: [String: Data] = [:] + var recentVerified: [String] = [] + private(set) var recentVerifiedRequests: [(limit: Int, excluding: String)] = [] + private(set) var recordedVouches: [(vouchee: String, voucher: String, timestamp: Date)] = [] + var recordVouchResult = true + var lastBatchSentAt: [String: Date] = [:] + private(set) var markedBatchSent: [(fingerprint: String, date: Date)] = [] + + func getFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] } + func isVerifiedFingerprint(_ fingerprint: String) -> Bool { verifiedFingerprints.contains(fingerprint) } + func signingKey(forFingerprint fingerprint: String) -> Data? { signingKeysByFingerprint[fingerprint] } + + func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] { + recentVerifiedRequests.append((limit, fingerprint)) + return Array(recentVerified.filter { $0 != fingerprint }.prefix(limit)) + } + + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool { + recordedVouches.append((voucheeFingerprint, voucherFingerprint, timestamp)) + return recordVouchResult + } + + func lastVouchBatchSent(to fingerprint: String) -> Date? { lastBatchSentAt[fingerprint] } + + func markVouchBatchSent(to fingerprint: String, at date: Date) { + markedBatchSent.append((fingerprint, date)) + lastBatchSentAt[fingerprint] = date + } + + // Transport + var capabilitiesByPeerID: [PeerID: PeerCapabilities] = [:] + var mySigningKey = Curve25519.Signing.PrivateKey() + private(set) var installedObservers: [(PeerID, String) -> Void] = [] + private(set) var sentVouchPayloads: [(payload: Data, peerID: PeerID)] = [] + + func peerCapabilities(for peerID: PeerID) -> PeerCapabilities { capabilitiesByPeerID[peerID] ?? [] } + + func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) { + installedObservers.append(handler) + } + + func noiseSignData(_ data: Data) -> Data? { try? mySigningKey.signature(for: data) } + + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) { + sentVouchPayloads.append((payload, peerID)) + } + + // UI refresh + private(set) var trustChangedCount = 0 + + func notifyPeerTrustChanged() { trustChangedCount += 1 } +} + +// MARK: - Tests + +struct ChatVouchCoordinatorContextTests { + private let peerID = PeerID(str: "1122334455667788") + private let peerFingerprint = String(repeating: "0f", count: 32) + + @MainActor + private func makeVerifiedCapablePeer() -> (MockChatVouchContext, ChatVouchCoordinator) { + let context = MockChatVouchContext() + let coordinator = ChatVouchCoordinator(context: context) + context.fingerprintsByPeerID[peerID] = peerFingerprint + context.verifiedFingerprints.insert(peerFingerprint) + context.capabilitiesByPeerID[peerID] = [.vouch] + return (context, coordinator) + } + + // MARK: Exchange policy + + @Test @MainActor + func peerAuthenticated_sendsBatchForVerifiedCapablePeer() throws { + let (context, coordinator) = makeVerifiedCapablePeer() + let vouchees = [String(repeating: "01", count: 32), String(repeating: "02", count: 32)] + context.recentVerified = vouchees + for vouchee in vouchees { + context.signingKeysByFingerprint[vouchee] = Data(repeating: 0x33, count: 32) + } + + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) + + // Candidates are requested most-recent-first, excluding the target. + #expect(context.recentVerifiedRequests.count == 1) + #expect(context.recentVerifiedRequests.first?.limit == VouchAttestation.maxBatchCount) + #expect(context.recentVerifiedRequests.first?.excluding == peerFingerprint) + + let sent = try #require(context.sentVouchPayloads.first) + #expect(sent.peerID == peerID) + let attestations = VouchAttestation.decodeList(from: sent.payload) + #expect(attestations.map(\.voucheeFingerprintHex) == vouchees) + // Every attestation carries a valid signature under our signing key. + let myPublicKey = context.mySigningKey.publicKey.rawRepresentation + #expect(attestations.allSatisfy { $0.verifySignature(voucherSigningKey: myPublicKey) }) + + // The rate limit is stamped only after an actual send. + #expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint]) + } + + @Test @MainActor + func peerAuthenticated_requiresVerificationAndCapability() { + let (context, coordinator) = makeVerifiedCapablePeer() + context.recentVerified = [String(repeating: "01", count: 32)] + context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32) + + // Not verified by me: nothing. + context.verifiedFingerprints.remove(peerFingerprint) + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) + #expect(context.sentVouchPayloads.isEmpty) + + // Verified but no .vouch capability: nothing. + context.verifiedFingerprints.insert(peerFingerprint) + context.capabilitiesByPeerID[peerID] = [] + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) + #expect(context.sentVouchPayloads.isEmpty) + #expect(context.markedBatchSent.isEmpty) + } + + @Test @MainActor + func peerAuthenticated_rateLimitsPerPeerPer24Hours() { + let (context, coordinator) = makeVerifiedCapablePeer() + context.recentVerified = [String(repeating: "01", count: 32)] + context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32) + + let now = Date() + context.lastBatchSentAt[peerFingerprint] = now.addingTimeInterval(-60 * 60) + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint, now: now) + #expect(context.sentVouchPayloads.isEmpty) + + // Once the interval has elapsed the batch goes out again. + context.lastBatchSentAt[peerFingerprint] = now.addingTimeInterval(-ChatVouchCoordinator.batchInterval - 1) + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint, now: now) + #expect(context.sentVouchPayloads.count == 1) + } + + @Test @MainActor + func peerAuthenticated_skipsCandidatesWithoutSigningKeysAndEmptyBatches() { + let (context, coordinator) = makeVerifiedCapablePeer() + let withKey = String(repeating: "01", count: 32) + let withoutKey = String(repeating: "02", count: 32) + context.recentVerified = [withoutKey, withKey] + context.signingKeysByFingerprint[withKey] = Data(repeating: 0x33, count: 32) + + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) + let attestations = VouchAttestation.decodeList(from: context.sentVouchPayloads[0].payload) + #expect(attestations.map(\.voucheeFingerprintHex) == [withKey]) + + // No signable candidates at all: nothing is sent or rate-stamped. + let freshPeer = PeerID(str: "aabbccddeeff0011") + let freshFingerprint = String(repeating: "0e", count: 32) + context.fingerprintsByPeerID[freshPeer] = freshFingerprint + context.verifiedFingerprints.insert(freshFingerprint) + context.capabilitiesByPeerID[freshPeer] = [.vouch] + context.recentVerified = [withoutKey] + coordinator.peerAuthenticated(freshPeer, fingerprint: freshFingerprint) + #expect(context.sentVouchPayloads.count == 1) + #expect(!context.markedBatchSent.contains { $0.fingerprint == freshFingerprint }) + } + + // MARK: Accept policy + + @MainActor + private func makeInboundBatch( + signedBy key: Curve25519.Signing.PrivateKey, + vouchee: String = String(repeating: "07", count: 32), + timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000) + ) throws -> Data { + let voucheeData = try #require(Data(hexString: vouchee)) + let attestation = try #require(VouchAttestation.build( + voucheeFingerprint: voucheeData, + voucheeSigningKey: Data(repeating: 0x44, count: 32), + timestampMs: timestampMs, + sign: { try? key.signature(for: $0) } + )) + return try #require(VouchAttestation.encodeList([attestation])) + } + + @Test @MainActor + func handleVouchPayload_acceptsValidVouchFromVerifiedSender() throws { + let (context, coordinator) = makeVerifiedCapablePeer() + let senderKey = Curve25519.Signing.PrivateKey() + context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation + + let vouchee = String(repeating: "07", count: 32) + let payload = try makeInboundBatch(signedBy: senderKey, vouchee: vouchee) + coordinator.handleVouchPayload(from: peerID, payload: payload) + + #expect(context.recordedVouches.count == 1) + #expect(context.recordedVouches.first?.vouchee == vouchee) + #expect(context.recordedVouches.first?.voucher == peerFingerprint) + #expect(context.trustChangedCount == 1) + } + + @Test @MainActor + func handleVouchPayload_rejectsUnverifiedOrUnknownSender() throws { + let (context, coordinator) = makeVerifiedCapablePeer() + let senderKey = Curve25519.Signing.PrivateKey() + context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation + let payload = try makeInboundBatch(signedBy: senderKey) + + // Sender's fingerprint is not in my verified set. + context.verifiedFingerprints.remove(peerFingerprint) + coordinator.handleVouchPayload(from: peerID, payload: payload) + #expect(context.recordedVouches.isEmpty) + + // Unknown peer entirely. + coordinator.handleVouchPayload(from: PeerID(str: "ffeeddccbbaa9988"), payload: payload) + #expect(context.recordedVouches.isEmpty) + #expect(context.trustChangedCount == 0) + } + + @Test @MainActor + func handleVouchPayload_rejectsForgedSignaturesAndExpiredAttestations() throws { + let (context, coordinator) = makeVerifiedCapablePeer() + let senderKey = Curve25519.Signing.PrivateKey() + context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation + + // Signed by an imposter key: signature check against the sender's + // announce-bound key fails. + let imposter = Curve25519.Signing.PrivateKey() + let forged = try makeInboundBatch(signedBy: imposter) + coordinator.handleVouchPayload(from: peerID, payload: forged) + #expect(context.recordedVouches.isEmpty) + + // Correctly signed but expired. + let staleMs = UInt64(Date().addingTimeInterval(-31 * 24 * 60 * 60).timeIntervalSince1970 * 1000) + let expired = try makeInboundBatch(signedBy: senderKey, timestampMs: staleMs) + coordinator.handleVouchPayload(from: peerID, payload: expired) + #expect(context.recordedVouches.isEmpty) + #expect(context.trustChangedCount == 0) + + // No signing key known for the sender: batch dropped. + context.signingKeysByFingerprint.removeValue(forKey: peerFingerprint) + let valid = try makeInboundBatch(signedBy: senderKey) + coordinator.handleVouchPayload(from: peerID, payload: valid) + #expect(context.recordedVouches.isEmpty) + } + + @Test @MainActor + func handleVouchPayload_skipsUIRefreshWhenNothingStored() throws { + let (context, coordinator) = makeVerifiedCapablePeer() + let senderKey = Curve25519.Signing.PrivateKey() + context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation + context.recordVouchResult = false // e.g. self-vouch dropped by the store + + let payload = try makeInboundBatch(signedBy: senderKey) + coordinator.handleVouchPayload(from: peerID, payload: payload) + #expect(context.recordedVouches.count == 1) + #expect(context.trustChangedCount == 0) + } + + @Test @MainActor + func setupNoiseCallbacks_installsAdditiveObserver() { + let (context, coordinator) = makeVerifiedCapablePeer() + coordinator.setupNoiseCallbacks() + #expect(context.installedObservers.count == 1) + } +} diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index 99bed2b5..3da82140 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -370,6 +370,173 @@ struct CommandProcessorTests { } } + // MARK: - /pay + + @MainActor + @Test func payWithoutArgumentsPrintsUsage() { + let processor = makePayProcessor(context: MockCommandContextProvider()) + switch processor.process("/pay") { + case .success(let message): + #expect(message?.contains("usage: /pay") == true) + default: + Issue.record("Expected success (usage) result") + } + } + + @MainActor + @Test func payRejectsInvalidToken() { + let context = MockCommandContextProvider() + let processor = makePayProcessor(context: context) + for bad in ["/pay nonsense", "/pay cashuAshort", "/pay cashuA!!!!!!!!!!!!!!!!"] { + switch processor.process(bad) { + case .error: + break + default: + Issue.record("Expected error for \(bad)") + } + } + #expect(context.sentPrivateMessages.isEmpty) + #expect(context.sentPublicMessages.isEmpty) + } + + @MainActor + @Test func paySendsBareTokenInPrivateChat() { + let context = MockCommandContextProvider() + let peerID = PeerID(str: "abcd1234abcd1234") + context.selectedPrivateChatPeer = peerID + let processor = makePayProcessor(context: context) + + // cashu: URI form must be normalized to the bare token before sending + switch processor.process("/pay cashu:\(Self.validV3Token)") { + case .success(let message): + #expect(message?.contains("21 sat") == true) + default: + Issue.record("Expected success result") + } + #expect(context.sentPrivateMessages.count == 1) + #expect(context.sentPrivateMessages.first?.content == Self.validV3Token) + #expect(context.sentPrivateMessages.first?.peerID == peerID) + #expect(context.sentPublicMessages.isEmpty) + } + + @MainActor + @Test func payInPublicChannelRequiresExplicitConfirm() { + let context = MockCommandContextProvider() + let processor = makePayProcessor(context: context) + + switch processor.process("/pay \(Self.validV3Token)") { + case .error(let message): + #expect(message.contains("public") == true) + default: + Issue.record("Expected error without confirm") + } + #expect(context.sentPublicMessages.isEmpty) + + switch processor.process("/pay \(Self.validV3Token) public") { + case .success: + break + default: + Issue.record("Expected success with confirm") + } + #expect(context.sentPublicMessages == [Self.validV3Token]) + #expect(context.sentPrivateMessages.isEmpty) + } + + @MainActor + @Test func payRejectsTruncatedOrJunkV4Token() { + let context = MockCommandContextProvider() + context.selectedPrivateChatPeer = PeerID(str: "abcd1234abcd1234") + let processor = makePayProcessor(context: context) + + // Truncated V4 (definite-length CBOR can no longer be walked) and + // pure base64 junk under the cashuB prefix must both be refused. + let truncatedV4 = String(Self.validV4Token.prefix(Self.validV4Token.count - 12)) + let junkV4 = "cashuB" + String(repeating: "Q", count: 40) + for bad in ["/pay \(truncatedV4)", "/pay \(junkV4)"] { + switch processor.process(bad) { + case .error(let message): + #expect(message.contains("invalid cashu token") == true) + default: + Issue.record("Expected error for \(bad)") + } + } + #expect(context.sentPrivateMessages.isEmpty) + #expect(context.sentPublicMessages.isEmpty) + } + + @MainActor + @Test func paySendsValidDefiniteLengthV4Token() { + let context = MockCommandContextProvider() + let peerID = PeerID(str: "abcd1234abcd1234") + context.selectedPrivateChatPeer = peerID + let processor = makePayProcessor(context: context) + + switch processor.process("/pay \(Self.validV4Token)") { + case .success(let message): + #expect(message?.contains("21 sat") == true) + default: + Issue.record("Expected success result for valid V4 token") + } + #expect(context.sentPrivateMessages.count == 1) + #expect(context.sentPrivateMessages.first?.content == Self.validV4Token) + } + + /// 21-sat single-mint V3 token (proofs of 1+4+16). + private static let validV3Token: String = { + let json: [String: Any] = [ + "token": [[ + "mint": "https://mint.example.com", + "proofs": [1, 4, 16].map { ["amount": $0, "id": "009a1f293253e41e", "secret": "s", "C": "02c"] } + ]], + "unit": "sat" + ] + let data = try! JSONSerialization.data(withJSONObject: json) + let b64 = data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "cashuA" + b64 + }() + + /// 21-sat single-mint definite-length V4 (CBOR) token (proofs of 1+4+16). + private static let validV4Token: String = { + func head(_ major: UInt8, _ value: UInt64) -> [UInt8] { + switch value { + case 0...23: return [(major << 5) | UInt8(value)] + case 24...0xFF: return [(major << 5) | 24, UInt8(value)] + default: return [(major << 5) | 25, UInt8(value >> 8), UInt8(value & 0xFF)] + } + } + func text(_ s: String) -> [UInt8] { head(3, UInt64(s.utf8.count)) + Array(s.utf8) } + func bytes(_ b: [UInt8]) -> [UInt8] { head(2, UInt64(b.count)) + b } + func uint(_ v: UInt64) -> [UInt8] { head(0, v) } + func array(_ items: [[UInt8]]) -> [UInt8] { head(4, UInt64(items.count)) + items.flatMap { $0 } } + func map(_ pairs: [(String, [UInt8])]) -> [UInt8] { head(5, UInt64(pairs.count)) + pairs.flatMap { text($0.0) + $0.1 } } + + let proofs = [UInt64(1), 4, 16].map { amount in + map([("a", uint(amount)), ("s", text("secret")), ("c", bytes([0x02, 0xAB, 0xCD]))]) + } + let cbor = map([ + ("m", text("https://mint.example.com")), + ("u", text("sat")), + ("t", array([map([("i", bytes([0x00, 0xAD, 0x26, 0x8C])), ("p", array(proofs))])])) + ]) + let b64 = Data(cbor).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "cashuB" + b64 + }() + + @MainActor + private func makePayProcessor(context: MockCommandContextProvider) -> CommandProcessor { + CommandProcessor( + contextProvider: context, + meshService: MockTransport(), + identityManager: MockIdentityManager(MockKeychain()) + ) + } + @MainActor private func withSelectedChannel( _ channel: ChannelID, @@ -435,6 +602,8 @@ private final class MockCommandContextProvider: CommandContextProvider { private(set) var sentPublicRawMessages: [String] = [] private(set) var localPrivateSystemMessages: [(content: String, peerID: PeerID)] = [] private(set) var publicSystemMessages: [String] = [] + private(set) var commandOutputs: [String] = [] + private(set) var commandOutputDestinations: [CommandOutputDestination] = [] private(set) var toggledFavorites: [PeerID] = [] private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = [] @@ -477,6 +646,11 @@ private final class MockCommandContextProvider: CommandContextProvider { sentPublicRawMessages.append(content) } + private(set) var sentPublicMessages: [String] = [] + func sendPublicMessage(_ content: String) { + sentPublicMessages.append(content) + } + func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) { localPrivateSystemMessages.append((content, peerID)) } @@ -485,6 +659,18 @@ private final class MockCommandContextProvider: CommandContextProvider { publicSystemMessages.append(content) } + func currentCommandDestination() -> CommandOutputDestination { + if let peerID = selectedPrivateChatPeer { + return .privateChat(peerID) + } + return .meshTimeline + } + + func addCommandOutput(_ content: String, to destination: CommandOutputDestination) { + commandOutputs.append(content) + commandOutputDestinations.append(destination) + } + func toggleFavorite(peerID: PeerID) { toggledFavorites.append(peerID) } @@ -492,4 +678,32 @@ private final class MockCommandContextProvider: CommandContextProvider { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { favoriteNotifications.append((peerID, isFavorite)) } + + // Groups: record the parsed subcommand + argument the processor forwarded. + private(set) var groupCommands: [(subcommand: String, argument: String)] = [] + + func groupCreate(named name: String) -> CommandResult { + groupCommands.append(("create", name)) + return .handled + } + + func groupInvite(nickname: String) -> CommandResult { + groupCommands.append(("invite", nickname)) + return .handled + } + + func groupRemove(nickname: String) -> CommandResult { + groupCommands.append(("remove", nickname)) + return .handled + } + + func groupLeave() -> CommandResult { + groupCommands.append(("leave", "")) + return .handled + } + + func groupList() -> CommandResult { + groupCommands.append(("list", "")) + return .handled + } } diff --git a/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift b/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift new file mode 100644 index 00000000..456932a7 --- /dev/null +++ b/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift @@ -0,0 +1,399 @@ +// +// PrekeyEndToEndTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import CoreBluetooth +import BitFoundation +@testable import bitchat + +/// Forward-secret courier flow through real BLEService instances: Bob gossips +/// a signed prekey bundle, Alice verifies and caches it, seals to a one-time +/// prekey instead of Bob's static key, Carol carries the opaque envelope, and +/// Bob opens it with the matching prekey private. +struct PrekeyEndToEndTests { + + // MARK: - Helpers + + private final class PacketTap { + private let lock = NSLock() + private var packets: [BitchatPacket] = [] + + func record(_ packet: BitchatPacket) { + lock.lock(); packets.append(packet); lock.unlock() + } + + func first(ofType type: MessageType) -> BitchatPacket? { + lock.lock(); defer { lock.unlock() } + return packets.first { $0.type == type.rawValue } + } + } + + private final class NoiseCaptureDelegate: BitchatDelegate { + private let lock = NSLock() + private var payloads: [(peerID: PeerID, type: NoisePayloadType, payload: Data)] = [] + + func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) { + lock.lock(); payloads.append((peerID, type, payload)); lock.unlock() + } + + func snapshot() -> [(peerID: PeerID, type: NoisePayloadType, payload: Data)] { + lock.lock(); defer { lock.unlock() } + return payloads + } + + // Unused BitchatDelegate requirements. + func didReceiveMessage(_ message: BitchatMessage) {} + func didConnectToPeer(_ peerID: PeerID) {} + func didDisconnectFromPeer(_ peerID: PeerID) {} + func didUpdatePeerList(_ peers: [PeerID]) {} + func didUpdateBluetoothState(_ state: CBManagerState) {} + func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {} + } + + private func makeService() -> BLEService { + let keychain = MockKeychain() + let service = BLEService( + keychain: keychain, + idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()), + identityManager: MockIdentityManager(keychain), + initializeBluetoothManagers: false + ) + service.courierStore = CourierStore(persistsToDisk: false) + service.prekeyBundleStore = PrekeyBundleStore(persistsToDisk: false) + return service + } + + private func preseedConnectedPeer(_ peer: BLEService, in service: BLEService) { + let packet = BitchatPacket( + type: MessageType.message.rawValue, + senderID: Data(hexString: peer.myPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data("ping".utf8), + signature: nil, + ttl: 1 + ) + service._test_handlePacket(packet, fromPeerID: peer.myPeerID) + } + + /// Broadcast announce + prekey bundle from `peer` and return both packets. + private func captureAnnounceAndBundle(from peer: BLEService, tap: PacketTap) async throws -> (announce: BitchatPacket, bundle: BitchatPacket) { + peer.sendBroadcastAnnounce() + let published = await TestHelpers.waitUntil( + { tap.first(ofType: .announce) != nil && tap.first(ofType: .prekeyBundle) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(published) + return ( + announce: try #require(tap.first(ofType: .announce)), + bundle: try #require(tap.first(ofType: .prekeyBundle)) + ) + } + + // MARK: - Tests + + @Test func prekeySealedMailTravelsViaCourierAndOpens() async throws { + let alice = makeService() + let carol = makeService() + let bob = makeService() + carol.courierDepositPolicy = { _, _ in .favorite } + + let bobDelegate = NoiseCaptureDelegate() + bob.delegate = bobDelegate + + let aliceOut = PacketTap() + alice._test_onOutboundPacket = aliceOut.record + let carolOut = PacketTap() + carol._test_onOutboundPacket = carolOut.record + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + preseedConnectedPeer(carol, in: alice) + + // 1. While Bob is still around, Alice hears his verified announce + // (binding his signing key) and his gossiped prekey bundle. + let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) + alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false) + alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false) + + let cached = await TestHelpers.waitUntil( + { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, + timeout: TestConstants.defaultTimeout + ) + #expect(cached) + + // 2. Bob goes dark; Alice seals for him and deposits with Carol. + // The envelope must be v2: sealed to a one-time prekey. + #expect(alice.sendCourierMessage( + "burn after reading", + messageID: "prekey-msg-1", + recipientNoiseKey: bob.noiseStaticPublicKeyData(), + via: [carol.myPeerID] + )) + let deposited = await TestHelpers.waitUntil( + { aliceOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(deposited) + let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) + let sealedEnvelope = try #require(CourierEnvelope.decode(depositPacket.payload)) + #expect(sealedEnvelope.prekeyID != nil) + + // 3. Carol carries it (opaque, prekey or not). + carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) + let carried = await TestHelpers.waitUntil( + { !carol.courierStore.isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(carried) + + // 4. Bob resurfaces near Carol → handover, and the v2 discriminator + // survives the store round-trip. + bob.sendBroadcastAnnounce() + let reannounced = await TestHelpers.waitUntil( + { bobOut.first(ofType: .announce) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(reannounced) + let handoverTrigger = try #require(bobOut.first(ofType: .announce)) + carol._test_handlePacket(handoverTrigger, fromPeerID: bob.myPeerID, preseedPeer: false) + + let handedOver = await TestHelpers.waitUntil( + { carolOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(handedOver) + let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope)) + let handedEnvelope = try #require(CourierEnvelope.decode(handoverPacket.payload)) + #expect(handedEnvelope.prekeyID == sealedEnvelope.prekeyID) + + // 5. Bob opens it with the matching one-time prekey private and sees + // Alice as the authenticated sender. + bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID) + let received = await TestHelpers.waitUntil( + { !bobDelegate.snapshot().isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(received) + + let delivered = try #require(bobDelegate.snapshot().first) + #expect(delivered.type == .privateMessage) + #expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData())) + let message = try #require(PrivateMessagePacket.decode(from: delivered.payload)) + #expect(message.messageID == "prekey-msg-1") + #expect(message.content == "burn after reading") + + // 6. Redelivery tolerance: the same envelope arriving via another + // packet (spray-and-wait) still opens inside the grace window. + let redelivery = BitchatPacket( + type: MessageType.courierEnvelope.rawValue, + senderID: Data(hexString: carol.myPeerID.id) ?? Data(), + recipientID: handoverPacket.recipientID, + timestamp: handoverPacket.timestamp + 1, + payload: handoverPacket.payload, + signature: nil, + ttl: 1 + ) + bob._test_handlePacket(redelivery, fromPeerID: carol.myPeerID) + let redelivered = await TestHelpers.waitUntil( + { bobDelegate.snapshot().count == 2 }, + timeout: TestConstants.defaultTimeout + ) + #expect(redelivered) + } + + @Test func withoutBundleSealingFallsBackToStatic() async throws { + let alice = makeService() + let bob = makeService() + + let bobDelegate = NoiseCaptureDelegate() + bob.delegate = bobDelegate + let aliceOut = PacketTap() + alice._test_onOutboundPacket = aliceOut.record + + // Bob is a connected "courier" who happens to be the recipient: the + // envelope reaches him directly and the recipient tag matches. + preseedConnectedPeer(bob, in: alice) + + // Alice never saw a bundle for Bob → v1 static-sealed envelope. + #expect(alice.sendCourierMessage( + "plain static seal", + messageID: "static-msg-1", + recipientNoiseKey: bob.noiseStaticPublicKeyData(), + via: [bob.myPeerID] + )) + let deposited = await TestHelpers.waitUntil( + { aliceOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(deposited) + let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) + let envelope = try #require(CourierEnvelope.decode(depositPacket.payload)) + #expect(envelope.prekeyID == nil) + + // Bob opens the v1 envelope exactly as before the prekey change. + // (No preseed: Alice is absent from Bob's mesh, so the sender should + // resolve to her full noise-key ID like the courier case.) + bob._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, preseedPeer: false) + let received = await TestHelpers.waitUntil( + { !bobDelegate.snapshot().isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(received) + let delivered = try #require(bobDelegate.snapshot().first) + #expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData())) + let message = try #require(PrivateMessagePacket.decode(from: delivered.payload)) + #expect(message.content == "plain static seal") + } + + @Test func unverifiableBundleIsIgnored() async throws { + let alice = makeService() + let bob = makeService() + + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + // Alice receives Bob's bundle but never saw a verified announce, so + // no signing key is bound to his noise key: the bundle must not be + // cached or enter Alice's gossip store. + let (_, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) + alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false) + + let cached = await TestHelpers.waitUntil( + { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, + timeout: TestConstants.shortTimeout + ) + #expect(!cached) + } + + @Test func forgedBundleSignatureIsRejected() async throws { + let alice = makeService() + let bob = makeService() + + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) + alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false) + + // Mallory tampers with the gossiped bundle in flight. + let bundle = try #require(PrekeyBundle.decode(bundlePacket.payload)) + var forgedSignature = bundle.signature + forgedSignature[0] ^= 0x01 + let forged = PrekeyBundle( + noiseStaticPublicKey: bundle.noiseStaticPublicKey, + prekeys: bundle.prekeys, + generatedAt: bundle.generatedAt, + signature: forgedSignature + ) + let forgedPacket = BitchatPacket( + type: MessageType.prekeyBundle.rawValue, + senderID: bundlePacket.senderID, + recipientID: nil, + timestamp: bundlePacket.timestamp, + payload: try #require(forged.encode()), + signature: bundlePacket.signature, + ttl: bundlePacket.ttl + ) + alice._test_handlePacket(forgedPacket, fromPeerID: bob.myPeerID, preseedPeer: false) + + let cached = await TestHelpers.waitUntil( + { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, + timeout: TestConstants.shortTimeout + ) + #expect(!cached) + } + + @Test func verifiedBundleEntersGossipStore() async throws { + let alice = makeService() + let bob = makeService() + + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) + alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false) + alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false) + + let cached = await TestHelpers.waitUntil( + { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, + timeout: TestConstants.defaultTimeout + ) + #expect(cached) + // The verified bundle now participates in Alice's sync rounds. + #expect(alice._test_hasGossipPrekeyBundle(for: bob.myPeerID)) + } + + @Test func spoofedSenderPrekeyBundleIsRejected() async throws { + let alice = makeService() + let bob = makeService() + + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) + alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false) + + // A relay re-broadcasts Bob's genuine bundle under a fabricated sender + // ID (the DoS that would multiply cache/gossip entries and exhaust the + // per-owner cap). Attribution is by the bundle's own key and the outer + // signature is bound to Bob's sender ID, so the spoof is dropped — no + // cache entry, and no gossip entry under either the fake or real ID. + let fakeSender = Data((0..<8).map { _ in UInt8.random(in: 0...255) }) + let spoofed = BitchatPacket( + type: MessageType.prekeyBundle.rawValue, + senderID: fakeSender, + recipientID: nil, + timestamp: bundlePacket.timestamp + 5_000, + payload: bundlePacket.payload, + signature: bundlePacket.signature, + ttl: bundlePacket.ttl + ) + alice._test_handlePacket(spoofed, fromPeerID: PeerID(hexData: fakeSender), preseedPeer: false) + + let cached = await TestHelpers.waitUntil( + { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, + timeout: TestConstants.shortTimeout + ) + #expect(!cached) + #expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID)) + #expect(!alice._test_hasGossipPrekeyBundle(for: PeerID(hexData: fakeSender))) + } + + @Test func replayedPrekeyBundleWithFreshTimestampIsRejected() async throws { + let alice = makeService() + let bob = makeService() + + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) + alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false) + + // Rewriting the outer timestamp (to defeat the freshness window) + // invalidates the packet signature, which covers senderID + timestamp. + let replay = BitchatPacket( + type: MessageType.prekeyBundle.rawValue, + senderID: bundlePacket.senderID, + recipientID: nil, + timestamp: bundlePacket.timestamp + 5_000, + payload: bundlePacket.payload, + signature: bundlePacket.signature, + ttl: bundlePacket.ttl + ) + alice._test_handlePacket(replay, fromPeerID: bob.myPeerID, preseedPeer: false) + + let cached = await TestHelpers.waitUntil( + { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, + timeout: TestConstants.shortTimeout + ) + #expect(!cached) + #expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID)) + } +} diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift index 0e7b35eb..76ffdb77 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -139,6 +139,7 @@ struct GossipSyncManagerTests { config.messageSyncIntervalSeconds = 1 config.fragmentSyncIntervalSeconds = 1 config.fileTransferSyncIntervalSeconds = 1 + config.prekeyBundleSyncIntervalSeconds = 1 config.maintenanceIntervalSeconds = 0 let requestSyncManager = RequestSyncManager() @@ -195,19 +196,25 @@ struct GossipSyncManagerTests { manager._performMaintenanceSynchronously(now: Date()) // One request per due schedule so each type group gets the full - // filter capacity: publicMessages, fragment, and fileTransfer. + // filter capacity: publicMessages, fragment, fileTransfer, and + // prekeyBundle. let sentPackets = delegate.packets - #expect(sentPackets.count == 3) + #expect(sentPackets.count == 4) let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) } - #expect(decoded.count == 3) + #expect(decoded.count == 4) let allTypes = decoded.compactMap(\.types).reduce(SyncTypeFlags(rawValue: 0)) { $0.union($1) } #expect(allTypes.contains(.announce)) #expect(allTypes.contains(.message)) #expect(allTypes.contains(.fragment)) #expect(allTypes.contains(.fileTransfer)) - #expect(decoded.contains { $0.types == .publicMessages }) + #expect(allTypes.contains(.prekeyBundle)) + #expect(allTypes.contains(.groupMessage)) + // The message schedule also asks for group messages (bit 10); + // responders that don't know the bit just ignore it. + #expect(decoded.contains { $0.types == SyncTypeFlags.publicMessages.union(.groupMessage) }) #expect(decoded.contains { $0.types == .fragment }) #expect(decoded.contains { $0.types == .fileTransfer }) + #expect(decoded.contains { $0.types == .prekeyBundle }) } @Test func truncatedFilterCarriesSinceCursor() throws { @@ -292,6 +299,7 @@ struct GossipSyncManagerTests { config.messageSyncIntervalSeconds = 0 config.fragmentSyncIntervalSeconds = 0 config.fileTransferSyncIntervalSeconds = 0 + config.prekeyBundleSyncIntervalSeconds = 0 let requestSyncManager = RequestSyncManager() let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) @@ -362,6 +370,8 @@ struct GossipSyncManagerTests { config.messageSyncIntervalSeconds = 0 config.fragmentSyncIntervalSeconds = 0 config.fileTransferSyncIntervalSeconds = 0 + // Silence the prekey round so the maintenance barrier below emits nothing. + config.prekeyBundleSyncIntervalSeconds = 0 let requestSyncManager = RequestSyncManager() let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) @@ -401,6 +411,7 @@ struct GossipSyncManagerTests { config.messageSyncIntervalSeconds = 0 config.fragmentSyncIntervalSeconds = 0 config.fileTransferSyncIntervalSeconds = 0 + config.prekeyBundleSyncIntervalSeconds = 0 config.responseRateLimitMaxResponses = 1 config.responseRateLimitWindowSeconds = 60 @@ -455,6 +466,7 @@ struct GossipSyncManagerTests { #expect(types.contains(.message)) #expect(types.contains(.fragment)) #expect(types.contains(.fileTransfer)) + #expect(types.contains(.prekeyBundle)) } @Test func handleRequestSyncHonorsTypeFilter() async throws { @@ -507,6 +519,187 @@ struct GossipSyncManagerTests { #expect(sentPackets[0].type == MessageType.fragment.rawValue) } + // MARK: - Fragment-ID filter (targeted resync) + + private func makeFragmentPacket(sender: Data, fragmentID: Data, index: UInt16, timestamp: UInt64) -> BitchatPacket { + // Fragment payload: 8-byte stream ID + index + total + original type. + var payload = fragmentID + payload.append(contentsOf: withUnsafeBytes(of: index.bigEndian) { Data($0) }) + payload.append(contentsOf: withUnsafeBytes(of: UInt16(4).bigEndian) { Data($0) }) + payload.append(MessageType.fileTransfer.rawValue) + payload.append(Data([0xEE])) + return BitchatPacket( + type: MessageType.fragment.rawValue, + senderID: sender, + recipientID: nil, + timestamp: timestamp, + payload: payload, + signature: nil, + ttl: 1 + ) + } + + @Test func handleRequestSyncHonorsFragmentIdFilter() async throws { + var config = GossipSyncManager.Config() + config.fragmentCapacity = 10 + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + // Silence the prekey round so the maintenance barrier isolates fragments. + config.prekeyBundleSyncIntervalSeconds = 0 + + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + manager.delegate = delegate + + let sender = try #require(Data(hexString: "aabbccddeeff0011")) + let wantedID = try #require(Data(hexString: "0102030405060708")) + let otherID = try #require(Data(hexString: "1112131415161718")) + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + + let wanted = makeFragmentPacket(sender: sender, fragmentID: wantedID, index: 1, timestamp: nowMs - 60_000) + let other = makeFragmentPacket(sender: sender, fragmentID: otherID, index: 2, timestamp: nowMs) + manager.onPublicPacketSeen(wanted) + manager.onPublicPacketSeen(other) + + // The since-cursor sits after both fragments; without the filter the + // responder would send nothing for `wanted`. The filter both bypasses + // the cursor and restricts the diff to exactly the named stream. + let request = RequestSyncPacket( + p: 7, + m: 1, + data: Data(), + types: .fragment, + sinceTimestamp: nowMs + 1, + fragmentIdFilter: RequestSyncPacket.encodeFragmentIdFilter([wantedID]) + ) + manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) + + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + // Barrier: flush the sync queue so a late second packet would be visible. + manager._performMaintenanceSynchronously(now: Date()) + let sentPackets = delegate.packets + #expect(sentPackets.count == 1) + let sent = try #require(sentPackets.first) + #expect(sent.type == MessageType.fragment.rawValue) + #expect(sent.payload.prefix(8) == wantedID) + #expect(sent.ttl == 0) + #expect(sent.isRSR) + } + + @Test func prekeyBundlesServeSyncAndSurviveStalePeerCleanup() async throws { + var config = GossipSyncManager.Config() + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + config.prekeyBundleSyncIntervalSeconds = 0 + config.stalePeerCleanupIntervalSeconds = 0 + config.stalePeerTimeoutSeconds = 5 + + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager()) + let delegate = RecordingDelegate() + manager.delegate = delegate + + // Bundles are keyed by their authenticated identity (the noise static + // key), not the packet senderID, so the payload must be a real bundle. + let noiseKey = Data(repeating: 0xAB, count: 32) + let senderPeer = PeerID(publicKey: noiseKey) + let sender = try #require(Data(hexString: senderPeer.id)) + let bundle = PrekeyBundle( + noiseStaticPublicKey: noiseKey, + prekeys: [PrekeyBundle.Prekey(id: 0, publicKey: Data(repeating: 0x11, count: 32))], + generatedAt: UInt64(Date().timeIntervalSince1970 * 1000), + signature: Data(count: PrekeyBundle.signatureLength) + ) + let bundlePacket = BitchatPacket( + type: MessageType.prekeyBundle.rawValue, + senderID: sender, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: try #require(bundle.encode()), + signature: nil, + ttl: 1 + ) + manager.onPublicPacketSeen(bundlePacket) + manager._performMaintenanceSynchronously(now: Date()) + #expect(manager._hasPrekeyBundle(for: senderPeer)) + + // Bundles outlive the owner's announce: a leave plus stale cleanup + // must not drop them (they exist to reach offline owners). + manager.removeAnnouncementForPeer(senderPeer) + manager._performMaintenanceSynchronously(now: Date().addingTimeInterval(config.stalePeerTimeoutSeconds + 1)) + #expect(manager._hasPrekeyBundle(for: senderPeer)) + + // And a .prekeyBundle sync request is answered with the stored packet. + let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .prekeyBundle) + manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + let served = try #require(delegate.packets.first) + #expect(served.type == MessageType.prekeyBundle.rawValue) + #expect(served.isRSR) + } + + @Test func prekeyBundleGossipIsKeyedByOwnerNotSenderID() { + // One valid bundle re-broadcast under many fabricated sender IDs must + // collapse to a single entry keyed by the bundle's own identity — the + // spray-to-exhaust-the-cap DoS produces one entry, not N. + let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: RequestSyncManager()) + let noiseKey = Data(repeating: 0xCD, count: 32) + let ownerPeer = PeerID(publicKey: noiseKey) + let bundle = PrekeyBundle( + noiseStaticPublicKey: noiseKey, + prekeys: [PrekeyBundle.Prekey(id: 0, publicKey: Data(repeating: 0x22, count: 32))], + generatedAt: UInt64(Date().timeIntervalSince1970 * 1000), + signature: Data(count: PrekeyBundle.signatureLength) + ) + guard let payload = bundle.encode() else { return } + + for i in 0..<5 { + let fakeSender = Data((0..<8).map { j in UInt8(truncatingIfNeeded: i * 31 + j) }) + let packet = BitchatPacket( + type: MessageType.prekeyBundle.rawValue, + senderID: fakeSender, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000) + UInt64(i), + payload: payload, + signature: nil, + ttl: 1 + ) + manager.onPublicPacketSeen(packet) + manager._performMaintenanceSynchronously(now: Date()) + // No fabricated sender ID ever creates its own entry. + #expect(!manager._hasPrekeyBundle(for: PeerID(hexData: fakeSender))) + } + // Exactly the owner-keyed entry exists. + #expect(manager._hasPrekeyBundle(for: ownerPeer)) + } + + @Test func requestMissingFragmentsSendsFilteredRequestToConnectedPeers() async throws { + var config = GossipSyncManager.Config() + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + delegate.connectedPeers = [PeerID(str: "FFFFFFFFFFFFFFFF")] + manager.delegate = delegate + + let stalledID = try #require(Data(hexString: "0102030405060708")) + manager.requestMissingFragments(fragmentIDs: [stalledID]) + + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + let sent = try #require(delegate.packets.first) + #expect(sent.type == MessageType.requestSync.rawValue) + #expect(sent.ttl == 0) + let request = try #require(RequestSyncPacket.decode(from: sent.payload)) + #expect(request.types == .fragment) + let ids = try #require(RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter)) + #expect(ids == Set([stalledID])) + } + // MARK: - Archive persistence @Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws { @@ -583,6 +776,7 @@ struct GossipSyncManagerTests { private final class RecordingDelegate: GossipSyncManager.Delegate { var onSend: (() -> Void)? + var connectedPeers: [PeerID] = [] private(set) var lastPacket: BitchatPacket? private(set) var packets: [BitchatPacket] = [] private let lock = NSLock() @@ -604,6 +798,6 @@ private final class RecordingDelegate: GossipSyncManager.Delegate { } func getConnectedPeers() -> [PeerID] { - return [] + return connectedPeers } } diff --git a/bitchatTests/MessageRateLimiterTests.swift b/bitchatTests/MessageRateLimiterTests.swift new file mode 100644 index 00000000..c9959761 --- /dev/null +++ b/bitchatTests/MessageRateLimiterTests.swift @@ -0,0 +1,119 @@ +// +// MessageRateLimiterTests.swift +// bitchatTests +// +// Tests for the public-intake token buckets, including the NIP-13 +// proof-of-work relaxation of the per-sender bucket. +// + +import Foundation +import Testing +@testable import bitchat + +struct MessageRateLimiterTests { + + private func makeLimiter( + senderCapacity: Double = 2, + contentCapacity: Double = 100 + ) -> MessageRateLimiter { + MessageRateLimiter( + senderCapacity: senderCapacity, + senderRefillPerSec: 0.0001, + contentCapacity: contentCapacity, + contentRefillPerSec: 0.0001 + ) + } + + @Test func senderBucketBlocksAfterCapacity() { + var limiter = makeLimiter() + let now = Date() + + let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now) + let second = limiter.allow(senderKey: "s", contentKey: "c2", now: now) + let third = limiter.allow(senderKey: "s", contentKey: "c3", now: now) + let otherSender = limiter.allow(senderKey: "other", contentKey: "c4", now: now) + + #expect(first) + #expect(second) + #expect(!third) + #expect(otherSender) + } + + @Test func validPoWBypassesExhaustedSenderBucket() { + var limiter = makeLimiter() + let now = Date() + + // Exhaust the sender bucket with plain (no-PoW) messages. + let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now) + let second = limiter.allow(senderKey: "s", contentKey: "c2", now: now) + let exhausted = limiter.allow(senderKey: "s", contentKey: "c3", now: now) + + // A message carrying sufficient validated PoW still passes, and so + // does more-than-sufficient PoW; plain messages stay blocked. + let powExact = limiter.allow( + senderKey: "s", + contentKey: "c4", + powBits: NostrPoW.rateLimitBypassBits, + now: now + ) + let powHigh = limiter.allow(senderKey: "s", contentKey: "c5", powBits: 20, now: now) + let plainAgain = limiter.allow(senderKey: "s", contentKey: "c6", now: now) + + #expect(first) + #expect(second) + #expect(!exhausted) + #expect(powExact) + #expect(powHigh) + #expect(!plainAgain) + } + + @Test func lowPoWDoesNotBypassSenderBucket() { + var limiter = makeLimiter(senderCapacity: 1) + let now = Date() + + let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now) + let lowPow = limiter.allow( + senderKey: "s", + contentKey: "c2", + powBits: NostrPoW.rateLimitBypassBits - 1, + now: now + ) + let zeroPow = limiter.allow(senderKey: "s", contentKey: "c3", powBits: 0, now: now) + + #expect(first) + #expect(!lowPow) + #expect(!zeroPow) + } + + @Test func powDoesNotBypassContentFloodBucket() { + var limiter = makeLimiter(senderCapacity: 100, contentCapacity: 1) + let now = Date() + + let first = limiter.allow(senderKey: "a", contentKey: "same", now: now) + // Identical content spammed with PoW is still throttled by the + // content bucket: PoW only relaxes the per-sender limit. + let powSameContent = limiter.allow(senderKey: "b", contentKey: "same", powBits: 20, now: now) + let powNewContent = limiter.allow(senderKey: "b", contentKey: "different", powBits: 20, now: now) + + #expect(first) + #expect(!powSameContent) + #expect(powNewContent) + } + + @Test func powBypassDoesNotDrainSenderBucket() { + var limiter = makeLimiter(senderCapacity: 1) + let now = Date() + + // PoW messages don't consume sender tokens, so a subsequent plain + // message still has its full budget. + let powFirst = limiter.allow(senderKey: "s", contentKey: "c1", powBits: 20, now: now) + let powSecond = limiter.allow(senderKey: "s", contentKey: "c2", powBits: 20, now: now) + let plain = limiter.allow(senderKey: "s", contentKey: "c3", now: now) + let plainExhausted = limiter.allow(senderKey: "s", contentKey: "c4", now: now) + + #expect(powFirst) + #expect(powSecond) + #expect(plain) + #expect(!plainExhausted) + } +} diff --git a/bitchatTests/Mocks/MockIdentityManager.swift b/bitchatTests/Mocks/MockIdentityManager.swift index 633c388b..1ff1926a 100644 --- a/bitchatTests/Mocks/MockIdentityManager.swift +++ b/bitchatTests/Mocks/MockIdentityManager.swift @@ -107,12 +107,55 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { func removeEphemeralSession(peerID: PeerID) {} func setVerified(fingerprint: String, verified: Bool) {} - + func isVerified(fingerprint: String) -> Bool { true } - + func getVerifiedFingerprints() -> Set { Set() } + + // MARK: Vouching (transitive verification) + + private var vouchesByVouchee: [String: [VouchRecord]] = [:] + private var vouchBatchSentAt: [String: Date] = [:] + + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool { + guard voucheeFingerprint != voucherFingerprint else { return false } + var records = vouchesByVouchee[voucheeFingerprint] ?? [] + records.removeAll { $0.voucherFingerprint == voucherFingerprint } + records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp)) + vouchesByVouchee[voucheeFingerprint] = records + return true + } + + func validVouchers(for fingerprint: String) -> [VouchRecord] { + vouchesByVouchee[fingerprint] ?? [] + } + + func isVouched(fingerprint: String) -> Bool { + !(vouchesByVouchee[fingerprint] ?? []).isEmpty + } + + func effectiveTrustLevel(for fingerprint: String) -> TrustLevel { + socialIdentities[fingerprint]?.trustLevel ?? .unknown + } + + func lastVouchBatchSent(to fingerprint: String) -> Date? { + vouchBatchSentAt[fingerprint] + } + + func markVouchBatchSent(to fingerprint: String, at date: Date) { + vouchBatchSentAt[fingerprint] = date + } + + func signingPublicKey(forFingerprint fingerprint: String) -> Data? { + nil + } + + func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] { + [] + } } diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index e4cf08de..5f1243e8 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -196,6 +196,27 @@ final class MockTransport: Transport { return courierSendResult } + // MARK: - Mesh Diagnostics + + private(set) var sentMeshPings: [PeerID] = [] + var meshPingResult: MeshPingResult? + var meshPaths: [PeerID: [PeerID]] = [:] + var meshTopologySnapshot: MeshTopologySnapshot? + + func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) { + sentMeshPings.append(peerID) + let result = meshPingResult + Task { @MainActor in completion(result) } + } + + func computeMeshPath(to peerID: PeerID) -> [PeerID]? { + meshPaths[peerID] + } + + func currentMeshTopology() -> MeshTopologySnapshot? { + meshTopologySnapshot + } + // MARK: - Test Helpers /// Clears all recorded method calls for fresh assertions diff --git a/bitchatTests/Nostr/NostrPoWTests.swift b/bitchatTests/Nostr/NostrPoWTests.swift new file mode 100644 index 00000000..1905e0f0 --- /dev/null +++ b/bitchatTests/Nostr/NostrPoWTests.swift @@ -0,0 +1,222 @@ +// +// NostrPoWTests.swift +// bitchatTests +// +// Tests for NIP-13 proof-of-work: leading-zero-bit counting, commitment +// semantics, and nonce-tag mining for geohash (kind 20000) events. +// + +import CryptoKit +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +struct NostrPoWTests { + + // MARK: - Leading zero bits + + @Test func leadingZeroBitsVectors() { + #expect(NostrPoW.leadingZeroBits(Data()) == 0) + #expect(NostrPoW.leadingZeroBits(Data([0x80])) == 0) + #expect(NostrPoW.leadingZeroBits(Data([0xFF, 0x00])) == 0) + #expect(NostrPoW.leadingZeroBits(Data([0x40])) == 1) + #expect(NostrPoW.leadingZeroBits(Data([0x01])) == 7) + #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x00, 0xF0])) == 16) + #expect(NostrPoW.leadingZeroBits(Data(repeating: 0x00, count: 32)) == 256) + } + + @Test func leadingZeroBitsExactByteBoundaries() { + // Zero byte contributes exactly 8, then the next byte decides. + #expect(NostrPoW.leadingZeroBits(Data([0x00, 0xFF])) == 8) + #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x80])) == 8) + #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x7F])) == 9) + #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x01])) == 15) + #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x00, 0x01])) == 23) + } + + @Test func leadingZeroBitsMatchesNIP13ExampleVector() throws { + // Worked example from the NIP-13 spec: this event ID has 36 leading + // zero bits. + let idHex = "000000000e9d97a1ab09fc381030b346cdd7a142ad57e6df0b46dc9bef6c7e2d" + let idData = try #require(Data(hexString: idHex)) + #expect(NostrPoW.leadingZeroBits(idData) == 36) + } + + // MARK: - Commitment semantics + + /// An ID with exactly 16 leading zero bits. + private let id16 = "0000f000" + String(repeating: "ab", count: 28) + + @Test func committedTargetCountsNotActualDifficulty() { + // Claimed < actual: only the committed target is credited, so lucky + // extra zeroes earn nothing beyond the commitment. + let tags = [["g", "u4pruy"], ["nonce", "12345", "8"]] + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 8) + } + + @Test func unmetCommitmentScoresZero() { + // Actual < claimed: the commitment is not met, so the claim is void. + let tags = [["nonce", "12345", "24"]] + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 0) + } + + @Test func exactCommitmentIsCredited() { + let tags = [["nonce", "12345", "16"]] + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 16) + } + + @Test func missingOrMalformedNonceTagScoresZero() { + // No nonce tag at all: leading zeroes without a commitment earn no + // credit (old clients simply keep the strict rate limits). + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["g", "u4pruy"]]) == 0) + // Nonce tag without a committed target. + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "12345"]]) == 0) + // Non-numeric or nonsensical targets. + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "high"]]) == 0) + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "0"]]) == 0) + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "-4"]]) == 0) + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "400"]]) == 0) + // Malformed event ID. + #expect(NostrPoW.validatedDifficulty(idHex: "not-hex", tags: [["nonce", "1", "8"]]) == 0) + } + + // MARK: - Mining + + @Test func minedNonceTagMeetsCommittedDifficulty() async throws { + let pubkey = String(repeating: "a", count: 64) + let createdAt = 1_700_000_000 + let baseTags = [["g", "u4pruydq"], ["n", "tester"]] + let content = "hello pow" + + let nonceTag = try #require(await NostrPoW.mineNonceTag( + pubkey: pubkey, + createdAt: createdAt, + kind: 20000, + tags: baseTags, + content: content, + targetBits: 8 + )) + + #expect(nonceTag.count == 3) + #expect(nonceTag.first == "nonce") + #expect(nonceTag[2] == "8") + + // Recompute the canonical NIP-01 event ID with the mined tag appended + // and verify the committed difficulty is genuinely met. + let idData = try Self.eventIDHash( + pubkey: pubkey, + createdAt: createdAt, + kind: 20000, + tags: baseTags + [nonceTag], + content: content + ) + #expect(NostrPoW.leadingZeroBits(idData) >= 8) + let idHex = idData.map { String(format: "%02x", $0) }.joined() + #expect(NostrPoW.validatedDifficulty(idHex: idHex, tags: baseTags + [nonceTag]) == 8) + } + + @Test func miningSurvivesContentThatNeedsEscaping() async throws { + // The in-place template mutation must stay correct when the content + // gets JSON-escaped — including content that contains hex runs that + // look exactly like the internal nonce placeholder. + let pubkey = String(repeating: "b", count: 64) + let createdAt = 1_700_000_123 + let content = "she said \"hi\"\n0000000000000000 / ffffffffffffffff 😀\\" + let baseTags = [["g", "9q8yy"]] + + let nonceTag = try #require(await NostrPoW.mineNonceTag( + pubkey: pubkey, + createdAt: createdAt, + kind: 20000, + tags: baseTags, + content: content, + targetBits: 4 + )) + + let idData = try Self.eventIDHash( + pubkey: pubkey, + createdAt: createdAt, + kind: 20000, + tags: baseTags + [nonceTag], + content: content + ) + #expect(NostrPoW.leadingZeroBits(idData) >= 4) + } + + @Test func minedGeohashEventValidatesEndToEnd() async throws { + let identity = try NostrIdentity.generate() + let event = try await NostrProtocol.createMinedEphemeralGeohashEvent( + content: "hello from a mined event", + geohash: "u4pruydq", + senderIdentity: identity, + nickname: "miner", + teleported: false + ) + + // The signed event's own ID (recomputed by sign()) carries the work. + #expect(event.isValidSignature()) + let idData = try #require(Data(hexString: event.id)) + #expect(NostrPoW.leadingZeroBits(idData) >= NostrPoW.targetBits) + #expect(NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags) == NostrPoW.targetBits) + + // Mining must not disturb the regular geohash tags. + #expect(event.tags.contains(["g", "u4pruydq"])) + #expect(event.tags.contains(["n", "miner"])) + #expect(event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue) + } + + @Test func cancelledMiningStillProducesHonestCommitment() async throws { + // Cancelling the surrounding task expedites mining: it steps the + // committed target down and still returns a tag whose commitment the + // hash actually meets — the message is never dropped or dishonest. + let pubkey = String(repeating: "c", count: 64) + let createdAt = 1_700_000_456 + let baseTags = [["g", "gbsuv"]] + let content = "expedited" + + let miningTask = Task { + await NostrPoW.mineNonceTag( + pubkey: pubkey, + createdAt: createdAt, + kind: 20000, + tags: baseTags, + content: content, + targetBits: 240 // unreachable: forces the cap/cancel path + ) + } + miningTask.cancel() + + let nonceTag = try #require(await miningTask.value) + let committed = try #require(Int(nonceTag[2])) + #expect(committed >= 0) + #expect(committed < 240) + + if committed > 0 { + let idData = try Self.eventIDHash( + pubkey: pubkey, + createdAt: createdAt, + kind: 20000, + tags: baseTags + [nonceTag], + content: content + ) + #expect(NostrPoW.leadingZeroBits(idData) >= committed) + } + } + + // MARK: - Helpers + + /// Canonical NIP-01 event ID hash, computed independently of the + /// production code path. + private static func eventIDHash( + pubkey: String, + createdAt: Int, + kind: Int, + tags: [[String]], + content: String + ) throws -> Data { + let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content] + let json = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes]) + return Data(SHA256.hash(data: json)) + } +} diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index 39ed95bf..d496529c 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -611,6 +611,7 @@ private final class PerfNostrContext: ChatNostrContext { private(set) var handledPublicMessageCount = 0 func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessageCount += 1 } + func handlePublicMessage(_ message: BitchatMessage, powBits: Int) { handledPublicMessageCount += 1 } func checkForMentions(_ message: BitchatMessage) {} func sendHapticFeedback(for message: BitchatMessage) {} func parseMentions(from content: String) -> [String] { diff --git a/bitchatTests/Prekeys/NoisePrekeyTests.swift b/bitchatTests/Prekeys/NoisePrekeyTests.swift new file mode 100644 index 00000000..44a39d11 --- /dev/null +++ b/bitchatTests/Prekeys/NoisePrekeyTests.swift @@ -0,0 +1,283 @@ +// +// NoisePrekeyTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import CryptoKit +import BitFoundation +@testable import bitchat + +/// Forward-secret one-way Noise X envelopes sealed to one-time prekeys +/// instead of the recipient's identity static key. +struct NoisePrekeyTests { + + @Test func sealAndOpenRoundTrip() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + let prekey = try #require(bundle.prekeys.first) + + let payload = Data("meet at the north gate".utf8) + let sealed = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey) + + let opened = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id) + #expect(opened.payload == payload) + // The X pattern authenticates the sender: Bob learns Alice's real static key. + #expect(opened.senderStaticKey == alice.getStaticPublicKeyData()) + } + + @Test func wrongPrekeyIDCannotOpen() throws { + // The prologue binds the ciphertext to a specific prekey ID; opening + // with a different (existing) prekey must fail. + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + #expect(bundle.prekeys.count >= 2) + + let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: bundle.prekeys[0]) + #expect(throws: (any Error).self) { + _ = try bob.openPrekeyPayload(sealed, prekeyID: bundle.prekeys[1].id) + } + } + + @Test func unknownPrekeyIDThrows() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + let prekey = try #require(bundle.prekeys.first) + + let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey) + #expect(throws: NoiseEncryptionError.unknownPrekey) { + _ = try bob.openPrekeyPayload(sealed, prekeyID: 0xDEAD_BEEF) + } + } + + @Test func wrongRecipientCannotOpen() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let carol = NoiseEncryptionService(keychain: MockKeychain()) + let bobBundle = try #require(bob.currentPrekeyBundle()) + // Ensure Carol holds a prekey under the same ID as Bob's. + _ = try #require(carol.currentPrekeyBundle()) + let prekey = try #require(bobBundle.prekeys.first) + + let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey) + #expect(throws: (any Error).self) { + _ = try carol.openPrekeyPayload(sealed, prekeyID: prekey.id) + } + } + + @Test func tamperedCiphertextFailsToOpen() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + let prekey = try #require(bundle.prekeys.first) + + var sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey) + sealed[sealed.count - 1] ^= 0x01 + #expect(throws: (any Error).self) { + _ = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id) + } + } + + @Test func consumedPrekeyStillOpensRedeliveredCiphertext() throws { + // Spray-and-wait can deliver the same ciphertext via several couriers + // days apart; the consumed private survives a grace window for that. + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + let prekey = try #require(bundle.prekeys.first) + + let sealed = try alice.sealPrekeyPayload(Data("hello".utf8), recipientPrekey: prekey) + let first = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id) + let second = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id) + #expect(first.payload == second.payload) + } + + @Test func prekeyAndStaticSealsAreNotInterchangeable() throws { + // Domain-separated prologues: a static-sealed envelope must not open + // via the prekey path and vice versa, even with matching key material. + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + let prekey = try #require(bundle.prekeys.first) + + let staticSealed = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: bob.getStaticPublicKeyData()) + #expect(throws: (any Error).self) { + _ = try bob.openPrekeyPayload(staticSealed, prekeyID: prekey.id) + } + + let prekeySealed = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: prekey) + #expect(throws: (any Error).self) { + _ = try bob.openCourierPayload(prekeySealed) + } + } + + @Test func sealRejectsInvalidPrekeyPublicKey() { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + #expect(throws: (any Error).self) { + _ = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: PrekeyBundle.Prekey(id: 1, publicKey: Data(repeating: 0, count: 32))) + } + #expect(throws: (any Error).self) { + _ = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: PrekeyBundle.Prekey(id: 1, publicKey: Data(repeating: 1, count: 8))) + } + } + + @Test func sealsAreNotLinkableAcrossSends() throws { + // Fresh ephemeral per seal even to the same prekey. + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + let prekey = try #require(bundle.prekeys.first) + let payload = Data("same message".utf8) + + let a = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey) + let b = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey) + #expect(a != b) + #expect(a.prefix(32) != b.prefix(32)) + } +} + +/// Local one-time prekey lifecycle: batch generation, consumption, the 48h +/// redelivery grace window, replenishment, and the panic wipe. +struct LocalPrekeyStoreTests { + + private final class Clock { + var now: Date + init(_ now: Date = Date()) { self.now = now } + } + + private func makeStore(clock: Clock, keychain: MockKeychain = MockKeychain()) -> LocalPrekeyStore { + LocalPrekeyStore(keychain: keychain, now: { clock.now }) + } + + private func bundle(noiseKey: Data, prekeys: [PrekeyBundle.Prekey], generatedAt: UInt64) -> PrekeyBundle { + PrekeyBundle( + noiseStaticPublicKey: noiseKey, + prekeys: prekeys, + generatedAt: generatedAt, + signature: Data(count: PrekeyBundle.signatureLength) + ) + } + + @Test func mintsFullBatchOnFirstUse() { + let store = makeStore(clock: Clock()) + let (prekeys, generatedAt) = store.currentBundlePrekeys() + #expect(prekeys.count == LocalPrekeyStore.Policy.batchSize) + #expect(generatedAt > 0) + #expect(Set(prekeys.map(\.id)).count == prekeys.count) + } + + @Test func consumptionBelowThresholdTriggersReplenishAndBumpsGeneration() { + let clock = Clock() + let store = makeStore(clock: clock) + let (initial, firstGeneratedAt) = store.currentBundlePrekeys() + + // Consuming down to the threshold does not regenerate... + let keepUnconsumed = LocalPrekeyStore.Policy.replenishThreshold + for prekey in initial.dropLast(keepUnconsumed) { + store.markConsumed(prekey.id) + } + #expect(!store.replenishIfNeeded()) + #expect(store.unconsumedCount == keepUnconsumed) + + // ...one more consumption does, topping back up to a full batch with + // a newer generation stamp. + clock.now = clock.now.addingTimeInterval(60) + store.markConsumed(initial[initial.count - keepUnconsumed].id) + #expect(store.replenishIfNeeded()) + let (replenished, secondGeneratedAt) = store.currentBundlePrekeys() + #expect(replenished.count == LocalPrekeyStore.Policy.batchSize) + #expect(secondGeneratedAt > firstGeneratedAt) + // Surviving unconsumed prekeys stay in the fresh bundle. + let survivorIDs = Set(initial.suffix(keepUnconsumed - 1).map(\.id)) + #expect(survivorIDs.isSubset(of: Set(replenished.map(\.id)))) + } + + @Test func consumingAPrekeyRepublishesANewerBundlePeersAccept() { + // Codex P1: consuming a prekey (even above the replenish threshold) + // must republish a strictly newer bundle so a peer that cached the old + // one replaces it and stops assigning the consumed ID before its 48h + // grace lapses. + let clock = Clock() + let store = makeStore(clock: clock) + let noiseKey = Data(repeating: 0xC0, count: 32) + + // Owner publishes; a peer caches it and would assign the first prekey. + let (initial, firstGeneratedAt) = store.currentBundlePrekeys() + let peerCache = PrekeyBundleStore(persistsToDisk: false) + #expect(peerCache.ingest(bundle(noiseKey: noiseKey, prekeys: initial, generatedAt: firstGeneratedAt))) + let consumedID = initial[0].id + #expect(peerCache.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == consumedID) + + // The owner opens mail sealed to that prekey: it's retired and the + // republished bundle is strictly newer and no longer offers the ID. + #expect(store.markConsumed(consumedID)) + let (afterConsume, secondGeneratedAt) = store.currentBundlePrekeys() + #expect(secondGeneratedAt > firstGeneratedAt) + #expect(!afterConsume.contains { $0.id == consumedID }) + + // The peer accepts the replacement (a same-generatedAt copy would be + // rejected) and stops assigning the consumed ID for new mail. + #expect(peerCache.ingest(bundle(noiseKey: noiseKey, prekeys: afterConsume, generatedAt: secondGeneratedAt))) + #expect(peerCache.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id != consumedID) + + // 48h grace: the owner can still open a redelivery of the in-flight + // ciphertext sealed to the consumed ID until the window lapses. + clock.now = clock.now.addingTimeInterval(LocalPrekeyStore.Policy.consumedGraceSeconds - 60) + #expect(store.privateKey(for: consumedID) != nil) + clock.now = clock.now.addingTimeInterval(120) + #expect(store.privateKey(for: consumedID) == nil) + } + + @Test func consumedPrivateSurvivesGraceWindowThenDies() { + let clock = Clock() + let store = makeStore(clock: clock) + let (prekeys, _) = store.currentBundlePrekeys() + let id = prekeys[0].id + + store.markConsumed(id) + // Within the grace window: still retrievable for redeliveries. + clock.now = clock.now.addingTimeInterval(LocalPrekeyStore.Policy.consumedGraceSeconds - 60) + #expect(store.privateKey(for: id) != nil) + + // Past the grace window: gone (even before replenish prunes it). + clock.now = clock.now.addingTimeInterval(120) + #expect(store.privateKey(for: id) == nil) + store.replenishIfNeeded() + #expect(store.privateKey(for: id) == nil) + } + + @Test func persistsAcrossInstances() { + let keychain = MockKeychain() + let clock = Clock() + let first = LocalPrekeyStore(keychain: keychain, now: { clock.now }) + let (prekeys, generatedAt) = first.currentBundlePrekeys() + first.markConsumed(prekeys[0].id) + + let second = LocalPrekeyStore(keychain: keychain, now: { clock.now }) + let (reloaded, reloadedGeneratedAt) = second.currentBundlePrekeys() + // Consuming a prekey shrinks the published bundle, so its generation + // stamp advances strictly (even without the clock moving) — peers must + // see a newer bundle to replace the one that still offered the + // consumed ID. + #expect(reloadedGeneratedAt > generatedAt) + #expect(Set(reloaded.map(\.id)) == Set(prekeys.dropFirst().map(\.id))) + // The consumed key is still openable within grace after a relaunch. + #expect(second.privateKey(for: prekeys[0].id) != nil) + } + + @Test func wipeRemovesEverything() { + let keychain = MockKeychain() + let store = LocalPrekeyStore(keychain: keychain) + let (prekeys, _) = store.currentBundlePrekeys() + store.wipe() + #expect(store.privateKey(for: prekeys[0].id) == nil) + #expect(keychain.getIdentityKey(forKey: "prekeysV1") == nil) + } +} diff --git a/bitchatTests/Prekeys/PrekeyBundleStoreTests.swift b/bitchatTests/Prekeys/PrekeyBundleStoreTests.swift new file mode 100644 index 00000000..319757f0 --- /dev/null +++ b/bitchatTests/Prekeys/PrekeyBundleStoreTests.swift @@ -0,0 +1,197 @@ +// +// PrekeyBundleStoreTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import CryptoKit +import BitFoundation +@testable import bitchat + +/// Sender-side cache of peers' verified prekey bundles: latest-wins ingest, +/// per-message prekey assignment (never reused across messages), expiry, and +/// the peer cap. +struct PrekeyBundleStoreTests { + + private func makeBundle( + noiseKey: Data = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation, + ids: [UInt32] = [0, 1, 2], + generatedAt: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000) + ) -> PrekeyBundle { + PrekeyBundle( + noiseStaticPublicKey: noiseKey, + prekeys: ids.map { PrekeyBundle.Prekey(id: $0, publicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation) }, + generatedAt: generatedAt, + signature: Data(count: PrekeyBundle.signatureLength) + ) + } + + @Test func ingestKeepsLatestByGeneratedAt() { + let store = PrekeyBundleStore(persistsToDisk: false) + let noiseKey = Data(repeating: 0xB0, count: 32) + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + + let old = makeBundle(noiseKey: noiseKey, ids: [0, 1], generatedAt: nowMs - 1000) + let new = makeBundle(noiseKey: noiseKey, ids: [2, 3], generatedAt: nowMs) + + #expect(store.ingest(new)) + // Older (and equal) bundles never displace a newer one. + #expect(!store.ingest(old)) + #expect(!store.ingest(new)) + + let assigned = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) + #expect(assigned?.id == 2) + } + + @Test func assignmentsConsumeDistinctPrekeysPerMessage() { + let store = PrekeyBundleStore(persistsToDisk: false) + let noiseKey = Data(repeating: 0xB1, count: 32) + #expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [10, 11]))) + + let first = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) + let second = store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey) + #expect(first?.id == 10) + #expect(second?.id == 11) + // Exhausted: fall back to static sealing. + #expect(store.assignPrekey(messageID: "m3", recipientNoiseKey: noiseKey) == nil) + #expect(!store.hasUsableBundle(for: noiseKey)) + } + + @Test func redepositOfSameMessageReusesItsPrekey() { + let store = PrekeyBundleStore(persistsToDisk: false) + let noiseKey = Data(repeating: 0xB2, count: 32) + #expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [5, 6, 7]))) + + let first = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) + let retry = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) + #expect(first?.id == retry?.id) + #expect(first?.publicKey == retry?.publicKey) + // Only one prekey was burned. + let next = store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey) + #expect(next?.id == 6) + } + + @Test func topUpBundleKeepsConsumptionStateForSurvivingIDs() { + let store = PrekeyBundleStore(persistsToDisk: false) + let noiseKey = Data(repeating: 0xB3, count: 32) + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + #expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [0, 1], generatedAt: nowMs - 1000))) + #expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 0) + + // The owner topped up: ID 1 survives (still unconsumed on their side), + // ID 0 rotated out, new IDs appear. + #expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [1, 8, 9], generatedAt: nowMs))) + // m1's assignment referenced a rotated-out ID; a re-deposit picks a + // fresh one rather than sealing to a dead key. + #expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 1) + #expect(store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id == 8) + } + + @Test func expiredBundleIsNeverUsed() { + var current = Date() + let store = PrekeyBundleStore(persistsToDisk: false, now: { current }) + let noiseKey = Data(repeating: 0xB4, count: 32) + #expect(store.ingest(makeBundle(noiseKey: noiseKey, generatedAt: UInt64(current.timeIntervalSince1970 * 1000)))) + #expect(store.hasUsableBundle(for: noiseKey)) + + current = current.addingTimeInterval(PrekeyBundleStore.Limits.maxBundleAgeForSealingSeconds + 60) + #expect(!store.hasUsableBundle(for: noiseKey)) + #expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) == nil) + } + + @Test func peerCapEvictsLeastRecentlyUpdated() { + var current = Date() + let store = PrekeyBundleStore(persistsToDisk: false, maxPeers: 2, now: { current }) + let keys = (0..<3).map { Data(repeating: UInt8(0xC0 + $0), count: 32) } + + for key in keys { + #expect(store.ingest(makeBundle(noiseKey: key, generatedAt: UInt64(current.timeIntervalSince1970 * 1000)))) + current = current.addingTimeInterval(1) + } + // Oldest entry evicted; the two most recent survive. + #expect(!store.hasUsableBundle(for: keys[0])) + #expect(store.hasUsableBundle(for: keys[1])) + #expect(store.hasUsableBundle(for: keys[2])) + } + + @Test func persistsAcrossInstancesAndWipes() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("prekey-bundle-store-tests-\(UUID().uuidString)", isDirectory: true) + let fileURL = dir.appendingPathComponent("bundles.json") + defer { try? FileManager.default.removeItem(at: dir) } + + let noiseKey = Data(repeating: 0xB5, count: 32) + let first = PrekeyBundleStore(fileURL: fileURL) + #expect(first.ingest(makeBundle(noiseKey: noiseKey, ids: [1, 2]))) + #expect(first.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 1) + + // Consumption state survives a relaunch, so a restart can't reuse a prekey. + let second = PrekeyBundleStore(fileURL: fileURL) + #expect(second.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id == 2) + + second.wipe() + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + let third = PrekeyBundleStore(fileURL: fileURL) + #expect(!third.hasUsableBundle(for: noiseKey)) + } +} + +/// Envelope v2 wire compatibility: the prekey ID rides an optional TLV that +/// v1 decoders skip as unknown. +struct CourierEnvelopeV2Tests { + + @Test func prekeyIDRoundTrips() throws { + let envelope = CourierEnvelope( + recipientTag: Data(repeating: 0x11, count: CourierEnvelope.tagLength), + expiry: UInt64(Date().timeIntervalSince1970 * 1000) + 60_000, + ciphertext: Data("ciphertext".utf8), + copies: 4, + prekeyID: 0xAABB_CCDD + ) + let encoded = try #require(envelope.encode()) + let decoded = try #require(CourierEnvelope.decode(encoded)) + #expect(decoded == envelope) + #expect(decoded.prekeyID == 0xAABB_CCDD) + } + + @Test func v1EnvelopeDecodesWithNilPrekeyID() throws { + let envelope = CourierEnvelope( + recipientTag: Data(repeating: 0x22, count: CourierEnvelope.tagLength), + expiry: UInt64(Date().timeIntervalSince1970 * 1000) + 60_000, + ciphertext: Data("legacy".utf8) + ) + let encoded = try #require(envelope.encode()) + let decoded = try #require(CourierEnvelope.decode(encoded)) + #expect(decoded.prekeyID == nil) + } + + @Test func v1EncodingIsByteIdenticalWithoutPrekeyID() throws { + // Static-sealed envelopes must stay on the pre-prekey wire format. + let tag = Data(repeating: 0x33, count: CourierEnvelope.tagLength) + let expiry: UInt64 = 1_800_000_000_000 + let ciphertext = Data("same".utf8) + let v1 = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext).encode()) + let v1Explicit = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext, prekeyID: nil).encode()) + #expect(v1 == v1Explicit) + // And a v2 envelope is the v1 bytes plus one trailing TLV a v1 + // decoder skips as unknown. + let v2 = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext, prekeyID: 7).encode()) + #expect(v2.prefix(v1.count) == v1) + #expect(v2.count == v1.count + 3 + 4) + } + + @Test func withCopiesPreservesPrekeyID() { + let envelope = CourierEnvelope( + recipientTag: Data(repeating: 0x44, count: CourierEnvelope.tagLength), + expiry: 1, + ciphertext: Data([0x01]), + copies: 4, + prekeyID: 9 + ) + #expect(envelope.withCopies(2).prekeyID == 9) + } +} diff --git a/bitchatTests/Prekeys/PrekeyBundleTests.swift b/bitchatTests/Prekeys/PrekeyBundleTests.swift new file mode 100644 index 00000000..8d9b212b --- /dev/null +++ b/bitchatTests/Prekeys/PrekeyBundleTests.swift @@ -0,0 +1,133 @@ +// +// PrekeyBundleTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import CryptoKit +import BitFoundation +@testable import bitchat + +/// Wire format and signature binding for gossiped one-time prekey bundles. +struct PrekeyBundleTests { + + private func makePrekeys(_ count: Int) -> [PrekeyBundle.Prekey] { + (0.. +// + +import CryptoKit +import Foundation +import Testing +@testable import bitchat + +struct BoardPacketsTests { + + private let authorKey = Curve25519.Signing.PrivateKey() + + private func makeSignedPost( + geohash: String = "9q8yy", + content: String = "water point at the north gate", + nickname: String = "ranger", + createdAt: UInt64 = 1_700_000_000_000, + lifetimeMs: UInt64 = 24 * 60 * 60 * 1000, + flags: UInt8 = 0, + signWith key: Curve25519.Signing.PrivateKey? = nil, + claimKey: Data? = nil + ) throws -> BoardPostPacket { + let signer = key ?? authorKey + let publicKey = claimKey ?? signer.publicKey.rawRepresentation + let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) }) + let expiresAt = createdAt + lifetimeMs + let signingBytes = BoardPostPacket.signingBytes( + postID: postID, + geohash: geohash, + content: content, + authorSigningKey: publicKey, + authorNickname: nickname, + createdAt: createdAt, + expiresAt: expiresAt, + flags: flags + ) + let signature = try signer.signature(for: signingBytes) + return BoardPostPacket( + postID: postID, + geohash: geohash, + content: content, + authorSigningKey: publicKey, + authorNickname: nickname, + createdAt: createdAt, + expiresAt: expiresAt, + flags: flags, + signature: signature + ) + } + + private func makeSignedTombstone( + postID: Data, + deletedAt: UInt64 = 1_700_000_100_000, + signWith key: Curve25519.Signing.PrivateKey? = nil, + claimKey: Data? = nil + ) throws -> BoardTombstonePacket { + let signer = key ?? authorKey + let publicKey = claimKey ?? signer.publicKey.rawRepresentation + let signature = try signer.signature(for: BoardTombstonePacket.signingBytes(postID: postID, deletedAt: deletedAt)) + return BoardTombstonePacket( + postID: postID, + authorSigningKey: publicKey, + deletedAt: deletedAt, + signature: signature + ) + } + + // MARK: - Round trips + + @Test func postRoundTrip() throws { + let post = try makeSignedPost(flags: BoardPostPacket.urgentFlag) + let encoded = BoardWire.post(post).encode() + let decoded = try #require(BoardWire.decode(from: encoded)) + #expect(decoded == .post(post)) + #expect(decoded.verifySignature()) + guard case .post(let roundTripped) = decoded else { + Issue.record("expected a post") + return + } + #expect(roundTripped.isUrgent) + #expect(roundTripped.geohash == "9q8yy") + } + + @Test func meshLocalPostRoundTrip() throws { + let post = try makeSignedPost(geohash: "") + let decoded = try #require(BoardWire.decode(from: BoardWire.post(post).encode())) + #expect(decoded == .post(post)) + #expect(decoded.verifySignature()) + } + + @Test func tombstoneRoundTrip() throws { + let post = try makeSignedPost() + let tombstone = try makeSignedTombstone(postID: post.postID) + let encoded = BoardWire.tombstone(tombstone).encode() + let decoded = try #require(BoardWire.decode(from: encoded)) + #expect(decoded == .tombstone(tombstone)) + #expect(decoded.verifySignature()) + } + + // MARK: - Signature verification + + @Test func forgedPostSignatureFailsVerification() throws { + // Signed by an attacker's key but claiming the victim's key as author. + let attacker = Curve25519.Signing.PrivateKey() + let victim = Curve25519.Signing.PrivateKey() + let forged = try makeSignedPost(signWith: attacker, claimKey: victim.publicKey.rawRepresentation) + let decoded = try #require(BoardWire.decode(from: BoardWire.post(forged).encode())) + #expect(!decoded.verifySignature()) + } + + @Test func tamperedContentFailsVerification() throws { + let post = try makeSignedPost(content: "meet at noon") + let tampered = BoardPostPacket( + postID: post.postID, + geohash: post.geohash, + content: "meet at midnight", + authorSigningKey: post.authorSigningKey, + authorNickname: post.authorNickname, + createdAt: post.createdAt, + expiresAt: post.expiresAt, + flags: post.flags, + signature: post.signature + ) + let decoded = try #require(BoardWire.decode(from: BoardWire.post(tampered).encode())) + #expect(!decoded.verifySignature()) + } + + @Test func forgedTombstoneSignatureFailsVerification() throws { + let post = try makeSignedPost() + let attacker = Curve25519.Signing.PrivateKey() + let forged = try makeSignedTombstone( + postID: post.postID, + signWith: attacker, + claimKey: post.authorSigningKey + ) + let decoded = try #require(BoardWire.decode(from: BoardWire.tombstone(forged).encode())) + #expect(!decoded.verifySignature()) + } + + // MARK: - Decode validation + + @Test func rejectsExpiryBeyondSevenDays() throws { + let tooLong = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs + 1) + #expect(BoardWire.decode(from: BoardWire.post(tooLong).encode()) == nil) + + let exactlySevenDays = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs) + #expect(BoardWire.decode(from: BoardWire.post(exactlySevenDays).encode()) != nil) + } + + @Test func rejectsExpiryBeforeCreation() throws { + let post = try makeSignedPost() + let inverted = BoardPostPacket( + postID: post.postID, + geohash: post.geohash, + content: post.content, + authorSigningKey: post.authorSigningKey, + authorNickname: post.authorNickname, + createdAt: post.expiresAt, + expiresAt: post.createdAt, + flags: post.flags, + signature: post.signature + ) + #expect(BoardWire.decode(from: BoardWire.post(inverted).encode()) == nil) + } + + @Test func rejectsOversizedContent() throws { + let oversized = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes + 1)) + #expect(BoardWire.decode(from: BoardWire.post(oversized).encode()) == nil) + + let maxed = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes)) + #expect(BoardWire.decode(from: BoardWire.post(maxed).encode()) != nil) + } + + @Test func rejectsInvalidGeohashCharacters() throws { + let invalid = try makeSignedPost(geohash: "9q8yA") // "A" is outside base32 + #expect(BoardWire.decode(from: BoardWire.post(invalid).encode()) == nil) + } + + @Test func toleratesUnknownTLVs() throws { + let post = try makeSignedPost() + var encoded = BoardWire.post(post).encode() + // Append an unknown TLV; decoders must skip it. + encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD]) + let decoded = try #require(BoardWire.decode(from: encoded)) + #expect(decoded == .post(post)) + #expect(decoded.verifySignature()) + } + + @Test func rejectsTruncatedPayload() throws { + let post = try makeSignedPost() + let encoded = BoardWire.post(post).encode() + #expect(BoardWire.decode(from: encoded.prefix(encoded.count - 1)) == nil) + } + + // MARK: - Urgent flag peek + + @Test func urgentFlagPeekMatchesFullDecode() throws { + let urgent = try makeSignedPost(flags: BoardPostPacket.urgentFlag) + let calm = try makeSignedPost() + let tombstone = try makeSignedTombstone(postID: calm.postID) + #expect(BoardWire.urgentFlag(in: BoardWire.post(urgent).encode())) + #expect(!BoardWire.urgentFlag(in: BoardWire.post(calm).encode())) + #expect(!BoardWire.urgentFlag(in: BoardWire.tombstone(tombstone).encode())) + #expect(!BoardWire.urgentFlag(in: Data())) + } +} diff --git a/bitchatTests/Protocols/NostrCarrierPacketTests.swift b/bitchatTests/Protocols/NostrCarrierPacketTests.swift new file mode 100644 index 00000000..50ffb287 --- /dev/null +++ b/bitchatTests/Protocols/NostrCarrierPacketTests.swift @@ -0,0 +1,96 @@ +// +// NostrCarrierPacketTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat + +@Suite("Nostr carrier packet TLV") +struct NostrCarrierPacketTests { + private func makeEvent(geohash: String = "u4pruy", content: String = "hello mesh") throws -> NostrEvent { + let identity = try NostrIdentity.generate() + return try NostrProtocol.createEphemeralGeohashEvent( + content: content, + geohash: geohash, + senderIdentity: identity, + nickname: "tester" + ) + } + + @Test("round-trips both directions with the signed event intact") + func roundTrip() throws { + let event = try makeEvent() + for direction in [NostrCarrierPacket.Direction.toGateway, .fromGateway] { + let packet = try #require(NostrCarrierPacket(direction: direction, geohash: "u4pruy", event: event)) + let encoded = try #require(packet.encode()) + let decoded = try #require(NostrCarrierPacket.decode(encoded)) + + #expect(decoded == packet) + #expect(decoded.direction == direction) + #expect(decoded.geohash == "u4pruy") + + // The carried event survives byte-exact: same ID, and the + // signature still verifies after the mesh hop. + let carried = try #require(decoded.event()) + #expect(carried.id == event.id) + #expect(carried.sig == event.sig) + #expect(carried.isValidSignature()) + } + } + + @Test("rejects an oversized event at construction and at decode") + func oversizedRejected() throws { + let oversized = Data(repeating: 0x7B, count: NostrCarrierPacket.maxEventJSONBytes + 1) + #expect(NostrCarrierPacket(direction: .toGateway, geohash: "u4pruy", eventJSON: oversized) == nil) + + // Hand-build the TLV bytes to bypass the initializer's cap. + var data = Data([0x01, 0x00, 0x01, NostrCarrierPacket.Direction.toGateway.rawValue]) + let geohash = Data("u4pruy".utf8) + data.append(contentsOf: [0x02, 0x00, UInt8(geohash.count)]) + data.append(geohash) + data.append(contentsOf: [0x03, UInt8((oversized.count >> 8) & 0xFF), UInt8(oversized.count & 0xFF)]) + data.append(oversized) + #expect(NostrCarrierPacket.decode(data) == nil) + } + + @Test("rejects an over-length or empty geohash") + func geohashBoundsEnforced() throws { + let event = try makeEvent() + #expect(NostrCarrierPacket(direction: .toGateway, geohash: "", event: event) == nil) + #expect(NostrCarrierPacket(direction: .toGateway, geohash: String(repeating: "u", count: 13), event: event) == nil) + #expect(NostrCarrierPacket(direction: .toGateway, geohash: String(repeating: "u", count: 12), event: event) != nil) + } + + @Test("skips unknown TLVs for forward compatibility") + func unknownTLVSkipped() throws { + let event = try makeEvent() + let packet = try #require(NostrCarrierPacket(direction: .fromGateway, geohash: "u4pruy", event: event)) + var encoded = try #require(packet.encode()) + // Append an unknown TLV (type 0x7F, 2-byte value). + encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD]) + let decoded = try #require(NostrCarrierPacket.decode(encoded)) + #expect(decoded == packet) + } + + @Test("rejects truncated and missing-field payloads") + func malformedRejected() throws { + let event = try makeEvent() + let packet = try #require(NostrCarrierPacket(direction: .toGateway, geohash: "u4pruy", event: event)) + let encoded = try #require(packet.encode()) + + // Truncation anywhere inside the last TLV fails cleanly. + #expect(NostrCarrierPacket.decode(encoded.dropLast(1)) == nil) + #expect(NostrCarrierPacket.decode(encoded.prefix(4)) == nil) + #expect(NostrCarrierPacket.decode(Data()) == nil) + + // Direction TLV alone (missing geohash and event) fails. + #expect(NostrCarrierPacket.decode(Data([0x01, 0x00, 0x01, 0x01])) == nil) + // Unknown direction value fails. + #expect(NostrCarrierPacket.decode(Data([0x01, 0x00, 0x01, 0x77])) == nil) + } +} diff --git a/bitchatTests/Protocols/PacketsTests.swift b/bitchatTests/Protocols/PacketsTests.swift index 78c244de..2368925a 100644 --- a/bitchatTests/Protocols/PacketsTests.swift +++ b/bitchatTests/Protocols/PacketsTests.swift @@ -1,3 +1,4 @@ +import BitFoundation import Foundation import Testing @@ -108,6 +109,42 @@ struct PacketsTests { #expect(decoded.directNeighbors == nil) } + @Test + func announcementPacketRoundTripsCapabilities() throws { + let capabilities: PeerCapabilities = [.prekeys, .board, .meshDiagnostics] + let packet = AnnouncementPacket( + nickname: "alice", + noisePublicKey: Data(repeating: 0x11, count: 32), + signingPublicKey: Data(repeating: 0x22, count: 32), + directNeighbors: nil, + capabilities: capabilities + ) + + let encoded = try #require(packet.encode()) + let decoded = try #require(AnnouncementPacket.decode(from: encoded)) + #expect(decoded.capabilities == capabilities) + } + + @Test + func announcementPacketWithoutCapabilitiesDecodesNilAndUnknownBitsSurvive() throws { + let legacy = try #require( + AnnouncementPacket( + nickname: "alice", + noisePublicKey: Data(repeating: 0x11, count: 32), + signingPublicKey: Data(repeating: 0x22, count: 32), + directNeighbors: nil + ).encode() + ) + // The TLV is emitted only when capabilities are set, so legacy peers + // (and this packet) decode as nil rather than empty. + #expect(try #require(AnnouncementPacket.decode(from: legacy)).capabilities == nil) + + var withFutureBits = legacy + withFutureBits.append(makeTLV(type: 0x05, value: Data([0x80, 0x01]))) + let decoded = try #require(AnnouncementPacket.decode(from: withFutureBits)) + #expect(decoded.capabilities?.rawValue == 0x0180) + } + @Test func privateMessagePacketRejectsUnknownTypeAndTruncation() { let unknownTLV = Data([0x7F, 0x01, 0x41]) diff --git a/bitchatTests/Protocols/VouchAttestationTests.swift b/bitchatTests/Protocols/VouchAttestationTests.swift new file mode 100644 index 00000000..064cf4d8 --- /dev/null +++ b/bitchatTests/Protocols/VouchAttestationTests.swift @@ -0,0 +1,176 @@ +import CryptoKit +import Foundation +import Testing + +@testable import bitchat + +struct VouchAttestationTests { + private let voucherKey = Curve25519.Signing.PrivateKey() + + private func makeAttestation( + fingerprint: Data = Data(repeating: 0xAA, count: 32), + signingKey: Data = Data(repeating: 0xBB, count: 32), + timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000), + signedBy key: Curve25519.Signing.PrivateKey? = nil + ) throws -> VouchAttestation { + let signer = key ?? voucherKey + return try #require( + VouchAttestation.build( + voucheeFingerprint: fingerprint, + voucheeSigningKey: signingKey, + timestampMs: timestampMs, + sign: { try? signer.signature(for: $0) } + ) + ) + } + + @Test + func roundTripsAndVerifiesSignature() throws { + let attestation = try makeAttestation() + let encoded = try #require(attestation.encode()) + let decoded = try #require(VouchAttestation.decode(from: encoded)) + + #expect(decoded == attestation) + #expect(decoded.voucheeFingerprintHex == String(repeating: "aa", count: 32)) + #expect(decoded.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation)) + } + + @Test + func decodeSkipsUnknownTLVsAndRejectsMalformedInput() throws { + let attestation = try makeAttestation() + var encoded = try #require(attestation.encode()) + + // Unknown TLV appended: skipped for forward compatibility. + encoded.append(contentsOf: [0x7F, 0x02, 0x01, 0x02]) + #expect(VouchAttestation.decode(from: encoded) == attestation) + + // Truncation and missing fields are rejected. + #expect(VouchAttestation.decode(from: encoded.dropLast()) == nil) + #expect(VouchAttestation.decode(from: Data([0x01, 0x20])) == nil) + #expect(VouchAttestation.decode(from: Data()) == nil) + + // Wrong field sizes are rejected. + var wrongSize = Data([0x01, 0x10]) + wrongSize.append(Data(repeating: 0xAA, count: 16)) + #expect(VouchAttestation.decode(from: wrongSize) == nil) + } + + @Test + func buildRejectsWrongKeyAndFingerprintSizes() { + let sign: (Data) -> Data? = { try? self.voucherKey.signature(for: $0) } + #expect(VouchAttestation.build( + voucheeFingerprint: Data(repeating: 0xAA, count: 16), + voucheeSigningKey: Data(repeating: 0xBB, count: 32), + sign: sign + ) == nil) + #expect(VouchAttestation.build( + voucheeFingerprint: Data(repeating: 0xAA, count: 32), + voucheeSigningKey: Data(repeating: 0xBB, count: 16), + sign: sign + ) == nil) + } + + @Test + func forgedSignatureFailsVerification() throws { + let attestation = try makeAttestation() + let otherKey = Curve25519.Signing.PrivateKey() + + // Verifying against a key that didn't sign fails. + #expect(!attestation.verifySignature(voucherSigningKey: otherKey.publicKey.rawRepresentation)) + + // An attestation signed by an imposter fails against the real key. + let forged = try makeAttestation(signedBy: otherKey) + #expect(!forged.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation)) + #expect(!attestation.verifySignature(voucherSigningKey: Data(repeating: 0x01, count: 3))) + } + + @Test + func tamperedFieldsFailVerification() throws { + let attestation = try makeAttestation() + let publicKey = voucherKey.publicKey.rawRepresentation + + var tamperedFingerprint = attestation.voucheeFingerprint + tamperedFingerprint[0] ^= 0xFF + let tampered = VouchAttestation( + voucheeFingerprint: tamperedFingerprint, + voucheeSigningKey: attestation.voucheeSigningKey, + timestampMs: attestation.timestampMs, + signature: attestation.signature + ) + #expect(!tampered.verifySignature(voucherSigningKey: publicKey)) + + let backdated = VouchAttestation( + voucheeFingerprint: attestation.voucheeFingerprint, + voucheeSigningKey: attestation.voucheeSigningKey, + timestampMs: attestation.timestampMs - 1, + signature: attestation.signature + ) + #expect(!backdated.verifySignature(voucherSigningKey: publicKey)) + } + + @Test + func expiryWindowIsEnforced() throws { + let now = Date() + let fresh = try makeAttestation(timestampMs: UInt64(now.timeIntervalSince1970 * 1000)) + #expect(!fresh.isExpired(now: now)) + + let thirtyOneDaysAgo = now.addingTimeInterval(-31 * 24 * 60 * 60) + let expired = try makeAttestation(timestampMs: UInt64(thirtyOneDaysAgo.timeIntervalSince1970 * 1000)) + #expect(expired.isExpired(now: now)) + + let farFuture = now.addingTimeInterval(2 * 60 * 60) + let fromTheFuture = try makeAttestation(timestampMs: UInt64(farFuture.timeIntervalSince1970 * 1000)) + #expect(fromTheFuture.isExpired(now: now)) + + // A verified-but-expired attestation still has a valid signature; the + // two checks are independent gates. + #expect(expired.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation)) + } + + @Test + func batchRoundTripsAndEnforcesCap() throws { + let attestations = try (0..<3).map { index in + try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32)) + } + let payload = try #require(VouchAttestation.encodeList(attestations)) + #expect(VouchAttestation.decodeList(from: payload) == attestations) + + #expect(VouchAttestation.encodeList([]) == nil) + + let tooMany = try (0..<17).map { index in + try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32)) + } + #expect(VouchAttestation.encodeList(tooMany) == nil) + } + + @Test + func decodeListIgnoresEntriesBeyondCapAndMalformedEntries() throws { + let attestations = try (0..<17).map { index in + try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32)) + } + // Hand-build an oversized batch that lies about its count. + var payload = Data([UInt8(attestations.count)]) + for attestation in attestations { + let encoded = try #require(attestation.encode()) + payload.append(UInt8(encoded.count >> 8)) + payload.append(UInt8(encoded.count & 0xFF)) + payload.append(encoded) + } + let decoded = VouchAttestation.decodeList(from: payload) + #expect(decoded.count == VouchAttestation.maxBatchCount) + #expect(decoded == Array(attestations.prefix(VouchAttestation.maxBatchCount))) + + // A malformed middle entry is dropped without killing the batch. + let good = try makeAttestation() + let goodEncoded = try #require(good.encode()) + var mixed = Data([2]) + mixed.append(contentsOf: [0x00, 0x03, 0xDE, 0xAD, 0xBE]) + mixed.append(UInt8(goodEncoded.count >> 8)) + mixed.append(UInt8(goodEncoded.count & 0xFF)) + mixed.append(goodEncoded) + #expect(VouchAttestation.decodeList(from: mixed) == [good]) + + #expect(VouchAttestation.decodeList(from: Data()) == []) + #expect(VouchAttestation.decodeList(from: Data([5])) == []) + } +} diff --git a/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift b/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift index ed7c66a1..a4423ec9 100644 --- a/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift +++ b/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift @@ -135,6 +135,140 @@ struct BLEFragmentAssemblyBufferTests { } } + @Test + func stalledBroadcastAssemblyReportsFragmentIDOnceUntilRetryLapses() throws { + var buffer = BLEFragmentAssemblyBuffer() + let fragmentID = Data((1...8).map { UInt8($0) }) + let packet = makePacket(payload: makePayload(count: 256)) + let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID) + let first = try #require(BLEFragmentHeader(packet: fragments[0])) + + let t0 = Date(timeIntervalSince1970: 100) + _ = buffer.append(first, maxInFlightAssemblies: 8, now: t0) + + // Not yet stalled. + let early = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(4)) + #expect(early.isEmpty) + + // Stalled: reported once, big-endian stream ID. + let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6)) + #expect(stalled == [fragmentID]) + + // Within the retry window: not re-reported. + let repeated = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(8)) + #expect(repeated.isEmpty) + + // After the retry window it is requested again. + let retried = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(17)) + #expect(retried == [fragmentID]) + } + + @Test + func newFragmentResetsStallClockAndCompletionStopsRequests() throws { + var buffer = BLEFragmentAssemblyBuffer() + let fragmentID = Data((10...17).map { UInt8($0) }) + let packet = makePacket(payload: makePayload(count: 384)) + let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID) + let headers = try fragments.map { try #require(BLEFragmentHeader(packet: $0)) } + #expect(headers.count >= 3) + + let t0 = Date(timeIntervalSince1970: 100) + _ = buffer.append(headers[0], maxInFlightAssemblies: 8, now: t0) + // A fragment arriving at t0+4 resets the stall clock. + _ = buffer.append(headers[1], maxInFlightAssemblies: 8, now: t0.addingTimeInterval(4)) + let afterProgress = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6)) + #expect(afterProgress.isEmpty) + + // Completion removes the assembly entirely. + var result: BLEFragmentAssemblyBuffer.AppendResult? + for header in headers.dropFirst(2) { + result = buffer.append(header, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(5)) + } + guard case .complete = result else { + Issue.record("Expected assembly to complete") + return + } + let afterCompletion = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(60)) + #expect(afterCompletion.isEmpty) + } + + @Test + func duplicateFragmentsDoNotResetStallClock() throws { + var buffer = BLEFragmentAssemblyBuffer() + let fragmentID = Data((20...27).map { UInt8($0) }) + let packet = makePacket(payload: makePayload(count: 256)) + let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID) + let first = try #require(BLEFragmentHeader(packet: fragments[0])) + + let t0 = Date(timeIntervalSince1970: 100) + _ = buffer.append(first, maxInFlightAssemblies: 8, now: t0) + + // Relay duplicates of the same index arrive every few seconds; they + // bring no new data, so they must not keep the stream "fresh". + _ = buffer.append(first, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(3)) + _ = buffer.append(first, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(5)) + + let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6)) + #expect(stalled == [fragmentID]) + } + + @Test + func overflowStalledStreamsRotateAcrossPasses() throws { + var buffer = BLEFragmentAssemblyBuffer() + let cap = RequestSyncPacket.maxFragmentIdFilterCount + let streamCount = cap + 10 + let t0 = Date(timeIntervalSince1970: 100) + + // Incomplete broadcast assemblies with staggered last-fragment times + // (stream 0 is the oldest stall). + var ids: [Data] = [] + for i in 0..> 8), UInt8(i & 0xFF)]) + ids.append(fragmentID) + let header = try #require(BLEFragmentHeader(packet: makeFragmentPacket( + fragmentID: fragmentID, + index: 0, + total: 2, + originalType: MessageType.message.rawValue, + fragmentData: Data([0x01]) + ))) + _ = buffer.append(header, maxInFlightAssemblies: streamCount, now: t0.addingTimeInterval(Double(i))) + } + + // All streams are stalled; only the cap's worth (oldest first) is + // requested and rate-limited, the overflow stays eligible. + let firstPassAt = t0.addingTimeInterval(Double(streamCount) + 5) + let firstPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt) + #expect(firstPass == Array(ids.prefix(cap))) + + // Next pass picks up exactly the overflow streams. + let secondPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt.addingTimeInterval(1)) + #expect(secondPass == Array(ids.suffix(streamCount - cap))) + + // Nothing left until a retry window lapses. + let thirdPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt.addingTimeInterval(2)) + #expect(thirdPass.isEmpty) + } + + @Test + func directedAssembliesAreNeverReportedAsStalled() throws { + var buffer = BLEFragmentAssemblyBuffer() + let fragment = makeFragmentPacket( + fragmentID: Data(repeating: 0x0A, count: 8), + index: 0, + total: 2, + originalType: MessageType.message.rawValue, + fragmentData: Data([0x01]), + recipientID: Data(hexString: "0102030405060708") + ) + let header = try #require(BLEFragmentHeader(packet: fragment)) + + let t0 = Date(timeIntervalSince1970: 100) + _ = buffer.append(header, maxInFlightAssemblies: 8, now: t0) + let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(60)) + #expect(stalled.isEmpty) + } + private func makePacket(payload: Data, timestamp: UInt64 = 0x0102030405) -> BitchatPacket { BitchatPacket( type: MessageType.message.rawValue, diff --git a/bitchatTests/Services/BLESourceRouteFailureCacheTests.swift b/bitchatTests/Services/BLESourceRouteFailureCacheTests.swift new file mode 100644 index 00000000..5d3315f6 --- /dev/null +++ b/bitchatTests/Services/BLESourceRouteFailureCacheTests.swift @@ -0,0 +1,98 @@ +// +// BLESourceRouteFailureCacheTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +struct BLESourceRouteFailureCacheTests { + private let recipient = PeerID(str: "0102030405060708") + private let config = BLESourceRouteFailureCache.Config( + confirmationWindowSeconds: 10, + suppressionSeconds: 60 + ) + + private func attempts(_ cache: inout BLESourceRouteFailureCache, at date: Date) -> Bool { + cache.shouldAttemptRoute(to: recipient, now: date) + } + + @Test func allowsRoutingByDefault() { + var cache = BLESourceRouteFailureCache(config: config) + #expect(attempts(&cache, at: Date())) + } + + @Test func unconfirmedRoutedSendSuppressesRouting() { + var cache = BLESourceRouteFailureCache(config: config) + let t0 = Date() + + cache.noteRoutedSend(to: recipient, now: t0) + // Inside the confirmation window: keep routing. + #expect(attempts(&cache, at: t0.addingTimeInterval(5))) + // Past the window with no inbound traffic: route failed, flood. + #expect(!attempts(&cache, at: t0.addingTimeInterval(11))) + // Still suppressed for the suppression TTL. + #expect(!attempts(&cache, at: t0.addingTimeInterval(40))) + // Suppression lapses: routing may be attempted again. + #expect(attempts(&cache, at: t0.addingTimeInterval(11 + 61))) + } + + @Test func inboundActivityConfirmsPendingSend() { + var cache = BLESourceRouteFailureCache(config: config) + let t0 = Date() + + cache.noteRoutedSend(to: recipient, now: t0) + cache.noteInboundActivity(from: recipient) + // Confirmed: no suppression even long after the window. + #expect(attempts(&cache, at: t0.addingTimeInterval(30))) + } + + @Test func inboundActivityDoesNotLiftActiveSuppression() { + var cache = BLESourceRouteFailureCache(config: config) + let t0 = Date() + + cache.noteRoutedSend(to: recipient, now: t0) + // Trip the failure → suppression starts at t0+15. + #expect(!attempts(&cache, at: t0.addingTimeInterval(15))) + // Inbound traffic may have arrived via flood; suppression holds. + cache.noteInboundActivity(from: recipient) + #expect(!attempts(&cache, at: t0.addingTimeInterval(20))) + #expect(attempts(&cache, at: t0.addingTimeInterval(15 + 61))) + } + + @Test func backToBackSendsShareOneDeadline() { + var cache = BLESourceRouteFailureCache(config: config) + let t0 = Date() + + cache.noteRoutedSend(to: recipient, now: t0) + cache.noteRoutedSend(to: recipient, now: t0.addingTimeInterval(8)) + // Deadline runs from the first unconfirmed send. + #expect(!attempts(&cache, at: t0.addingTimeInterval(11))) + } + + @Test func pruneDropsExpiredEntries() { + var cache = BLESourceRouteFailureCache(config: config) + let t0 = Date() + + cache.noteRoutedSend(to: recipient, now: t0) + // Past confirmation + suppression: the entry can no longer matter. + cache.prune(now: t0.addingTimeInterval(75)) + #expect(attempts(&cache, at: t0.addingTimeInterval(76))) + } + + @Test func pruneKeepsEntriesThatStillMatter() { + var cache = BLESourceRouteFailureCache(config: config) + let t0 = Date() + + cache.noteRoutedSend(to: recipient, now: t0) + cache.prune(now: t0.addingTimeInterval(30)) + // The unconverted pending entry survives pruning and still converts + // into a suppression on the next routing decision. + #expect(!attempts(&cache, at: t0.addingTimeInterval(31))) + } +} diff --git a/bitchatTests/Services/BLESourceRouteOriginationPolicyTests.swift b/bitchatTests/Services/BLESourceRouteOriginationPolicyTests.swift new file mode 100644 index 00000000..96b8d434 --- /dev/null +++ b/bitchatTests/Services/BLESourceRouteOriginationPolicyTests.swift @@ -0,0 +1,98 @@ +// +// BLESourceRouteOriginationPolicyTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +struct BLESourceRouteOriginationPolicyTests { + private let localPeerIDData = Data(hexString: "0102030405060708")! + private let recipient = PeerID(str: "1112131415161718") + private let hop = Data(hexString: "2122232425262728")! + + private func makePacket( + senderID: Data? = nil, + recipientID: Data? = Data(hexString: "1112131415161718"), + ttl: UInt8 = 7 + ) -> BitchatPacket { + BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: senderID ?? localPeerIDData, + recipientID: recipientID, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data([0x01]), + signature: nil, + ttl: ttl + ) + } + + private func decide( + packet: BitchatPacket, + isRecipientConnected: Bool = false, + shouldAttemptRoute: Bool = true, + computedRoute: [Data]? = nil + ) -> BLESourceRouteOriginationPolicy.Decision { + BLESourceRouteOriginationPolicy.decide( + for: packet, + to: recipient, + localPeerIDData: localPeerIDData, + isRecipientConnected: { _ in isRecipientConnected }, + shouldAttemptRoute: { _ in shouldAttemptRoute }, + computeRoute: { _ in computedRoute ?? [self.hop] } + ) + } + + @Test func routesWhenAllGatesPass() { + #expect(decide(packet: makePacket()) == .route([hop])) + } + + @Test func relayedPacketNeverGetsRoute() { + let relayed = makePacket(senderID: Data(hexString: "aabbccddeeff0011")) + #expect(decide(packet: relayed) == .flood(.relayedNotOriginator)) + } + + @Test func broadcastRecipientNeverGetsRoute() { + let broadcast = makePacket(recipientID: Data(repeating: 0xFF, count: 8)) + #expect(decide(packet: broadcast) == .flood(.broadcast)) + let noRecipient = makePacket(recipientID: nil) + #expect(decide(packet: noRecipient) == .flood(.broadcast)) + } + + @Test func linkLocalTTLNeverGetsRoute() { + // TTL 0/1 packets (e.g. REQUEST_SYNC) cannot traverse hops. + #expect(decide(packet: makePacket(ttl: 0)) == .flood(.noTTLHeadroom)) + #expect(decide(packet: makePacket(ttl: 1)) == .flood(.noTTLHeadroom)) + } + + @Test func directlyConnectedRecipientNeverGetsRoute() { + #expect(decide(packet: makePacket(), isRecipientConnected: true) == .flood(.recipientDirect)) + } + + @Test func suppressedRecipientFallsBackToFlood() { + #expect(decide(packet: makePacket(), shouldAttemptRoute: false) == .flood(.routeSuppressed)) + } + + @Test func missingOrEmptyRouteFallsBackToFlood() { + var sawComputeRoute = false + let result = BLESourceRouteOriginationPolicy.decide( + for: makePacket(), + to: recipient, + localPeerIDData: localPeerIDData, + isRecipientConnected: { _ in false }, + shouldAttemptRoute: { _ in true }, + computeRoute: { _ in + sawComputeRoute = true + return nil + } + ) + #expect(result == .flood(.noPath)) + #expect(sawComputeRoute) + #expect(decide(packet: makePacket(), computedRoute: []) == .flood(.noPath)) + } +} diff --git a/bitchatTests/Services/BoardStoreTests.swift b/bitchatTests/Services/BoardStoreTests.swift new file mode 100644 index 00000000..d501de39 --- /dev/null +++ b/bitchatTests/Services/BoardStoreTests.swift @@ -0,0 +1,385 @@ +// +// BoardStoreTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import CryptoKit +import Foundation +import Testing +@testable import bitchat + +struct BoardStoreTests { + + private final class MutableClock: @unchecked Sendable { + var now: Date + init(now: Date) { self.now = now } + } + + private let baseDate = Date(timeIntervalSince1970: 1_700_000_000) + private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) } + + private func makeStore(clock: MutableClock, fileURL: URL? = nil) -> BoardStore { + BoardStore(persistsToDisk: fileURL != nil, fileURL: fileURL, now: { clock.now }) + } + + private func tempFileURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("board-store-\(UUID().uuidString).json") + } + + private func makePost( + author: Curve25519.Signing.PrivateKey, + geohash: String = "9q8yy", + content: String = "note", + createdAt: UInt64, + lifetimeMs: UInt64 = 24 * 60 * 60 * 1000 + ) throws -> (wire: BoardWire, packet: BitchatPacket, post: BoardPostPacket) { + let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) }) + let key = author.publicKey.rawRepresentation + let expiresAt = createdAt + lifetimeMs + let signingBytes = BoardPostPacket.signingBytes( + postID: postID, + geohash: geohash, + content: content, + authorSigningKey: key, + authorNickname: "tester", + createdAt: createdAt, + expiresAt: expiresAt, + flags: 0 + ) + let post = BoardPostPacket( + postID: postID, + geohash: geohash, + content: content, + authorSigningKey: key, + authorNickname: "tester", + createdAt: createdAt, + expiresAt: expiresAt, + flags: 0, + signature: try author.signature(for: signingBytes) + ) + let wire = BoardWire.post(post) + return (wire, makePacket(payload: wire.encode(), timestamp: createdAt), post) + } + + private func makeTombstone( + for post: BoardPostPacket, + author: Curve25519.Signing.PrivateKey, + deletedAt: UInt64, + claimKey: Data? = nil + ) throws -> (wire: BoardWire, packet: BitchatPacket) { + let tombstone = BoardTombstonePacket( + postID: post.postID, + authorSigningKey: claimKey ?? author.publicKey.rawRepresentation, + deletedAt: deletedAt, + signature: try author.signature(for: BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt)) + ) + let wire = BoardWire.tombstone(tombstone) + return (wire, makePacket(payload: wire.encode(), timestamp: deletedAt)) + } + + private func makePacket(payload: Data, timestamp: UInt64) -> BitchatPacket { + BitchatPacket( + type: MessageType.boardPost.rawValue, + senderID: Data((0..<8).map { _ in UInt8.random(in: 0...255) }), + recipientID: nil, + timestamp: timestamp, + payload: payload, + signature: nil, + ttl: 7 + ) + } + + // MARK: - Ingest basics + + @Test func ingestStoresAndDeduplicates() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs) + + #expect(store.ingest(entry.wire, packet: entry.packet) == .accepted) + #expect(store.ingest(entry.wire, packet: entry.packet) == .duplicate) + #expect(store.posts(forGeohash: "9q8yy").count == 1) + #expect(store.posts(forGeohash: "").isEmpty) + #expect(store.syncCandidates().count == 1) + } + + @Test func rejectsAlreadyExpiredPost() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs - 2 * 60 * 60 * 1000, lifetimeMs: 60 * 60 * 1000) + + #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected) + #expect(store.posts(forGeohash: "9q8yy").isEmpty) + } + + // MARK: - Receive-time timestamp policy + + @Test func rejectsPostCreatedBeyondClockSkew() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs + BoardStore.Limits.clockSkewMs + 60 * 1000) + + #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected) + #expect(store.posts(forGeohash: "9q8yy").isEmpty) + } + + @Test func acceptsPostCreatedWithinClockSkew() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs + BoardStore.Limits.clockSkewMs - 60 * 1000) + + #expect(store.ingest(entry.wire, packet: entry.packet) == .accepted) + #expect(store.posts(forGeohash: "9q8yy").count == 1) + } + + @Test func rejectsPostExpiringTooFarInTheFuture() throws { + // Builds the wire directly, bypassing the decoder's span check, to + // exercise the ingest-level expiresAt bound on its own. + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs, lifetimeMs: 30 * 24 * 60 * 60 * 1000) + + #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected) + #expect(store.posts(forGeohash: "9q8yy").isEmpty) + } + + // MARK: - Caps and eviction + + @Test func perAuthorCapEvictsOldest() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + + var oldestID: Data? + for index in 0..<(BoardStore.Limits.maxPostsPerAuthor + 1) { + let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000) + if index == 0 { oldestID = entry.post.postID } + #expect(store.ingest(entry.wire, packet: entry.packet) == .accepted) + } + + let posts = store.posts(forGeohash: "9q8yy") + #expect(posts.count == BoardStore.Limits.maxPostsPerAuthor) + #expect(!posts.contains { $0.postID == oldestID }) + } + + @Test func globalCapEvictsOldest() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + + var oldestID: Data? + var author = Curve25519.Signing.PrivateKey() + for index in 0..<(BoardStore.Limits.maxPosts + 1) { + if index % BoardStore.Limits.maxPostsPerAuthor == 0 { + author = Curve25519.Signing.PrivateKey() + } + let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000) + if index == 0 { oldestID = entry.post.postID } + #expect(store.ingest(entry.wire, packet: entry.packet) == .accepted) + } + + let posts = store.posts(forGeohash: "9q8yy") + #expect(posts.count == BoardStore.Limits.maxPosts) + #expect(!posts.contains { $0.postID == oldestID }) + } + + // MARK: - Expiry sweep + + @Test func expiredPostsAreSwept() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let shortLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 60 * 60 * 1000) + let longLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 48 * 60 * 60 * 1000) + store.ingest(shortLived.wire, packet: shortLived.packet) + store.ingest(longLived.wire, packet: longLived.packet) + #expect(store.posts(forGeohash: "9q8yy").count == 2) + + clock.now = baseDate.addingTimeInterval(2 * 60 * 60) // 2h later + let remaining = store.posts(forGeohash: "9q8yy") + #expect(remaining.count == 1) + #expect(remaining.first?.postID == longLived.post.postID) + #expect(store.syncCandidates().count == 1) + } + + // MARK: - Tombstones + + @Test func tombstoneDeletesPostAndPropagatesUntilOriginalExpiry() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs, lifetimeMs: 24 * 60 * 60 * 1000) + store.ingest(entry.wire, packet: entry.packet) + + let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000) + #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted) + + // Post is gone, tombstone still syncs so the delete propagates. + #expect(store.posts(forGeohash: "9q8yy").isEmpty) + #expect(store.syncCandidates().count == 1) + + // Replayed copy of the deleted post is refused. + #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected) + + // After the post's original expiry the tombstone is dropped too. + clock.now = baseDate.addingTimeInterval(25 * 60 * 60) + #expect(store.syncCandidates().isEmpty) + } + + @Test func tombstoneFromWrongKeyIsRejected() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let attacker = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs) + store.ingest(entry.wire, packet: entry.packet) + + // Attacker signs with their own key (self-consistent wire, so it + // passes signature verification) but targets the victim's post. + let forged = try makeTombstone(for: entry.post, author: attacker, deletedAt: baseMs + 1000) + #expect(store.ingest(forged.wire, packet: forged.packet) == .rejected) + #expect(store.posts(forGeohash: "9q8yy").count == 1) + } + + @Test func tombstoneArrivingBeforePostSuppressesIt() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs) + + let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000) + #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted) + #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected) + #expect(store.posts(forGeohash: "9q8yy").isEmpty) + } + + @Test func orphanTombstoneRetentionIsBoundedByReceiveTime() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs) // never ingested + + // Attacker-chosen far-future deletedAt must not extend retention. + let farFuture = baseMs + 365 * 24 * 60 * 60 * 1000 + let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: farFuture) + #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted) + #expect(store.syncCandidates().count == 1) + + // No post can outlive 7 days from receipt, so neither may an orphan + // tombstone (plus skew allowance). + clock.now = baseDate.addingTimeInterval(8 * 24 * 60 * 60) + #expect(store.syncCandidates().isEmpty) + } + + @Test func orphanTombstonePerAuthorCapEvictsOldest() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + + var unseenPosts: [(wire: BoardWire, packet: BitchatPacket, post: BoardPostPacket)] = [] + for index in 0..<(BoardStore.Limits.maxOrphanTombstonesPerAuthor + 1) { + let entry = try makePost(author: author, createdAt: baseMs + UInt64(index)) + unseenPosts.append(entry) + let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000) + #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted) + } + + #expect(store.syncCandidates().count == BoardStore.Limits.maxOrphanTombstonesPerAuthor) + // The oldest orphan was evicted, so its post is no longer suppressed… + #expect(store.ingest(unseenPosts[0].wire, packet: unseenPosts[0].packet) == .accepted) + // …while the surviving orphans still suppress theirs. + #expect(store.ingest(unseenPosts[1].wire, packet: unseenPosts[1].packet) == .rejected) + } + + @Test func orphanTombstoneGlobalCapEvictsOldest() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + + var author = Curve25519.Signing.PrivateKey() + for index in 0..<(BoardStore.Limits.maxOrphanTombstones + 1) { + if index % BoardStore.Limits.maxOrphanTombstonesPerAuthor == 0 { + author = Curve25519.Signing.PrivateKey() + } + let entry = try makePost(author: author, createdAt: baseMs + UInt64(index)) + let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000) + #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted) + } + + #expect(store.syncCandidates().count == BoardStore.Limits.maxOrphanTombstones) + } + + @Test func matchedTombstonesAreExemptFromOrphanCaps() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + + // Post-then-delete more times than the per-author orphan cap; every + // tombstone matched a live post, so none may be evicted. + let cycles = BoardStore.Limits.maxOrphanTombstonesPerAuthor + 2 + for index in 0.. +// + +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("Gateway mode policy") +@MainActor +struct GatewayServiceTests { + private static let geohash = "u4pruy" + + /// Closure-injected harness around `GatewayService` recording every + /// side effect, with a controllable clock and relay connectivity. + @MainActor + private final class Fixture { + private final class ClockBox { + var now = Date() + } + + var relaysConnected = true + var currentGeohash: String? = GatewayServiceTests.geohash + var gatewayPeers: [PeerID] = [] + var sendToGatewaySucceeds = true + + private(set) var published: [(event: NostrEvent, geohash: String)] = [] + private(set) var broadcasts: [Data] = [] + private(set) var injected: [NostrEvent] = [] + private(set) var uplinkSends: [(payload: Data, peer: PeerID)] = [] + private(set) var enabledChanges: [Bool] = [] + private(set) var scheduledDrains: [(delay: TimeInterval, work: @MainActor () -> Void)] = [] + + private let clock = ClockBox() + let defaults: UserDefaults + let service: GatewayService + + init(enabled: Bool = true, suite: String = "GatewayServiceTests-\(UUID().uuidString)") { + defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let clock = clock + service = GatewayService(defaults: defaults) { clock.now } + service.publishToRelays = { [weak self] event, geohash in + self?.published.append((event, geohash)) + } + service.broadcastToMesh = { [weak self] payload in + self?.broadcasts.append(payload) + } + service.sendToGatewayPeer = { [weak self] payload, peer in + guard let self, self.sendToGatewaySucceeds else { return false } + self.uplinkSends.append((payload, peer)) + return true + } + service.availableGatewayPeers = { [weak self] in self?.gatewayPeers ?? [] } + service.relaysConnected = { [weak self] in self?.relaysConnected ?? false } + service.currentGeohash = { [weak self] in self?.currentGeohash } + service.injectInbound = { [weak self] event in self?.injected.append(event) } + service.onEnabledChanged = { [weak self] enabled in self?.enabledChanges.append(enabled) } + // Capture drain timers instead of arming a real Task so the drain + // is deterministic under the fake clock. + service.scheduleDrainTimer = { [weak self] delay, work in + self?.scheduledDrains.append((delay, work)) + } + if enabled { + service.setEnabled(true) + } + } + + func advance(_ seconds: TimeInterval) { + clock.now = clock.now.addingTimeInterval(seconds) + } + + /// Fires every currently-scheduled drain timer (simulating the window + /// freeing), as the real Task would after its delay. + func fireScheduledDrains() { + let due = scheduledDrains + scheduledDrains.removeAll() + for item in due { item.work() } + } + } + + // MARK: Event helpers + + private func makeEvent( + geohash: String = GatewayServiceTests.geohash, + content: String = "hello \(UUID().uuidString.prefix(8))" + ) throws -> NostrEvent { + let identity = try NostrIdentity.generate() + return try NostrProtocol.createEphemeralGeohashEvent( + content: content, + geohash: geohash, + senderIdentity: identity, + nickname: "tester" + ) + } + + /// A copy of `event` with tampered content but the original ID and + /// signature — what a forging gateway or mesh peer would produce. + private func forge(_ event: NostrEvent) throws -> NostrEvent { + let dict: [String: Any] = [ + "id": event.id, + "pubkey": event.pubkey, + "created_at": event.created_at, + "kind": event.kind, + "tags": event.tags, + "content": event.content + " (tampered)", + "sig": event.sig ?? "" + ] + return try NostrEvent(from: dict) + } + + private func carrierPayload( + _ event: NostrEvent, + direction: NostrCarrierPacket.Direction = .toGateway, + geohash: String = GatewayServiceTests.geohash + ) throws -> Data { + let packet = try #require(NostrCarrierPacket(direction: direction, geohash: geohash, event: event)) + return try #require(packet.encode()) + } + + private func deposit( + _ event: NostrEvent, + into fixture: Fixture, + from depositor: PeerID = PeerID(str: "1122334455667788"), + geohash: String = GatewayServiceTests.geohash + ) throws { + let payload = try carrierPayload(event, direction: .toGateway, geohash: geohash) + fixture.service.handleMeshCarrier(payload, from: depositor, directedToUs: true) + } + + // MARK: - Uplink verification gates + + @Test("publishes a verified deposit to the geo relays") + func verifiedDepositPublished() throws { + let fixture = Fixture() + let event = try makeEvent() + try deposit(event, into: fixture) + + #expect(fixture.published.count == 1) + #expect(fixture.published.first?.event.id == event.id) + #expect(fixture.published.first?.geohash == Self.geohash) + // Viewing the same geohash: the carried message shows on our own timeline. + #expect(fixture.injected.map(\.id) == [event.id]) + } + + @Test("rejects a forged signature") + func forgedSignatureRejected() throws { + let fixture = Fixture() + let forged = try forge(try makeEvent()) + try deposit(forged, into: fixture) + + #expect(fixture.published.isEmpty) + #expect(fixture.injected.isEmpty) + } + + @Test("rejects wrong kind, geohash mismatch, and stale events") + func structuralGates() throws { + let fixture = Fixture() + + // Wrong kind (kind-1 text note instead of kind-20000 ephemeral). + let identity = try NostrIdentity.generate() + let note = try NostrProtocol.createGeohashTextNote( + content: "note", + geohash: Self.geohash, + senderIdentity: identity + ) + try deposit(note, into: fixture) + #expect(fixture.published.isEmpty) + + // Carrier geohash disagreeing with the event's #g tag. + let mismatched = try makeEvent(geohash: "9q8yyk") + try deposit(mismatched, into: fixture, geohash: Self.geohash) + #expect(fixture.published.isEmpty) + + // Stale event (beyond accepted clock skew). + let stale = try makeEvent() + fixture.advance(GatewayService.Limits.maxEventAgeSeconds + 60) + try deposit(stale, into: fixture) + #expect(fixture.published.isEmpty) + } + + @Test("does nothing while the toggle is off") + func disabledGatewayIgnoresDeposits() throws { + let fixture = Fixture(enabled: false) + try deposit(try makeEvent(), into: fixture) + #expect(fixture.published.isEmpty) + #expect(fixture.service.queuedUplinks.isEmpty) + + fixture.service.rebroadcastRelayEvent(try makeEvent(), geohash: Self.geohash) + #expect(fixture.broadcasts.isEmpty) + } + + // MARK: - Uplink quotas and rate limit + + @Test("rate-limits deposits per depositor per minute") + func uplinkRateLimit() throws { + let fixture = Fixture() + let depositor = PeerID(str: "aabbccddeeff0011") + + for _ in 0.. +// + +import CryptoKit +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +struct GroupProtocolTests { + + // MARK: - Fixtures + + /// Deterministic member identity: an Ed25519 keypair plus the 64-hex + /// fingerprint the roster pins. + private struct TestIdentity { + let signingKey: Curve25519.Signing.PrivateKey + let fingerprint: String + + init(seed: UInt8) { + signingKey = Curve25519.Signing.PrivateKey() + fingerprint = Data(repeating: seed, count: 32).hexEncodedString() + } + + var member: GroupMember { + GroupMember( + fingerprint: fingerprint, + signingKey: signingKey.publicKey.rawRepresentation, + nickname: "peer-\(fingerprint.prefix(4))" + ) + } + + func sign(_ data: Data) -> Data? { + try? signingKey.signature(for: data) + } + } + + private let creator = TestIdentity(seed: 0xC1) + private let member = TestIdentity(seed: 0xA2) + private let outsider = TestIdentity(seed: 0xE3) + + private let groupID = Data((0..<16).map { UInt8($0) }) + private let key = Data(repeating: 0x42, count: 32) + + private func makeGroup(extraMembers: [GroupMember] = [], epoch: UInt32 = 1) -> BitchatGroup { + BitchatGroup( + groupID: groupID, + name: "trail crew", + epoch: epoch, + members: [creator.member, member.member] + extraMembers, + creatorFingerprint: creator.fingerprint + ) + } + + // MARK: - State payload (invite / key update) + + @Test func statePayloadRoundTripAndSignatureVerify() throws { + let group = makeGroup() + let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign)) + let encoded = try #require(payload.encode()) + + let decoded = try #require(GroupStatePayload.decode(encoded)) + #expect(decoded == payload) + #expect(decoded.groupID == groupID) + #expect(decoded.name == "trail crew") + #expect(decoded.key == key) + #expect(decoded.epoch == 1) + #expect(decoded.members == group.members) + #expect(decoded.creatorFingerprint == creator.fingerprint) + #expect(decoded.verifyCreatorSignature()) + #expect(decoded.asGroup == group) + } + + @Test func forgedCreatorSignatureIsRejected() throws { + let group = makeGroup() + // Signed by a member who is in the roster but is NOT the creator. + let forged = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: member.sign)) + #expect(!forged.verifyCreatorSignature()) + + // An outsider signing is equally rejected. + let outsiderForged = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: outsider.sign)) + #expect(!outsiderForged.verifyCreatorSignature()) + } + + @Test func tamperedStateFailsSignature() throws { + let group = makeGroup() + let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign)) + + // Bumping the epoch invalidates the signature. + let epochTampered = GroupStatePayload( + groupID: payload.groupID, + name: payload.name, + key: payload.key, + epoch: payload.epoch + 1, + members: payload.members, + creatorFingerprint: payload.creatorFingerprint, + signature: payload.signature + ) + #expect(!epochTampered.verifyCreatorSignature()) + + // So does swapping the key. + let keyTampered = GroupStatePayload( + groupID: payload.groupID, + name: payload.name, + key: Data(repeating: 0x99, count: 32), + epoch: payload.epoch, + members: payload.members, + creatorFingerprint: payload.creatorFingerprint, + signature: payload.signature + ) + #expect(!keyTampered.verifyCreatorSignature()) + + // And so does adding a member to the roster. + let rosterTampered = GroupStatePayload( + groupID: payload.groupID, + name: payload.name, + key: payload.key, + epoch: payload.epoch, + members: payload.members + [outsider.member], + creatorFingerprint: payload.creatorFingerprint, + signature: payload.signature + ) + #expect(!rosterTampered.verifyCreatorSignature()) + } + + @Test func creatorMissingFromRosterIsRejected() throws { + // State claiming a creator whose fingerprint is not in the roster has + // no key to verify against and must fail closed. + let group = BitchatGroup( + groupID: groupID, + name: "orphan", + epoch: 1, + members: [member.member], + creatorFingerprint: creator.fingerprint + ) + guard let rosterBlob = GroupRosterCoding.encode(group.members) else { + Issue.record("roster should encode") + return + } + let content = GroupStatePayload.signingContent(groupID: groupID, epoch: 1, key: key, rosterBlob: rosterBlob, name: group.name) + let payload = GroupStatePayload( + groupID: groupID, + name: group.name, + key: key, + epoch: 1, + members: group.members, + creatorFingerprint: creator.fingerprint, + signature: creator.sign(content) ?? Data() + ) + #expect(!payload.verifyCreatorSignature()) + } + + @Test func rosterCapIsEnforcedOnTheWire() { + // 17 members cannot be encoded (hard cap is 16)… + let seventeen = (0..<17).map { TestIdentity(seed: UInt8($0 + 1)).member } + #expect(GroupRosterCoding.encode(seventeen) == nil) + + // …and a hand-built blob claiming 17 members fails to decode. + let sixteen = (0..<16).map { TestIdentity(seed: UInt8($0 + 1)).member } + guard var blob = GroupRosterCoding.encode(sixteen) else { + Issue.record("16-member roster should encode") + return + } + #expect(GroupRosterCoding.decode(blob)?.count == 16) + blob[blob.startIndex] = 17 + #expect(GroupRosterCoding.decode(blob) == nil) + } + + // MARK: - Message seal / open + + @Test func messageRoundTrip() throws { + let group = makeGroup() + let timestampMs: UInt64 = 1_750_000_000_000 + let sealed = try GroupCrypto.sealMessage( + content: "summit at noon", + messageID: "msg-1", + senderNickname: "alice", + senderSigningKey: member.member.signingKey, + timestampMs: timestampMs, + groupID: groupID, + epoch: group.epoch, + key: key, + sign: member.sign + ) + + let envelope = try #require(GroupMessageEnvelope.decode(sealed)) + #expect(envelope.groupID == groupID) + #expect(envelope.epoch == group.epoch) + + let plaintext = try GroupCrypto.openMessage(envelope, key: key) + #expect(plaintext.messageID == "msg-1") + #expect(plaintext.content == "summit at noon") + #expect(plaintext.senderNickname == "alice") + #expect(plaintext.timestampMs == timestampMs) + #expect(plaintext.senderSigningKey == member.member.signingKey) + + // The roster resolves the sender; an outsider's key would not. + #expect(group.member(withSigningKey: plaintext.senderSigningKey) != nil) + #expect(group.member(withSigningKey: outsider.member.signingKey) == nil) + } + + @Test func wrongKeyFailsToDecrypt() throws { + let sealed = try GroupCrypto.sealMessage( + content: "hi", + messageID: "msg-2", + senderNickname: "alice", + senderSigningKey: member.member.signingKey, + timestampMs: 1, + groupID: groupID, + epoch: 1, + key: key, + sign: member.sign + ) + let envelope = try #require(GroupMessageEnvelope.decode(sealed)) + #expect(throws: GroupCryptoError.decryptionFailed) { + _ = try GroupCrypto.openMessage(envelope, key: Data(repeating: 0x7F, count: 32)) + } + } + + @Test func epochIsBoundIntoTheCiphertext() throws { + // Re-labeling an epoch-1 envelope as epoch 2 must break the AEAD: + // a rotated-out member cannot replay old ciphertext into a new epoch. + let sealed = try GroupCrypto.sealMessage( + content: "hi", + messageID: "msg-3", + senderNickname: "alice", + senderSigningKey: member.member.signingKey, + timestampMs: 1, + groupID: groupID, + epoch: 1, + key: key, + sign: member.sign + ) + let envelope = try #require(GroupMessageEnvelope.decode(sealed)) + let relabeled = GroupMessageEnvelope( + groupID: envelope.groupID, + epoch: 2, + nonce: envelope.nonce, + ciphertext: envelope.ciphertext + ) + #expect(throws: GroupCryptoError.decryptionFailed) { + _ = try GroupCrypto.openMessage(relabeled, key: key) + } + } + + @Test func badSenderSignatureIsRejected() throws { + // A key-holder who signs with a key other than the one they claim + // (or garbage) is dropped even though decryption succeeds. + let sealed = try GroupCrypto.sealMessage( + content: "spoof", + messageID: "msg-4", + senderNickname: "mallory", + senderSigningKey: member.member.signingKey, // claims member's key… + timestampMs: 1, + groupID: groupID, + epoch: 1, + key: key, + sign: outsider.sign // …but signs with the outsider's + ) + let envelope = try #require(GroupMessageEnvelope.decode(sealed)) + #expect(throws: GroupCryptoError.badSenderSignature) { + _ = try GroupCrypto.openMessage(envelope, key: key) + } + } + + @Test func tamperedCiphertextFailsToOpen() throws { + let sealed = try GroupCrypto.sealMessage( + content: "hi", + messageID: "msg-5", + senderNickname: "alice", + senderSigningKey: member.member.signingKey, + timestampMs: 1, + groupID: groupID, + epoch: 1, + key: key, + sign: member.sign + ) + let envelope = try #require(GroupMessageEnvelope.decode(sealed)) + var flipped = envelope.ciphertext + flipped[flipped.startIndex] ^= 0x01 + let tampered = GroupMessageEnvelope( + groupID: envelope.groupID, + epoch: envelope.epoch, + nonce: envelope.nonce, + ciphertext: flipped + ) + #expect(throws: GroupCryptoError.decryptionFailed) { + _ = try GroupCrypto.openMessage(tampered, key: key) + } + } + + @Test func malformedEnvelopesAreRejected() { + #expect(GroupMessageEnvelope.decode(Data()) == nil) + #expect(GroupMessageEnvelope.decode(Data([0x01, 0x00])) == nil) + #expect(GroupStatePayload.decode(Data([0xFF, 0x00, 0x01])) == nil) + } + + // MARK: - Oversize / UTF-8 safety (Codex findings) + + @Test func oversizeMessageContentFailsToSealInsteadOfTruncating() { + // A content whose UTF-8 exceeds the 16-bit TLV length must fail to + // seal (surfacing send_failed) rather than silently truncate into a + // ciphertext recipients would drop. + let oversize = String(repeating: "a", count: 70_000) + #expect(throws: (any Error).self) { + _ = try GroupCrypto.sealMessage( + content: oversize, + messageID: "big", + senderNickname: "alice", + senderSigningKey: member.member.signingKey, + timestampMs: 1, + groupID: groupID, + epoch: 1, + key: key, + sign: member.sign + ) + } + } + + @Test func multiByteNicknameTruncatesOnScalarBoundary() throws { + // 40 euro signs = 120 UTF-8 bytes; a raw 64-byte prefix would split + // the 21st scalar and make the roster undecodable. Truncation must + // land on a Character boundary so the blob round-trips. + let euros = String(repeating: "€", count: 40) + let wide = GroupMember( + fingerprint: creator.fingerprint, + signingKey: creator.member.signingKey, + nickname: euros + ) + let blob = try #require(GroupRosterCoding.encode([wide])) + let decoded = try #require(GroupRosterCoding.decode(blob)) + #expect(decoded.count == 1) + #expect(Data(decoded[0].nickname.utf8).count <= 64) + #expect(decoded[0].nickname.allSatisfy { $0 == "€" }) + #expect(!decoded[0].nickname.isEmpty) + } + + // MARK: - Signable-bytes forward-proofing + + @Test func creatorSignatureCoversName() throws { + let group = makeGroup() + let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign)) + #expect(payload.verifyCreatorSignature()) + + // Swapping only the display name must invalidate the creator signature. + let renamed = GroupStatePayload( + groupID: payload.groupID, + name: "totally different name", + key: payload.key, + epoch: payload.epoch, + members: payload.members, + creatorFingerprint: payload.creatorFingerprint, + signature: payload.signature + ) + #expect(!renamed.verifyCreatorSignature()) + } + + @Test func messageSignatureCoversEpoch() { + // The signed bytes differ by epoch, so a signature captured at one + // epoch cannot verify when re-sealed under a later epoch key. + let atEpoch1 = GroupCrypto.messageSigningContent( + groupID: groupID, epoch: 1, messageID: "m", timestampMs: 1, content: "x" + ) + let atEpoch2 = GroupCrypto.messageSigningContent( + groupID: groupID, epoch: 2, messageID: "m", timestampMs: 1, content: "x" + ) + #expect(atEpoch1 != atEpoch2) + } +} diff --git a/bitchatTests/Services/GroupStoreTests.swift b/bitchatTests/Services/GroupStoreTests.swift new file mode 100644 index 00000000..adf04806 --- /dev/null +++ b/bitchatTests/Services/GroupStoreTests.swift @@ -0,0 +1,170 @@ +// +// GroupStoreTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +@MainActor +struct GroupStoreTests { + + private func makeMember(seed: UInt8, nickname: String = "peer") -> GroupMember { + GroupMember( + fingerprint: Data(repeating: seed, count: 32).hexEncodedString(), + signingKey: Data(repeating: seed &+ 1, count: 32), + nickname: nickname + ) + } + + private func tempFileURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("group-store-tests-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("groups.json") + } + + // MARK: - Create / read + + @Test func createGroupStoresMetadataAndKey() throws { + let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) + let creator = makeMember(seed: 0xC1, nickname: "me") + + let group = try #require(store.createGroup(named: "ops", creator: creator)) + #expect(group.groupID.count == BitchatGroup.groupIDLength) + #expect(group.epoch == 1) + #expect(group.members == [creator]) + #expect(group.creatorFingerprint == creator.fingerprint) + + #expect(store.group(withID: group.groupID) == group) + #expect(store.group(for: group.peerID) == group) + let key = try #require(store.key(forGroupID: group.groupID)) + #expect(key.count == BitchatGroup.keyLength) + #expect(group.peerID.isGroup) + #expect(group.peerID.groupIDData == group.groupID) + } + + // MARK: - Roster cap + + @Test func rosterCapIsEnforced() throws { + let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) + let creator = makeMember(seed: 0xC1) + let group = try #require(store.createGroup(named: "big", creator: creator)) + + // Filling to the cap works… + let fifteen = (1...15).map { makeMember(seed: UInt8($0)) } + #expect(store.updateRoster(groupID: group.groupID, members: [creator] + fifteen) != nil) + #expect(store.group(withID: group.groupID)?.members.count == BitchatGroup.maxMembers) + + // …one more is rejected. + let overflow = [creator] + fifteen + [makeMember(seed: 0x99)] + #expect(store.updateRoster(groupID: group.groupID, members: overflow) == nil) + #expect(store.group(withID: group.groupID)?.members.count == BitchatGroup.maxMembers) + + // Direct upsert past the cap is rejected too. + var oversized = group + oversized.members = overflow + #expect(!store.upsert(oversized, key: Data(repeating: 1, count: 32))) + } + + @Test func rosterMustRetainCreator() throws { + let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) + let creator = makeMember(seed: 0xC1) + let other = makeMember(seed: 0xA1) + let group = try #require(store.createGroup(named: "crew", creator: creator)) + + #expect(store.updateRoster(groupID: group.groupID, members: [other]) == nil) + #expect(store.group(withID: group.groupID)?.members == [creator]) + } + + // MARK: - Rotation + + @Test func rotateKeyBumpsEpochAndReplacesKey() throws { + let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) + let creator = makeMember(seed: 0xC1) + let removed = makeMember(seed: 0xA1) + let group = try #require(store.createGroup(named: "crew", creator: creator)) + #expect(store.updateRoster(groupID: group.groupID, members: [creator, removed]) != nil) + let oldKey = try #require(store.key(forGroupID: group.groupID)) + + let rotation = try #require(store.rotateKey(groupID: group.groupID, members: [creator])) + #expect(rotation.group.epoch == 2) + #expect(rotation.group.members == [creator]) + #expect(rotation.key != oldKey) + #expect(store.key(forGroupID: group.groupID) == rotation.key) + #expect(store.group(withID: group.groupID)?.epoch == 2) + } + + // MARK: - Persistence + + @Test func persistsAcrossInstances() throws { + let keychain = MockKeychain() + let fileURL = tempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let creator = makeMember(seed: 0xC1, nickname: "me") + let group: BitchatGroup + do { + let store = GroupStore(keychain: keychain, fileURL: fileURL) + group = try #require(store.createGroup(named: "hike", creator: creator)) + } + + let reloaded = GroupStore(keychain: keychain, fileURL: fileURL) + #expect(reloaded.groups == [group]) + #expect(reloaded.key(forGroupID: group.groupID) != nil) + } + + @Test func groupsWithoutKeysAreDroppedOnLoad() throws { + let keychain = MockKeychain() + let fileURL = tempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let group: BitchatGroup + do { + let store = GroupStore(keychain: keychain, fileURL: fileURL) + group = try #require(store.createGroup(named: "stale", creator: makeMember(seed: 0xC1))) + } + // Simulate a keychain wipe without the metadata file being removed. + _ = keychain.deleteAllKeychainData() + + let reloaded = GroupStore(keychain: keychain, fileURL: fileURL) + #expect(reloaded.groups.isEmpty) + #expect(reloaded.group(withID: group.groupID) == nil) + } + + // MARK: - Panic wipe + + @Test func wipeRemovesMetadataAndKeys() throws { + let keychain = MockKeychain() + let fileURL = tempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let store = GroupStore(keychain: keychain, fileURL: fileURL) + let group = try #require(store.createGroup(named: "gone", creator: makeMember(seed: 0xC1))) + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + + store.wipe() + + #expect(store.groups.isEmpty) + #expect(store.key(forGroupID: group.groupID) == nil) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + + // A fresh instance sees nothing either. + let reloaded = GroupStore(keychain: keychain, fileURL: fileURL) + #expect(reloaded.groups.isEmpty) + } + + @Test func removeGroupDeletesItsKey() throws { + let keychain = MockKeychain() + let store = GroupStore(keychain: keychain, persistsToDisk: false) + let group = try #require(store.createGroup(named: "bye", creator: makeMember(seed: 0xC1))) + + store.removeGroup(withID: group.groupID) + #expect(store.groups.isEmpty) + #expect(store.key(forGroupID: group.groupID) == nil) + } +} diff --git a/bitchatTests/Services/MeshDiagnosticsTests.swift b/bitchatTests/Services/MeshDiagnosticsTests.swift new file mode 100644 index 00000000..d716791f --- /dev/null +++ b/bitchatTests/Services/MeshDiagnosticsTests.swift @@ -0,0 +1,300 @@ +// +// MeshDiagnosticsTests.swift +// bitchatTests +// +// Tests for /ping and /trace command handling and the topology snapshot +// types backing the mesh topology map. +// This is free and unencumbered software released into the public domain. +// + +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +@Suite(.serialized) +struct MeshDiagnosticsTests { + + // MARK: - Helpers + + @MainActor + private func makeProcessor( + context: DiagnosticsMockContext, + transport: MockTransport + ) -> CommandProcessor { + CommandProcessor( + contextProvider: context, + meshService: transport, + identityManager: MockIdentityManager(MockKeychain()) + ) + } + + /// Waits for the async ping completion (MainActor hop) to land. + @MainActor + private func waitForCommandOutput(_ context: DiagnosticsMockContext) async { + for _ in 0..<100 { + if !context.commandOutputs.isEmpty { return } + await Task.yield() + try? await Task.sleep(nanoseconds: 5_000_000) + } + } + + // MARK: - /ping + + @MainActor + @Test func pingWithoutArgumentShowsUsage() { + let context = DiagnosticsMockContext() + let processor = makeProcessor(context: context, transport: MockTransport()) + let result = processor.process("/ping") + switch result { + case .error(let message): + #expect(message == "usage: /ping ") + default: + Issue.record("Expected usage error") + } + } + + @MainActor + @Test func pingUnknownPeerFails() { + let context = DiagnosticsMockContext() + let processor = makeProcessor(context: context, transport: MockTransport()) + let result = processor.process("/ping @ghost") + switch result { + case .error(let message): + #expect(message == "cannot ping ghost: not found on mesh") + default: + Issue.record("Expected error result") + } + } + + @MainActor + @Test func pingGeoDMPeerIsRejected() { + let context = DiagnosticsMockContext() + context.nicknameToPeerID["alice"] = PeerID(nostr_: "aabbccddeeff00112233445566778899") + let processor = makeProcessor(context: context, transport: MockTransport()) + let result = processor.process("/ping @alice") + switch result { + case .error(let message): + #expect(message == "cannot ping alice: not found on mesh") + default: + Issue.record("Expected error for geo peer") + } + } + + @MainActor + @Test func pingSuccessReportsRttAndHops() async { + let context = DiagnosticsMockContext() + let peerID = PeerID(str: "abcd1234abcd1234") + context.nicknameToPeerID["alice"] = peerID + let transport = MockTransport() + transport.meshPingResult = MeshPingResult(rttMs: 42, hops: 2) + let processor = makeProcessor(context: context, transport: transport) + + let result = processor.process("/ping @alice") + switch result { + case .success(let message): + #expect(message == "pinging alice…") + default: + Issue.record("Expected immediate 'pinging' feedback") + } + #expect(transport.sentMeshPings == [peerID]) + + await waitForCommandOutput(context) + #expect(context.commandOutputs == ["pong from alice: 42 ms · 2 hops"]) + } + + @MainActor + @Test func pingDirectPeerReportsSingleHop() async { + let context = DiagnosticsMockContext() + context.nicknameToPeerID["alice"] = PeerID(str: "abcd1234abcd1234") + let transport = MockTransport() + transport.meshPingResult = MeshPingResult(rttMs: 8, hops: 1) + let processor = makeProcessor(context: context, transport: transport) + + _ = processor.process("/ping alice") + await waitForCommandOutput(context) + #expect(context.commandOutputs == ["pong from alice: 8 ms · direct (1 hop)"]) + } + + @MainActor + @Test func pingTimeoutReportsNoReply() async { + let context = DiagnosticsMockContext() + context.nicknameToPeerID["alice"] = PeerID(str: "abcd1234abcd1234") + let transport = MockTransport() + transport.meshPingResult = nil + let processor = makeProcessor(context: context, transport: transport) + + _ = processor.process("/ping @alice") + await waitForCommandOutput(context) + #expect(context.commandOutputs == ["no reply from alice"]) + } + + @MainActor + @Test func pingOutputRoutesToConversationWhereCommandWasIssued() async { + let context = DiagnosticsMockContext() + let alice = PeerID(str: "abcd1234abcd1234") + let bob = PeerID(str: "b0b0b0b0b0b0b0b0") + context.nicknameToPeerID["alice"] = alice + let transport = MockTransport() + transport.meshPingResult = MeshPingResult(rttMs: 42, hops: 2) + let processor = makeProcessor(context: context, transport: transport) + + // Issue the ping from bob's DM, then switch chats before the async + // result lands. The output must follow the origin conversation, not + // whatever is selected at callback time. + context.selectedPrivateChatPeer = bob + _ = processor.process("/ping @alice") + context.selectedPrivateChatPeer = nil + + await waitForCommandOutput(context) + #expect(context.commandOutputDestinations == [.privateChat(bob)]) + } + + @MainActor + @Test func pingIssuedFromPublicTimelineRoutesToMeshTimeline() async { + let context = DiagnosticsMockContext() + let alice = PeerID(str: "abcd1234abcd1234") + context.nicknameToPeerID["alice"] = alice + let transport = MockTransport() + transport.meshPingResult = MeshPingResult(rttMs: 7, hops: 1) + let processor = makeProcessor(context: context, transport: transport) + + _ = processor.process("/ping @alice") + // Opening a DM afterwards must not swallow the public-timeline result. + context.selectedPrivateChatPeer = alice + + await waitForCommandOutput(context) + #expect(context.commandOutputDestinations == [.meshTimeline]) + } + + // MARK: - /trace + + @MainActor + @Test func traceDirectPeerShowsOneHop() { + let context = DiagnosticsMockContext() + let bob = PeerID(str: "b0b0b0b0b0b0b0b0") + context.nicknameToPeerID["bob"] = bob + let transport = MockTransport() + transport.meshPaths[bob] = [] + let processor = makeProcessor(context: context, transport: transport) + + let result = processor.process("/trace @bob") + switch result { + case .success(let message): + #expect(message == "estimated path: you → bob (1 hop)") + default: + Issue.record("Expected success result") + } + } + + @MainActor + @Test func traceMultiHopUsesNicknamesWithShortIDFallback() { + let context = DiagnosticsMockContext() + let bob = PeerID(str: "b0b0b0b0b0b0b0b0") + let alice = PeerID(str: "a11cea11cea11cea") + let unknown = PeerID(str: "dead00beef001234") + context.nicknameToPeerID["bob"] = bob + let transport = MockTransport() + transport.peerNicknames = [alice: "alice"] + transport.meshPaths[bob] = [alice, unknown] + let processor = makeProcessor(context: context, transport: transport) + + let result = processor.process("/trace bob") + switch result { + case .success(let message): + #expect(message == "estimated path: you → alice → dead00be… → bob (3 hops)") + default: + Issue.record("Expected success result") + } + } + + @MainActor + @Test func traceWithoutPathReportsNoKnownPath() { + let context = DiagnosticsMockContext() + context.nicknameToPeerID["bob"] = PeerID(str: "b0b0b0b0b0b0b0b0") + let processor = makeProcessor(context: context, transport: MockTransport()) + + let result = processor.process("/trace @bob") + switch result { + case .success(let message): + #expect(message == "no known path to bob") + default: + Issue.record("Expected success result") + } + } + + // MARK: - Topology snapshot types + + @Test func topologyEdgeNormalizesEndpointOrder() { + let a = PeerID(str: "aaaa000000000000") + let b = PeerID(str: "bbbb000000000000") + #expect(MeshTopologyEdge(a, b) == MeshTopologyEdge(b, a)) + #expect(Set([MeshTopologyEdge(a, b), MeshTopologyEdge(b, a)]).count == 1) + } + + @Test func topologyLayoutPlacesSelfInCenter() { + let nodes: [MeshTopologyDisplayModel.Node] = [ + .init(id: "self", label: "me", isSelf: true), + .init(id: "a", label: "alice", isSelf: false), + .init(id: "b", label: "bob", isSelf: false) + ] + let size = CGSize(width: 200, height: 200) + let positions = MeshTopologyView.layout(nodes: nodes, in: size) + + #expect(positions["self"] == CGPoint(x: 100, y: 100)) + #expect(positions.count == 3) + // Ring nodes sit on the same radius around the center. + let radiusA = hypot((positions["a"]?.x ?? 0) - 100, (positions["a"]?.y ?? 0) - 100) + let radiusB = hypot((positions["b"]?.x ?? 0) - 100, (positions["b"]?.y ?? 0) - 100) + #expect(abs(radiusA - radiusB) < 0.001) + #expect(radiusA > 0) + } +} + +/// Minimal CommandContextProvider for diagnostics tests; records deferred +/// command output so async /ping results can be asserted. +@MainActor +private final class DiagnosticsMockContext: CommandContextProvider { + var nickname: String = "tester" + var activeChannel: ChannelID = .mesh + var selectedPrivateChatPeer: PeerID? + var blockedUsers: Set = [] + let idBridge = NostrIdentityBridge(keychain: MockKeychain()) + + var nicknameToPeerID: [String: PeerID] = [:] + private(set) var commandOutputs: [String] = [] + private(set) var commandOutputDestinations: [CommandOutputDestination] = [] + + func getPeerIDForNickname(_ nickname: String) -> PeerID? { + nicknameToPeerID[nickname] + } + + func getVisibleGeoParticipants() -> [CommandGeoParticipant] { [] } + func nostrPubkeyForDisplayName(_ displayName: String) -> String? { nil } + func startPrivateChat(with peerID: PeerID) {} + func sendPrivateMessage(_ content: String, to peerID: PeerID) {} + func clearCurrentPublicTimeline() {} + func clearPrivateChat(_ peerID: PeerID) {} + func sendPublicRaw(_ content: String) {} + func sendPublicMessage(_ content: String) {} + func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {} + func addPublicSystemMessage(_ content: String) {} + func toggleFavorite(peerID: PeerID) {} + func groupCreate(named name: String) -> CommandResult { .handled } + func groupInvite(nickname: String) -> CommandResult { .handled } + func groupRemove(nickname: String) -> CommandResult { .handled } + func groupLeave() -> CommandResult { .handled } + func groupList() -> CommandResult { .handled } + + func currentCommandDestination() -> CommandOutputDestination { + if let peerID = selectedPrivateChatPeer { + return .privateChat(peerID) + } + return .meshTimeline + } + + func addCommandOutput(_ content: String, to destination: CommandOutputDestination) { + commandOutputs.append(content) + commandOutputDestinations.append(destination) + } +} diff --git a/bitchatTests/Services/MeshTopologyTrackerTests.swift b/bitchatTests/Services/MeshTopologyTrackerTests.swift index 1ae0f5f5..fa5d2a4d 100644 --- a/bitchatTests/Services/MeshTopologyTrackerTests.swift +++ b/bitchatTests/Services/MeshTopologyTrackerTests.swift @@ -94,10 +94,132 @@ struct MeshTopologyTrackerTests { tracker.updateNeighbors(for: a, neighbors: [b]) tracker.updateNeighbors(for: b, neighbors: [a]) - + // When start == end, route should be empty (no intermediate hops needed) let route = try #require(tracker.computeRoute(from: a, to: a)) #expect(route == []) } + @Test func noPathReturnsNil() throws { + let tracker = MeshTopologyTracker() + let a = try hex("0101010101010101") + let b = try hex("0202020202020202") + let c = try hex("0303030303030303") + let d = try hex("0404040404040404") + + // Two disconnected islands: A-B and C-D + tracker.updateNeighbors(for: a, neighbors: [b]) + tracker.updateNeighbors(for: b, neighbors: [a]) + tracker.updateNeighbors(for: c, neighbors: [d]) + tracker.updateNeighbors(for: d, neighbors: [c]) + + #expect(tracker.computeRoute(from: a, to: d) == nil) + } + + /// Build a confirmed line topology n0 - n1 - ... - n(count-1). + private func makeLine(_ tracker: MeshTopologyTracker, count: Int) throws -> [Data] { + let nodes = try (0.. 0 { neighbors.append(nodes[i - 1]) } + if i < count - 1 { neighbors.append(nodes[i + 1]) } + tracker.updateNeighbors(for: nodes[i], neighbors: neighbors) + } + return nodes + } + + @Test func maxHopsCapsIntermediateHopCount() throws { + let tracker = MeshTopologyTracker() + // 7 nodes: source + 5 intermediates + target + let nodes = try makeLine(tracker, count: 7) + + // 5 intermediates exceed a 4-hop cap + #expect(tracker.computeRoute(from: nodes[0], to: nodes[6], maxHops: 4) == nil) + // 4 intermediates fit exactly + let route = try #require(tracker.computeRoute(from: nodes[0], to: nodes[5], maxHops: 4)) + #expect(route == Array(nodes[1...4])) + } + + @Test func staleNeighborBlocksRoute() throws { + let tracker = MeshTopologyTracker() + let a = try hex("0101010101010101") + let b = try hex("0202020202020202") + let c = try hex("0303030303030303") + + let staleDate = Date().addingTimeInterval(-120) // past 60s freshness + tracker.updateNeighbors(for: a, neighbors: [b]) + tracker.updateNeighbors(for: b, neighbors: [a, c], at: staleDate) + tracker.updateNeighbors(for: c, neighbors: [b]) + + #expect(tracker.computeRoute(from: a, to: c) == nil) + + // Refreshing B restores the route. + tracker.updateNeighbors(for: b, neighbors: [a, c]) + let route = try #require(tracker.computeRoute(from: a, to: c)) + #expect(route == [b]) + } + + @Test func versionGateBlocksV1AndUnknownHops() throws { + let tracker = MeshTopologyTracker() + let a = try hex("0101010101010101") + let b = try hex("0202020202020202") + let c = try hex("0303030303030303") + + tracker.updateNeighbors(for: a, neighbors: [b]) + tracker.updateNeighbors(for: b, neighbors: [a, c]) + tracker.updateNeighbors(for: c, neighbors: [b]) + + // Without the gate the route exists. + #expect(tracker.computeRoute(from: a, to: c) == [b]) + // Version-unknown hops are assumed v1-only and block gated routes. + #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil) + + // A v1 observation does not unlock the gate. + tracker.recordObservedVersion(1, for: b) + tracker.recordObservedVersion(2, for: c) + #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil) + + // Once the hop is observed speaking v2 the route opens. + tracker.recordObservedVersion(2, for: b) + let route = try #require(tracker.computeRoute(from: a, to: c, requiringVersion: 2)) + #expect(route == [b]) + } + + @Test func versionGateRequiresV2Target() throws { + let tracker = MeshTopologyTracker() + let a = try hex("0101010101010101") + let b = try hex("0202020202020202") + let c = try hex("0303030303030303") + + tracker.updateNeighbors(for: a, neighbors: [b]) + tracker.updateNeighbors(for: b, neighbors: [a, c]) + tracker.updateNeighbors(for: c, neighbors: [b]) + tracker.recordObservedVersion(2, for: b) + + // The recipient must decode the v2 frame too. + #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil) + + tracker.recordObservedVersion(2, for: c) + #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == [b]) + } + + @Test func pruneDropsStaleObservedVersions() throws { + let tracker = MeshTopologyTracker() + let a = try hex("0101010101010101") + let b = try hex("0202020202020202") + let c = try hex("0303030303030303") + + tracker.updateNeighbors(for: a, neighbors: [b]) + tracker.updateNeighbors(for: b, neighbors: [a, c]) + tracker.updateNeighbors(for: c, neighbors: [b]) + let old = Date().addingTimeInterval(-120) + tracker.recordObservedVersion(2, for: b, at: old) + tracker.recordObservedVersion(2, for: c, at: old) + + #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == [b]) + tracker.prune(olderThan: 60) + // Claims are fresh but the version observations aged out. + #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil) + } + } diff --git a/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift b/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift new file mode 100644 index 00000000..6ab46490 --- /dev/null +++ b/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift @@ -0,0 +1,335 @@ +import Foundation +import Testing + +@testable import bitchat + +/// Vouch storage, accept-policy gates, derived trust levels, and persistence +/// compatibility for `SecureIdentityStateManager`. +/// +/// Ordering note: mutations use barrier blocks on the manager's concurrent +/// queue and reads use `queue.sync`, so a read submitted after a mutation +/// always observes it — no polling needed. +/// +/// `@MainActor` matches production (the manager's vouch API is driven by the +/// main-actor `ChatVouchCoordinator`) and keeps the blocking `queue.sync` +/// reads off the Swift Concurrency cooperative pool. Left nonisolated, Swift +/// Testing runs these tests in parallel on that pool, and on CI's few-core +/// runners every pool thread ended up parked in `queue.sync` behind a pending +/// `queue.async(.barrier)` write that never got a dispatch worker — a +/// process-wide deadlock (watchdog SIGKILL, exit 137). +@MainActor +struct SecureIdentityStateManagerVouchTests { + private let voucher = String(repeating: "0a", count: 32) + private let vouchee = String(repeating: "0b", count: 32) + + private func makeManager() -> SecureIdentityStateManager { + SecureIdentityStateManager(MockKeychain()) + } + + // MARK: - Accept-policy gates + + @Test + func recordVouch_rejectsUnverifiedVoucher() { + let manager = makeManager() + + #expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())) + #expect(manager.validVouchers(for: vouchee).isEmpty) + + manager.setVerified(fingerprint: voucher, verified: true) + #expect(manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())) + #expect(manager.validVouchers(for: vouchee).count == 1) + } + + @Test + func recordVouch_ignoresSelfVouch() { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + + #expect(!manager.recordVouch(voucheeFingerprint: voucher, voucherFingerprint: voucher, timestamp: Date())) + #expect(manager.validVouchers(for: voucher).isEmpty) + } + + @Test + func recordVouch_ignoresAlreadyVerifiedVouchee() { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + manager.setVerified(fingerprint: vouchee, verified: true) + + #expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())) + #expect(!manager.isVouched(fingerprint: vouchee)) + } + + @Test + func recordVouch_rejectsStaleAndFarFutureTimestamps() { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + + let stale = Date().addingTimeInterval(-31 * 24 * 60 * 60) + #expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: stale)) + + let farFuture = Date().addingTimeInterval(2 * 60 * 60) + #expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: farFuture)) + + #expect(manager.validVouchers(for: vouchee).isEmpty) + } + + @Test + func recordVouch_capsVouchersPerVoucheeKeepingMostRecent() { + let manager = makeManager() + let base = Date() + + // 9 verified vouchers vouch with strictly increasing timestamps. + let vouchers = (0..<9).map { String(format: "%02x", $0 + 0x10) + String(repeating: "00", count: 31) } + for (index, voucherFingerprint) in vouchers.enumerated() { + manager.setVerified(fingerprint: voucherFingerprint, verified: true) + let stored = manager.recordVouch( + voucheeFingerprint: vouchee, + voucherFingerprint: voucherFingerprint, + timestamp: base.addingTimeInterval(TimeInterval(index)), + now: base.addingTimeInterval(TimeInterval(index)) + ) + #expect(stored) + } + + let records = manager.validVouchers(for: vouchee) + #expect(records.count == SecureIdentityStateManager.maxVouchersPerVouchee) + // The oldest voucher fell off the end. + #expect(!records.contains { $0.voucherFingerprint == vouchers[0] }) + #expect(records.contains { $0.voucherFingerprint == vouchers[8] }) + + // An attestation older than everything retained is not stored. + let older = String(repeating: "0c", count: 32) + manager.setVerified(fingerprint: older, verified: true) + #expect(!manager.recordVouch( + voucheeFingerprint: vouchee, + voucherFingerprint: older, + timestamp: base.addingTimeInterval(-1), + now: base + )) + + // A repeat vouch from a retained voucher refreshes, not duplicates. + #expect(manager.recordVouch( + voucheeFingerprint: vouchee, + voucherFingerprint: vouchers[8], + timestamp: base.addingTimeInterval(100), + now: base.addingTimeInterval(100) + )) + #expect(manager.validVouchers(for: vouchee).count == SecureIdentityStateManager.maxVouchersPerVouchee) + } + + // MARK: - Derived trust & invalidation + + @Test + func unverifyingVoucher_invalidatesTheirVouchesWithoutDeletingThem() { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) + #expect(manager.isVouched(fingerprint: vouchee)) + + // Removing my verification of the voucher retires their vouches… + manager.setVerified(fingerprint: voucher, verified: false) + #expect(!manager.isVouched(fingerprint: vouchee)) + #expect(manager.validVouchers(for: vouchee).isEmpty) + + // …but the records survive: re-verifying the voucher restores them + // (recompute on read, no cascade delete). + manager.setVerified(fingerprint: voucher, verified: true) + #expect(manager.isVouched(fingerprint: vouchee)) + } + + @Test + func validVouchers_expireAtReadTime() { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + + let now = Date() + let timestamp = now.addingTimeInterval(-29 * 24 * 60 * 60) + #expect(manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: timestamp, now: now)) + #expect(manager.isVouched(fingerprint: vouchee, now: now)) + + let twoDaysLater = now.addingTimeInterval(2 * 24 * 60 * 60) + #expect(manager.validVouchers(for: vouchee, now: twoDaysLater).isEmpty) + #expect(!manager.isVouched(fingerprint: vouchee, now: twoDaysLater)) + } + + @Test + func effectiveTrustLevel_slotsVouchedBetweenCasualAndTrusted() { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + + // Unknown peer with a valid vouch reads as vouched. + #expect(manager.effectiveTrustLevel(for: vouchee) == .unknown) + manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) + #expect(manager.effectiveTrustLevel(for: vouchee) == .vouched) + + // Explicit trust outranks a vouch. + manager.updateSocialIdentity(SocialIdentity( + fingerprint: vouchee, + localPetname: nil, + claimedNickname: "bob", + trustLevel: .trusted, + isFavorite: false, + isBlocked: false, + notes: nil + )) + #expect(manager.effectiveTrustLevel(for: vouchee) == .trusted) + + // Explicit verification outranks everything. + manager.setVerified(fingerprint: vouchee, verified: true) + #expect(manager.effectiveTrustLevel(for: vouchee) == .verified) + #expect(!manager.isVouched(fingerprint: vouchee)) + + // Losing the voucher downgrades vouched back to the stored level. + manager.setVerified(fingerprint: vouchee, verified: false) + manager.setVerified(fingerprint: voucher, verified: false) + #expect(manager.effectiveTrustLevel(for: vouchee) == .casual) + } + + // MARK: - Exchange-policy state + + @Test + func mostRecentlyVerifiedFingerprints_ordersAndExcludes() { + let manager = makeManager() + let first = String(repeating: "01", count: 32) + let second = String(repeating: "02", count: 32) + let third = String(repeating: "03", count: 32) + manager.setVerified(fingerprint: first, verified: true) + manager.setVerified(fingerprint: second, verified: true) + manager.setVerified(fingerprint: third, verified: true) + + let ordered = manager.mostRecentlyVerifiedFingerprints(limit: 16, excluding: third) + #expect(ordered == [second, first]) + + let limited = manager.mostRecentlyVerifiedFingerprints(limit: 1, excluding: third) + #expect(limited == [second]) + } + + @Test + func vouchBatchSentAt_roundTrips() { + let manager = makeManager() + #expect(manager.lastVouchBatchSent(to: voucher) == nil) + + let sentAt = Date(timeIntervalSince1970: 1_700_000_000) + manager.markVouchBatchSent(to: voucher, at: sentAt) + #expect(manager.lastVouchBatchSent(to: voucher) == sentAt) + } + + @Test + func signingPublicKey_returnsAnnounceBoundKeyByFingerprint() async { + let manager = makeManager() + let signingKey = Data(repeating: 0x22, count: 32) + manager.upsertCryptographicIdentity( + fingerprint: voucher, + noisePublicKey: Data(repeating: 0x11, count: 32), + signingPublicKey: signingKey, + claimedNickname: nil + ) + + let stored = await waitUntil { manager.signingPublicKey(forFingerprint: voucher) == signingKey } + #expect(stored) + #expect(manager.signingPublicKey(forFingerprint: vouchee) == nil) + } + + // MARK: - Panic wipe + + @Test + func clearAllIdentityData_wipesVouchState() async { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) + manager.markVouchBatchSent(to: voucher, at: Date()) + #expect(manager.isVouched(fingerprint: vouchee)) + + manager.clearAllIdentityData() + + let wiped = await waitUntil { !manager.isVouched(fingerprint: vouchee) } + #expect(wiped) + #expect(manager.validVouchers(for: vouchee).isEmpty) + #expect(manager.lastVouchBatchSent(to: voucher) == nil) + #expect(manager.mostRecentlyVerifiedFingerprints(limit: 16, excluding: "").isEmpty) + } + + // MARK: - Persistence compatibility + + @Test + func trustLevelRawValuesAreStable() throws { + // Raw values are what's persisted; they must never change when cases + // are added mid-ladder. + #expect(TrustLevel.unknown.rawValue == "unknown") + #expect(TrustLevel.casual.rawValue == "casual") + #expect(TrustLevel.vouched.rawValue == "vouched") + #expect(TrustLevel.trusted.rawValue == "trusted") + #expect(TrustLevel.verified.rawValue == "verified") + + let legacy = Data(#"["unknown","casual","trusted","verified"]"#.utf8) + let decoded = try JSONDecoder().decode([TrustLevel].self, from: legacy) + #expect(decoded == [.unknown, .casual, .trusted, .verified]) + } + + @Test + func identityCachePersistedBeforeVouchingDecodesCleanly() throws { + // A cache captured before the vouch fields existed must decode without + // tripping the "unreadable cache" recovery path. + let legacyJSON = Data(""" + { + "socialIdentities": {}, + "nicknameIndex": {}, + "verifiedFingerprints": ["\(voucher)"], + "lastInteractions": {}, + "blockedNostrPubkeys": [], + "version": 1 + } + """.utf8) + + let decoded = try JSONDecoder().decode(IdentityCache.self, from: legacyJSON) + #expect(decoded.vouchesByVouchee == nil) + #expect(decoded.vouchBatchSentAt == nil) + #expect(decoded.verifiedAt == nil) + #expect(decoded.verifiedFingerprints == [voucher]) + } + + @Test + func identityCacheRoundTripsVouchState() throws { + var cache = IdentityCache() + cache.verifiedFingerprints = [voucher] + cache.vouchesByVouchee = [vouchee: [VouchRecord(voucherFingerprint: voucher, timestamp: Date(timeIntervalSince1970: 1_700_000_000))]] + cache.vouchBatchSentAt = [voucher: Date(timeIntervalSince1970: 1_700_000_001)] + cache.verifiedAt = [voucher: Date(timeIntervalSince1970: 1_700_000_002)] + + let decoded = try JSONDecoder().decode(IdentityCache.self, from: JSONEncoder().encode(cache)) + #expect(decoded.vouchesByVouchee == cache.vouchesByVouchee) + #expect(decoded.vouchBatchSentAt == cache.vouchBatchSentAt) + #expect(decoded.verifiedAt == cache.verifiedAt) + } + + @Test + func vouchStateSurvivesReload() async { + let keychain = MockKeychain() + let manager = SecureIdentityStateManager(keychain) + manager.setVerified(fingerprint: voucher, verified: true) + manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) + let saved = await waitUntil { manager.isVouched(fingerprint: self.vouchee) } + #expect(saved) + manager.forceSave() + + let reloaded = SecureIdentityStateManager(keychain) + #expect(reloaded.isVouched(fingerprint: vouchee)) + #expect(reloaded.validVouchers(for: vouchee).count == 1) + } + + // MARK: - Helpers + + private func waitUntil( + timeout: TimeInterval = 1.0, + condition: @escaping () -> Bool + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { + return true + } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return condition() + } +} diff --git a/bitchatTests/Services/UnifiedPeerServiceTests.swift b/bitchatTests/Services/UnifiedPeerServiceTests.swift index f5a5fd6c..172bf5b1 100644 --- a/bitchatTests/Services/UnifiedPeerServiceTests.swift +++ b/bitchatTests/Services/UnifiedPeerServiceTests.swift @@ -292,4 +292,37 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol { func getVerifiedFingerprints() -> Set { verified } + + // MARK: Vouching (unused by these tests) + + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool { + false + } + + func validVouchers(for fingerprint: String) -> [VouchRecord] { + [] + } + + func isVouched(fingerprint: String) -> Bool { + false + } + + func effectiveTrustLevel(for fingerprint: String) -> TrustLevel { + verified.contains(fingerprint) ? .verified : .unknown + } + + func lastVouchBatchSent(to fingerprint: String) -> Date? { + nil + } + + func markVouchBatchSent(to fingerprint: String, at date: Date) {} + + func signingPublicKey(forFingerprint fingerprint: String) -> Data? { + nil + } + + func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] { + [] + } } diff --git a/bitchatTests/Sync/GossipSyncBoardTests.swift b/bitchatTests/Sync/GossipSyncBoardTests.swift new file mode 100644 index 00000000..8676531a --- /dev/null +++ b/bitchatTests/Sync/GossipSyncBoardTests.swift @@ -0,0 +1,133 @@ +// +// GossipSyncBoardTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +/// Board posts ride gossip sync through a provider that queries the board +/// store, so retention (expiry, tombstones, caps) has a single owner. +struct GossipSyncBoardTests { + + private let myPeerID = PeerID(str: "0102030405060708") + + private func makeBoardPacket(timestamp: UInt64) throws -> BitchatPacket { + BitchatPacket( + type: MessageType.boardPost.rawValue, + senderID: try #require(Data(hexString: "aabbccddeeff0011")), + recipientID: nil, + timestamp: timestamp, + payload: Data([0x42]), + signature: nil, + ttl: 7 + ) + } + + private func quietConfig() -> GossipSyncManager.Config { + var config = GossipSyncManager.Config() + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + // Silence the prekey round (added by the prekeys feature; groupMessage + // rides the message schedule, already off) so board is isolated. + config.prekeyBundleSyncIntervalSeconds = 0 + return config + } + + @Test func boardRequestIsServedFromProvider() async throws { + let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager()) + let delegate = RecordingBoardDelegate() + manager.delegate = delegate + let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000)) + manager.boardPacketsProvider = { return [boardPacket] } + + let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board) + manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) + + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + let sent = try #require(delegate.packets.first) + #expect(sent.type == MessageType.boardPost.rawValue) + #expect(sent.isRSR) + } + + @Test func nonBoardRequestDoesNotServeBoardPackets() async throws { + let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager()) + let delegate = RecordingBoardDelegate() + manager.delegate = delegate + let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000)) + manager.boardPacketsProvider = { return [boardPacket] } + + let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .publicMessages) + manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) + + // Follow with a board request; only its response should arrive, which + // also proves the first request produced nothing. + let boardRequest = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board) + manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: boardRequest) + + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + #expect(delegate.packets.count == 1) + #expect(delegate.packets.first?.type == MessageType.boardPost.rawValue) + } + + @Test func maintenanceEmitsBoardRoundOnlyWithProvider() throws { + var config = quietConfig() + config.boardSyncIntervalSeconds = 1 + + // Without a provider the board schedule stays silent. + let unwired = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager()) + let unwiredDelegate = RecordingBoardDelegate() + unwired.delegate = unwiredDelegate + unwired._performMaintenanceSynchronously(now: Date()) + #expect(unwiredDelegate.packets.isEmpty) + + // With a provider, maintenance sends a board-typed request. + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager()) + let delegate = RecordingBoardDelegate() + manager.delegate = delegate + manager.boardPacketsProvider = { return [] } + manager._performMaintenanceSynchronously(now: Date()) + + #expect(delegate.packets.count == 1) + let payload = try #require(delegate.packets.first?.payload) + let request = try #require(RequestSyncPacket.decode(from: payload)) + #expect(request.types == .board) + } +} + +private final class RecordingBoardDelegate: GossipSyncManager.Delegate { + private let lock = NSLock() + private var _packets: [BitchatPacket] = [] + + var packets: [BitchatPacket] { + lock.lock() + defer { lock.unlock() } + return _packets + } + + func sendPacket(_ packet: BitchatPacket) { + lock.lock() + _packets.append(packet) + lock.unlock() + } + + func sendPacket(to peerID: PeerID, packet: BitchatPacket) { + lock.lock() + _packets.append(packet) + lock.unlock() + } + + func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket { + packet + } + + func getConnectedPeers() -> [PeerID] { + [] + } +} diff --git a/bitchatTests/Sync/RequestSyncPacketFragmentFilterTests.swift b/bitchatTests/Sync/RequestSyncPacketFragmentFilterTests.swift new file mode 100644 index 00000000..d055bde6 --- /dev/null +++ b/bitchatTests/Sync/RequestSyncPacketFragmentFilterTests.swift @@ -0,0 +1,76 @@ +// +// RequestSyncPacketFragmentFilterTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +struct RequestSyncPacketFragmentFilterTests { + + @Test func fragmentIdFilterRoundTripsThroughWireEncoding() throws { + let id1 = try #require(Data(hexString: "00112233445566aa")) + let id2 = try #require(Data(hexString: "ffeeddccbbaa9988")) + let filter = try #require(RequestSyncPacket.encodeFragmentIdFilter([id1, id2])) + + let packet = RequestSyncPacket(p: 7, m: 128, data: Data([0x01]), types: .fragment, fragmentIdFilter: filter) + let decoded = try #require(RequestSyncPacket.decode(from: packet.encode())) + + #expect(decoded.fragmentIdFilter == filter) + let ids = try #require(RequestSyncPacket.decodeFragmentIdFilter(decoded.fragmentIdFilter)) + #expect(ids == Set([id1, id2])) + } + + @Test func encodeCapsFilterAtMaxCountWithinDecoderBudget() throws { + let ids = (0..<100).map { i -> Data in + var id = Data(repeating: 0, count: 8) + id[7] = UInt8(i) + return id + } + let filter = try #require(RequestSyncPacket.encodeFragmentIdFilter(ids)) + + let tokens = filter.split(separator: ",") + #expect(tokens.count == RequestSyncPacket.maxFragmentIdFilterCount) + // 60 IDs * 17 bytes ("<16 hex>,") - 1 = 1019 ≤ the 1024-byte cap. + #expect(filter.utf8.count == 1019) + #expect(filter.utf8.count <= 1024) + } + + @Test func encodeDropsMalformedIDs() throws { + let good = try #require(Data(hexString: "0011223344556677")) + let short = Data([0x01, 0x02]) + let filter = try #require(RequestSyncPacket.encodeFragmentIdFilter([short, good])) + #expect(filter == good.hexEncodedString()) + #expect(RequestSyncPacket.encodeFragmentIdFilter([short]) == nil) + #expect(RequestSyncPacket.encodeFragmentIdFilter([]) == nil) + } + + @Test func decodeIgnoresMalformedTokens() throws { + let good = try #require(Data(hexString: "0011223344556677")) + let ids = try #require( + RequestSyncPacket.decodeFragmentIdFilter("zzzz,0011,0011223344556677,") + ) + #expect(ids == Set([good])) + #expect(RequestSyncPacket.decodeFragmentIdFilter(nil) == nil) + #expect(RequestSyncPacket.decodeFragmentIdFilter("not-hex") == nil) + } + + @Test func decoderIgnoresOversizedFilterValue() throws { + // Hand-roll a payload whose 0x06 TLV exceeds the acceptance cap; the + // request must still decode, with the filter dropped. + var payload = RequestSyncPacket(p: 7, m: 128, data: Data([0x01])).encode() + let oversized = Data(repeating: UInt8(ascii: "a"), count: 1025) + payload.append(0x06) + payload.append(UInt8((oversized.count >> 8) & 0xFF)) + payload.append(UInt8(oversized.count & 0xFF)) + payload.append(oversized) + + let decoded = try #require(RequestSyncPacket.decode(from: payload)) + #expect(decoded.fragmentIdFilter == nil) + } +} diff --git a/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift b/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift new file mode 100644 index 00000000..47623f8f --- /dev/null +++ b/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift @@ -0,0 +1,79 @@ +// +// SyncTypeFlagsBoardTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +/// The board sync flag is the first bit outside the original single byte of +/// type flags. These tests pin down the wire compatibility contract: the +/// types TLV has been a variable-length (1-8 byte) little-endian bitfield +/// since type-aware sync, so widening to two bytes must decode everywhere +/// and unknown bits must be ignored, not rejected. +struct SyncTypeFlagsBoardTests { + + @Test func boardFlagEncodesIntoSecondByte() throws { + let data = try #require(SyncTypeFlags.board.toData()) + // Little-endian: low byte first, board bit (bit 8) in byte 2. + #expect(data == Data([0x00, 0x01])) + } + + @Test func boardFlagRoundTrips() throws { + let flags = SyncTypeFlags(messageTypes: [.message, .boardPost]) + let data = try #require(flags.toData()) + let decoded = try #require(SyncTypeFlags.decode(data)) + #expect(decoded.contains(.message)) + #expect(decoded.contains(.boardPost)) + #expect(!decoded.contains(.fragment)) + #expect(Set(decoded.toMessageTypes()) == Set([.message, .boardPost])) + } + + /// An old decoder is modeled by bits it has no mapping for: the shared + /// decode path accepts the bytes and simply maps unknown bits to no + /// message type, so a board-only request reads as "nothing I can serve". + @Test func unknownBitsDecodeToNoTypes() throws { + // Bits 11-15 are unassigned after the feature integration (bit 8 is + // board, 9 prekey, 10 group); a future (or unknown) two-byte bitfield + // must decode without error and yield no known types. + let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xF8]))) + #expect(decoded.toMessageTypes().isEmpty) + for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost] { + #expect(!decoded.contains(type)) + } + } + + @Test func mixedKnownAndUnknownBitsKeepKnownTypes() throws { + // Known low-byte flags survive alongside unknown high bits (11-15). + let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xF8]))) + #expect(decoded.contains(.announce)) + #expect(decoded.contains(.message)) + #expect(Set(decoded.toMessageTypes()) == Set([.announce, .message])) + } + + @Test func requestSyncPacketRoundTripsBoardFlag() throws { + let request = RequestSyncPacket( + p: 4, + m: 128, + data: Data([0xAB, 0xCD]), + types: SyncTypeFlags(messageTypes: [.boardPost]) + ) + let decoded = try #require(RequestSyncPacket.decode(from: request.encode())) + let types = try #require(decoded.types) + #expect(types.contains(.boardPost)) + #expect(!types.contains(.message)) + } + + @Test func singleByteLegacyEncodingStillDecodes() throws { + // Requests from old clients keep the one-byte bitfield. + let decoded = try #require(SyncTypeFlags.decode(Data([0x03]))) + #expect(decoded.contains(.announce)) + #expect(decoded.contains(.message)) + #expect(!decoded.contains(.boardPost)) + } +} diff --git a/bitchatTests/Sync/SyncTypeFlagsGroupTests.swift b/bitchatTests/Sync/SyncTypeFlagsGroupTests.swift new file mode 100644 index 00000000..2bd9d53b --- /dev/null +++ b/bitchatTests/Sync/SyncTypeFlagsGroupTests.swift @@ -0,0 +1,62 @@ +// +// SyncTypeFlagsGroupTests.swift +// bitchat +// +// Wire-compat proof for the groupMessage sync bit (bit 10): the types +// bitfield widens from 1 to 2 bytes, and clients that don't know the bit +// simply ignore it. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +struct SyncTypeFlagsGroupTests { + + @Test func groupMessageOccupiesBitTen() { + #expect(SyncTypeFlags.groupMessage.rawValue == 1 << 10) + #expect(SyncTypeFlags.groupMessage.contains(.groupMessage)) + #expect(!SyncTypeFlags.publicMessages.contains(.groupMessage)) + } + + @Test func extendedBitfieldWidensToTwoBytes() throws { + // Legacy flags fit one byte… + #expect(SyncTypeFlags.publicMessages.toData() == Data([0x03])) + + // …the group bit widens the little-endian encoding to two bytes. + let combined = SyncTypeFlags.publicMessages.union(.groupMessage) + let encoded = try #require(combined.toData()) + #expect(encoded == Data([0x03, 0x04])) + + let decoded = try #require(SyncTypeFlags.decode(encoded)) + #expect(decoded == combined) + #expect(Set(decoded.toMessageTypes()) == Set([.announce, .message, .groupMessage])) + } + + @Test func unknownBitsAreIgnoredNotRejected() throws { + // An "old client" reading a 2-byte field keeps the raw bits but maps + // unknown bit indices to no message type — it answers with the types + // it knows instead of dropping the request. + let futuristic = try #require(SyncTypeFlags.decode(Data([0x03, 0xFC]))) + #expect(Set(futuristic.toMessageTypes()) == Set([.announce, .message, .groupMessage])) + #expect(futuristic.contains(.announce)) + #expect(futuristic.contains(.message)) + } + + @Test func requestSyncPacketRoundTripsGroupFlag() throws { + let types = SyncTypeFlags.publicMessages.union(.groupMessage) + let packet = RequestSyncPacket(p: 8, m: 1024, data: Data([0xAB, 0xCD]), types: types) + let encoded = packet.encode() + + let decoded = try #require(RequestSyncPacket.decode(from: encoded)) + #expect(decoded.types == types) + #expect(decoded.types?.contains(.groupMessage) == true) + #expect(decoded.p == 8) + #expect(decoded.m == 1024) + #expect(decoded.data == Data([0xAB, 0xCD])) + } +} diff --git a/bitchatTests/Sync/SyncTypeFlagsTests.swift b/bitchatTests/Sync/SyncTypeFlagsTests.swift index a3ab3cc4..15e33f4b 100644 --- a/bitchatTests/Sync/SyncTypeFlagsTests.swift +++ b/bitchatTests/Sync/SyncTypeFlagsTests.swift @@ -13,17 +13,19 @@ struct SyncTypeFlagsTests { } @Test func decodeDropsPhantomBits() { - // Bits 8+ map to no message type. They must not survive decode as + // Bits 11+ map to no message type (bits 8/9/10 are board/prekey/group + // after the feature integration). They must not survive decode as // phantom membership. - let phantom = Data([0x00, 0xFF]) // bits 8..15 set, no known type + let phantom = Data([0x00, 0xF8]) // bits 11..15 set, no known type let decoded = SyncTypeFlags.decode(phantom) #expect(decoded?.rawValue == 0) #expect(decoded?.toMessageTypes().isEmpty == true) } @Test func phantomBitsAreStrippedButKnownBitsSurvive() { - // Low byte = announce(0) + message(1); high byte = phantom. - let mixed = Data([0b0000_0011, 0xFF]) + // Low byte = announce(0) + message(1); high byte = phantom (bits 11+, + // which map to no known type). + let mixed = Data([0b0000_0011, 0xF8]) let decoded = SyncTypeFlags.decode(mixed) #expect(decoded?.contains(.announce) == true) #expect(decoded?.contains(.message) == true) @@ -33,11 +35,12 @@ struct SyncTypeFlagsTests { @Test func rawValueInitNormalizesPhantomBits() { let flags = SyncTypeFlags(rawValue: 0xFFFF_FFFF_FFFF_FFFF) - // Every known type bit is set; nothing above them survives, so the - // field serializes to a single byte. + // Every known type bit is set; nothing above them survives. The + // highest known bit is 10 (groupMessage), so the field serializes to + // two bytes. #expect(flags.contains(.announce)) #expect(flags.contains(.fileTransfer)) let data = flags.toData() - #expect(data?.count == 1) + #expect(data?.count == 2) } } diff --git a/bitchatTests/WifiBulk/WifiBulkCryptoTests.swift b/bitchatTests/WifiBulk/WifiBulkCryptoTests.swift new file mode 100644 index 00000000..adc93142 --- /dev/null +++ b/bitchatTests/WifiBulk/WifiBulkCryptoTests.swift @@ -0,0 +1,264 @@ +// +// WifiBulkCryptoTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import CryptoKit +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("WifiBulk channel crypto") +struct WifiBulkCryptoTests { + private static func keyHex(_ key: SymmetricKey) -> String { + key.withUnsafeBytes { Data($0).map { String(format: "%02x", $0) }.joined() } + } + + private func makeKey() throws -> SymmetricKey { + try #require(WifiBulkCrypto.deriveKey( + senderToken: Data(repeating: 0x11, count: 32), + receiverToken: Data(repeating: 0x22, count: 32), + transferID: Data(repeating: 0x33, count: 16) + )) + } + + // MARK: HKDF derivation + + @Test("HKDF derivation matches fixed vectors") + func hkdfVectors() throws { + // Vectors computed independently with CryptoKit HKDF, + // ikm = senderToken ‖ receiverToken, salt = transferID, + // info = "bitchat-bulk-v1", 32 bytes out. + let key1 = try makeKey() + #expect(Self.keyHex(key1) == "9ee6f4bf7753a8a9564d6760b7064e31657f1a6bcca2b3ff266bb975cc4f66eb") + + let key2 = try #require(WifiBulkCrypto.deriveKey( + senderToken: Data((0..<32).map { UInt8($0) }), + receiverToken: Data((0..<32).map { UInt8(255 - $0) }), + transferID: Data((0..<16).map { UInt8($0 * 3) }) + )) + #expect(Self.keyHex(key2) == "432ebb559f2f546d632a91d53b5c25af36f15d1ba53917910a0041329dc0efd4") + } + + @Test("HKDF derivation is order- and role-sensitive") + func hkdfRoleSensitivity() throws { + let a = Data(repeating: 0x11, count: 32) + let b = Data(repeating: 0x22, count: 32) + let tid = Data(repeating: 0x33, count: 16) + let forward = try #require(WifiBulkCrypto.deriveKey(senderToken: a, receiverToken: b, transferID: tid)) + let reversed = try #require(WifiBulkCrypto.deriveKey(senderToken: b, receiverToken: a, transferID: tid)) + #expect(Self.keyHex(forward) != Self.keyHex(reversed)) + } + + @Test("HKDF derivation rejects wrong-length inputs") + func hkdfRejectsBadLengths() { + let token = Data(repeating: 1, count: 32) + let tid = Data(repeating: 2, count: 16) + #expect(WifiBulkCrypto.deriveKey(senderToken: Data(count: 31), receiverToken: token, transferID: tid) == nil) + #expect(WifiBulkCrypto.deriveKey(senderToken: token, receiverToken: Data(count: 33), transferID: tid) == nil) + #expect(WifiBulkCrypto.deriveKey(senderToken: token, receiverToken: token, transferID: Data(count: 15)) == nil) + } + + // MARK: Frame sealing + + @Test("Frame seal/open round-trips") + func frameRoundTrip() throws { + let key = try makeKey() + let plaintext = Data((0..<1000).map { UInt8($0 % 251) }) + let body = try WifiBulkCrypto.sealFrameBody(plaintext, direction: .senderToReceiver, counter: 7, key: key) + let opened = try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 7, key: key) + #expect(opened == plaintext) + } + + @Test("Tampered frames are rejected") + func tamperRejection() throws { + let key = try makeKey() + var body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 0, key: key) + body[body.count - 1] ^= 0x01 // flip a tag bit + #expect(throws: WifiBulkCryptoError.authenticationFailed) { + try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 0, key: key) + } + } + + @Test("Frames cannot be replayed at another counter or reflected across directions") + func nonceBinding() throws { + let key = try makeKey() + let body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 3, key: key) + #expect(throws: WifiBulkCryptoError.nonceMismatch) { + try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 4, key: key) + } + #expect(throws: WifiBulkCryptoError.nonceMismatch) { + try WifiBulkCrypto.openFrameBody(body, direction: .receiverToSender, counter: 3, key: key) + } + } + + @Test("Frames sealed under a different key are rejected") + func wrongKeyRejection() throws { + let key = try makeKey() + let otherKey = try #require(WifiBulkCrypto.deriveKey( + senderToken: Data(repeating: 0x44, count: 32), + receiverToken: Data(repeating: 0x22, count: 32), + transferID: Data(repeating: 0x33, count: 16) + )) + let body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 0, key: otherKey) + #expect(throws: WifiBulkCryptoError.authenticationFailed) { + try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 0, key: key) + } + } + + @Test("Auth and receipt control frames validate and reject forgeries") + func controlFrames() throws { + let key = try makeKey() + let transferID = Data(repeating: 0x33, count: 16) + let hash = Data(repeating: 0x55, count: 32) + + let auth = try WifiBulkCrypto.makeClientAuthFrameBody(transferID: transferID, key: key) + #expect(WifiBulkCrypto.validateClientAuthFrameBody(auth, transferID: transferID, key: key)) + #expect(!WifiBulkCrypto.validateClientAuthFrameBody(auth, transferID: Data(repeating: 0x34, count: 16), key: key)) + var forgedAuth = auth + forgedAuth[forgedAuth.count - 1] ^= 0x01 + #expect(!WifiBulkCrypto.validateClientAuthFrameBody(forgedAuth, transferID: transferID, key: key)) + + let receipt = try WifiBulkCrypto.makeReceiptFrameBody(payloadHash: hash, key: key) + #expect(WifiBulkCrypto.validateReceiptFrameBody(receipt, payloadHash: hash, key: key)) + #expect(!WifiBulkCrypto.validateReceiptFrameBody(receipt, payloadHash: Data(repeating: 0x56, count: 32), key: key)) + // An auth frame is not a receipt (distinct counter). + #expect(!WifiBulkCrypto.validateReceiptFrameBody(auth, payloadHash: hash, key: key)) + } + + // MARK: Frame buffer + + @Test("Frame buffer reassembles frames from arbitrary byte boundaries") + func frameBufferReassembly() throws { + let bodyA = Data(repeating: 0xAA, count: 100) + let bodyB = Data(repeating: 0xBB, count: 5) + var stream = WifiBulkCrypto.frameData(body: bodyA) + stream.append(WifiBulkCrypto.frameData(body: bodyB)) + + let buffer = WifiBulkFrameBuffer(maxBodyBytes: 1024) + // Drip-feed 3 bytes at a time. + var extracted: [Data] = [] + var index = stream.startIndex + while index < stream.endIndex { + let next = stream.index(index, offsetBy: 3, limitedBy: stream.endIndex) ?? stream.endIndex + buffer.append(Data(stream[index.. [Data] { + try stride(from: 0, to: payload.count, by: chunkSize).enumerated().map { index, offset in + try WifiBulkCrypto.sealFrameBody( + Data(payload[offset.. +// + +import CryptoKit +import BitFoundation +import Foundation +import Network +import Testing +@testable import bitchat + +/// End-to-end integration over real Network.framework sockets on localhost: +/// two `WifiBulkTransferService` instances negotiate through an in-process +/// "Noise" pipe, then move the payload over a genuine TCP connection using +/// the production listener/browser-free test hooks (peer-to-peer and Bonjour +/// are disabled — unit-test hosts have no AWDL or mDNS access). +@Suite("WifiBulk loopback integration", .serialized) +struct WifiBulkLoopbackTests { + private static let senderPeer = PeerID(str: "00112233aabbccdd") + private static let receiverPeer = PeerID(str: "ddccbbaa33221100") + + private func makeConfig() -> WifiBulkTransferServiceConfig { + var config = WifiBulkTransferServiceConfig() + config.usePeerToPeer = false + config.publishBonjourService = false + config.offerTimeout = 5.0 + config.transferWindow = 10.0 + return config + } + + @Test("Payload crosses a real TCP loopback channel and lands verified") + func loopbackTransferSucceeds() async throws { + let payload = Data((0..<300_000).map { UInt8($0 % 249) }) + + let delivered = WifiBulkTestBox() + let progress = WifiBulkTestBox() + let fallbacks = WifiBulkTestBox() + let ports = WifiBulkTestBox<(Data, UInt16)>() + let offers = WifiBulkTestBox() + + var senderService: WifiBulkTransferService? + var receiverService: WifiBulkTransferService? + + // Once the receiver has accepted AND the listener port is known, + // connect the receiver straight to 127.0.0.1 (Bonjour stand-in). + let accepted = WifiBulkTestBox() + let connectIfReady: () -> Void = { + guard let (transferID, port) = ports.values.first, + accepted.values.contains(transferID), + let nwPort = NWEndpoint.Port(rawValue: port) else { return } + receiverService?._test_connectIncoming( + transferID: transferID, + to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort) + ) + } + + let senderEnv = WifiBulkTransferServiceEnvironment( + sendNoisePayload: { typed, _ in + // Sender → receiver control plane (offer). + guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue else { return true } + offers.append(Data(typed.dropFirst())) + receiverService?.handleOfferPayload(Data(typed.dropFirst()), from: Self.senderPeer) + return true + }, + isPeerConnected: { _ in true }, + deliverReceivedFile: { _, _, _ in }, + progressStart: { id, total in progress.append("start:\(id):\(total)") }, + progressChunkSent: { id in progress.append("chunk:\(id)") }, + progressReset: { id in progress.append("reset:\(id)") }, + progressCancel: { id in progress.append("cancel:\(id)") } + ) + let receiverEnv = WifiBulkTransferServiceEnvironment( + sendNoisePayload: { typed, _ in + // Receiver → sender control plane (response). + guard typed.first == NoisePayloadType.bulkTransferResponse.rawValue else { return true } + let body = Data(typed.dropFirst()) + if let response = WifiBulkResponse.decode(body), response.accepted { + accepted.append(response.transferID) + } + senderService?.handleResponsePayload(body, from: Self.receiverPeer) + connectIfReady() + return true + }, + isPeerConnected: { _ in true }, + deliverReceivedFile: { data, peer, limit in + #expect(peer == Self.senderPeer) + #expect(limit == FileTransferLimits.maxWifiBulkPayloadBytes) + delivered.append(data) + }, + progressStart: { _, _ in }, + progressChunkSent: { _ in }, + progressReset: { _ in }, + progressCancel: { _ in } + ) + + let sender = WifiBulkTransferService(environment: senderEnv, config: makeConfig()) + sender._test_onListenerReady = { transferID, port in + ports.append((transferID, port)) + connectIfReady() + } + let receiver = WifiBulkTransferService(environment: receiverEnv, config: makeConfig()) + senderService = sender + receiverService = receiver + + sender.sendFile(payload: payload, to: Self.receiverPeer, transferId: "t-loopback") { + fallbacks.append(true) + } + + #expect(await wifiBulkWait(timeout: 10.0) { delivered.count == 1 }) + #expect(delivered.values.first == payload) + #expect(fallbacks.count == 0) + + // Deterministic teardown: both sides drop all transfer state. + #expect(await wifiBulkWait { sender._test_activeOutgoingCount == 0 }) + #expect(await wifiBulkWait { receiver._test_activeIncomingCount == 0 }) + + // Progress mirrored the BLE contract: start with the chunk total, + // then exactly `total` chunk ticks (the last one gated on the receipt). + let totalChunks = (payload.count + TransportConfig.wifiBulkChunkBytes - 1) / TransportConfig.wifiBulkChunkBytes + #expect(await wifiBulkWait { progress.values.filter { $0 == "chunk:t-loopback" }.count == totalChunks }) + #expect(progress.values.first == "start:t-loopback:\(totalChunks)") + #expect(!progress.values.contains("reset:t-loopback")) + } + + @Test("A gatecrasher without the channel key is disconnected and the real peer still succeeds") + func gatecrasherIsRejected() async throws { + let payload = Data((0..<150_000).map { UInt8($0 % 241) }) + + let delivered = WifiBulkTestBox() + let fallbacks = WifiBulkTestBox() + let ports = WifiBulkTestBox<(Data, UInt16)>() + let accepted = WifiBulkTestBox() + + var senderService: WifiBulkTransferService? + var receiverService: WifiBulkTransferService? + + let gatecrashed = WifiBulkTestBox() + let connectIfReady: () -> Void = { + guard let (transferID, port) = ports.values.first, + accepted.values.contains(transferID), + gatecrashed.count > 0, + let nwPort = NWEndpoint.Port(rawValue: port) else { return } + receiverService?._test_connectIncoming( + transferID: transferID, + to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort) + ) + } + + let senderEnv = WifiBulkTransferServiceEnvironment( + sendNoisePayload: { typed, _ in + guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue else { return true } + receiverService?.handleOfferPayload(Data(typed.dropFirst()), from: Self.senderPeer) + return true + }, + isPeerConnected: { _ in true }, + deliverReceivedFile: { _, _, _ in }, + progressStart: { _, _ in }, + progressChunkSent: { _ in }, + progressReset: { _ in }, + progressCancel: { _ in } + ) + let receiverEnv = WifiBulkTransferServiceEnvironment( + sendNoisePayload: { typed, _ in + guard typed.first == NoisePayloadType.bulkTransferResponse.rawValue else { return true } + let body = Data(typed.dropFirst()) + if let response = WifiBulkResponse.decode(body), response.accepted { + accepted.append(response.transferID) + } + senderService?.handleResponsePayload(body, from: Self.receiverPeer) + connectIfReady() + return true + }, + isPeerConnected: { _ in true }, + deliverReceivedFile: { data, _, _ in delivered.append(data) }, + progressStart: { _, _ in }, + progressChunkSent: { _ in }, + progressReset: { _ in }, + progressCancel: { _ in } + ) + + let sender = WifiBulkTransferService(environment: senderEnv, config: makeConfig()) + let gateQueue = DispatchQueue(label: "test.gatecrasher") + sender._test_onListenerReady = { transferID, port in + ports.append((transferID, port)) + guard let nwPort = NWEndpoint.Port(rawValue: port) else { return } + // A stranger who saw the Bonjour advertisement connects first and + // sends garbage that cannot carry a valid MAC. + let crasher = NWConnection( + to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort), + using: .tcp + ) + crasher.stateUpdateHandler = { state in + if case .ready = state { + let junkBody = Data(repeating: 0xAA, count: 60) + crasher.send( + content: WifiBulkCrypto.frameData(body: junkBody), + completion: .contentProcessed { _ in + gatecrashed.append(true) + connectIfReady() + } + ) + } + } + crasher.start(queue: gateQueue) + } + let receiver = WifiBulkTransferService(environment: receiverEnv, config: makeConfig()) + senderService = sender + receiverService = receiver + + sender.sendFile(payload: payload, to: Self.receiverPeer, transferId: "t-gatecrash") { + fallbacks.append(true) + } + + #expect(await wifiBulkWait(timeout: 10.0) { delivered.count == 1 }) + #expect(delivered.values.first == payload) + #expect(fallbacks.count == 0) + #expect(await wifiBulkWait { sender._test_activeOutgoingCount == 0 }) + } + + @Test("Receiver vanishing mid-negotiation leaves the sender to time out into BLE") + func vanishedReceiverFallsBack() async { + var config = makeConfig() + config.offerTimeout = 0.3 + + let fallbacks = WifiBulkTestBox() + let environment = WifiBulkTransferServiceEnvironment( + sendNoisePayload: { _, _ in true }, // offer sent, receiver never answers + isPeerConnected: { _ in true }, + deliverReceivedFile: { _, _, _ in }, + progressStart: { _, _ in }, + progressChunkSent: { _ in }, + progressReset: { _ in }, + progressCancel: { _ in } + ) + let sender = WifiBulkTransferService(environment: environment, config: config) + sender.sendFile(payload: Data(repeating: 1, count: 100_000), to: Self.receiverPeer, transferId: "t-vanish") { + fallbacks.append(true) + } + #expect(await wifiBulkWait { fallbacks.count == 1 }) + #expect(sender._test_activeOutgoingCount == 0) + } +} diff --git a/bitchatTests/WifiBulk/WifiBulkMessagesTests.swift b/bitchatTests/WifiBulk/WifiBulkMessagesTests.swift new file mode 100644 index 00000000..1cc92657 --- /dev/null +++ b/bitchatTests/WifiBulk/WifiBulkMessagesTests.swift @@ -0,0 +1,107 @@ +// +// WifiBulkMessagesTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("WifiBulk offer/response TLV") +struct WifiBulkMessagesTests { + private func makeOffer( + fileSize: UInt64 = 300_000, + serviceName: String = "a1b2c3d4e5f60718a1b2c3d4e5f60718" + ) -> WifiBulkOffer { + WifiBulkOffer( + transferID: Data(repeating: 0xAB, count: WifiBulkWire.transferIDLength), + fileSize: fileSize, + payloadHash: Data(repeating: 0xCD, count: WifiBulkWire.hashLength), + token: Data(repeating: 0xEF, count: WifiBulkWire.tokenLength), + serviceName: serviceName + ) + } + + @Test("Offer round-trips through TLV encoding") + func offerRoundTrip() throws { + let offer = makeOffer() + let encoded = try #require(offer.encode()) + let decoded = try #require(WifiBulkOffer.decode(encoded)) + #expect(decoded == offer) + } + + @Test("Offer encode rejects malformed field lengths") + func offerEncodeRejectsBadFields() { + #expect(WifiBulkOffer( + transferID: Data(repeating: 1, count: 15), // short transferID + fileSize: 1, + payloadHash: Data(repeating: 2, count: 32), + token: Data(repeating: 3, count: 32), + serviceName: "x" + ).encode() == nil) + + #expect(makeOffer(serviceName: "").encode() == nil) + #expect(makeOffer(serviceName: String(repeating: "a", count: 64)).encode() == nil) + } + + @Test("Offer decode rejects missing or wrong-length fields") + func offerDecodeRejectsMalformed() throws { + let encoded = try #require(makeOffer().encode()) + + // Truncation anywhere breaks a TLV boundary or drops a required field. + #expect(WifiBulkOffer.decode(encoded.dropLast(1)) == nil) + #expect(WifiBulkOffer.decode(encoded.prefix(3)) == nil) + #expect(WifiBulkOffer.decode(Data()) == nil) + + // A wrong-length transferID TLV is ignored, leaving the field missing. + var mangled = Data([0x01, 0x00, 0x02, 0xAA, 0xBB]) // transferID of 2 bytes + mangled.append(encoded.dropFirst(3 + WifiBulkWire.transferIDLength)) + #expect(WifiBulkOffer.decode(mangled) == nil) + } + + @Test("Offer decode skips unknown TLVs for forward compatibility") + func offerDecodeSkipsUnknownTLVs() throws { + var encoded = try #require(makeOffer().encode()) + encoded.append(contentsOf: [0x7F, 0x00, 0x03, 0x01, 0x02, 0x03]) // unknown type 0x7F + let decoded = try #require(WifiBulkOffer.decode(encoded)) + #expect(decoded == makeOffer()) + } + + @Test("Accept response round-trips with token") + func acceptResponseRoundTrip() throws { + let response = WifiBulkResponse.accept( + transferID: Data(repeating: 0x11, count: WifiBulkWire.transferIDLength), + token: Data(repeating: 0x22, count: WifiBulkWire.tokenLength) + ) + let encoded = try #require(response.encode()) + let decoded = try #require(WifiBulkResponse.decode(encoded)) + #expect(decoded == response) + #expect(decoded.accepted) + #expect(decoded.token?.count == WifiBulkWire.tokenLength) + } + + @Test("Decline response round-trips without token") + func declineResponseRoundTrip() throws { + let response = WifiBulkResponse.decline( + transferID: Data(repeating: 0x11, count: WifiBulkWire.transferIDLength) + ) + let encoded = try #require(response.encode()) + let decoded = try #require(WifiBulkResponse.decode(encoded)) + #expect(decoded == response) + #expect(!decoded.accepted) + #expect(decoded.token == nil) + } + + @Test("Accept response without a token is rejected") + func acceptWithoutTokenRejected() throws { + // Hand-build: transferID + accepted=1, no token TLV. + var data = Data() + WifiBulkWire.appendTLV(0x01, value: Data(repeating: 0x11, count: 16), into: &data) + WifiBulkWire.appendTLV(0x02, value: Data([1]), into: &data) + #expect(WifiBulkResponse.decode(data) == nil) + } +} diff --git a/bitchatTests/WifiBulk/WifiBulkPolicyTests.swift b/bitchatTests/WifiBulk/WifiBulkPolicyTests.swift new file mode 100644 index 00000000..82a6c512 --- /dev/null +++ b/bitchatTests/WifiBulk/WifiBulkPolicyTests.swift @@ -0,0 +1,150 @@ +// +// WifiBulkPolicyTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("WifiBulk policy") +struct WifiBulkPolicyTests { + private func candidate( + payloadBytes: Int = 300_000, + capabilities: PeerCapabilities = [.wifiBulk], + direct: Bool = true, + session: Bool = true + ) -> WifiBulkPolicy.SendCandidate { + WifiBulkPolicy.SendCandidate( + payloadBytes: payloadBytes, + peerCapabilities: capabilities, + isDirectlyConnected: direct, + hasEstablishedNoiseSession: session + ) + } + + @Test("Eligible large transfer to a direct wifiBulk peer is offered") + func eligibleTransferOffered() { + #expect(WifiBulkPolicy.shouldOffer(candidate(), enabled: true)) + } + + @Test("Fallback matrix: every failed gate keeps the transfer on BLE") + func fallbackMatrix() { + // Feature disabled. + #expect(!WifiBulkPolicy.shouldOffer(candidate(), enabled: false)) + // Small file: negotiation overhead not worth it. + #expect(!WifiBulkPolicy.shouldOffer(candidate(payloadBytes: 64 * 1024), enabled: true)) + // Peer doesn't advertise the capability. + #expect(!WifiBulkPolicy.shouldOffer(candidate(capabilities: []), enabled: true)) + #expect(!WifiBulkPolicy.shouldOffer(candidate(capabilities: [.prekeys, .gateway]), enabled: true)) + // Multi-hop recipient (reachable but not directly connected). + #expect(!WifiBulkPolicy.shouldOffer(candidate(direct: false), enabled: true)) + // No established Noise session to carry the offer. + #expect(!WifiBulkPolicy.shouldOffer(candidate(session: false), enabled: true)) + // Payload beyond even the Wi-Fi ceiling. + #expect(!WifiBulkPolicy.shouldOffer( + candidate(payloadBytes: FileTransferLimits.maxWifiBulkPayloadBytes + 1), + enabled: true + )) + } + + @Test("Offer threshold is strictly greater than the minimum") + func offerThresholdBoundary() { + #expect(!WifiBulkPolicy.shouldOffer( + candidate(payloadBytes: TransportConfig.wifiBulkMinPayloadBytes), + enabled: true + )) + #expect(WifiBulkPolicy.shouldOffer( + candidate(payloadBytes: TransportConfig.wifiBulkMinPayloadBytes + 1), + enabled: true + )) + } + + private func offer(fileSize: UInt64) -> WifiBulkOffer { + WifiBulkOffer( + transferID: Data(repeating: 1, count: WifiBulkWire.transferIDLength), + fileSize: fileSize, + payloadHash: Data(repeating: 2, count: WifiBulkWire.hashLength), + token: Data(repeating: 3, count: WifiBulkWire.tokenLength), + serviceName: "0011223344556677" + ) + } + + @Test("Receiver accepts an in-cap offer from a direct peer") + func receiverAccepts() { + #expect(WifiBulkPolicy.shouldAccept( + offer: offer(fileSize: 1_000_000), + senderIsDirectlyConnected: true, + activeIncomingTransfers: 0, + enabled: true + )) + } + + @Test("Receiver enforces its own size cap, not the sender's word") + func receiverSizeCap() { + #expect(!WifiBulkPolicy.shouldAccept( + offer: offer(fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1), + senderIsDirectlyConnected: true, + activeIncomingTransfers: 0, + enabled: true + )) + #expect(WifiBulkPolicy.shouldAccept( + offer: offer(fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes)), + senderIsDirectlyConnected: true, + activeIncomingTransfers: 0, + enabled: true + )) + #expect(!WifiBulkPolicy.shouldAccept( + offer: offer(fileSize: 0), + senderIsDirectlyConnected: true, + activeIncomingTransfers: 0, + enabled: true + )) + } + + @Test("Receiver declines when disabled, indirect, or saturated") + func receiverDeclines() { + #expect(!WifiBulkPolicy.shouldAccept( + offer: offer(fileSize: 1000), + senderIsDirectlyConnected: true, + activeIncomingTransfers: 0, + enabled: false + )) + #expect(!WifiBulkPolicy.shouldAccept( + offer: offer(fileSize: 1000), + senderIsDirectlyConnected: false, + activeIncomingTransfers: 0, + enabled: true + )) + #expect(!WifiBulkPolicy.shouldAccept( + offer: offer(fileSize: 1000), + senderIsDirectlyConnected: true, + activeIncomingTransfers: TransportConfig.wifiBulkMaxConcurrentIncoming, + enabled: true + )) + } + + @Test("This build advertises the wifiBulk capability when enabled") + func localCapabilityAdvertised() { + #expect(PeerCapabilities.localSupported.contains(.wifiBulk) == TransportConfig.wifiBulkEnabled) + } + + @Test("File packets above the BLE cap encode/decode only with the Wi-Fi limit") + func filePacketWifiLimit() throws { + let content = Data(repeating: 0x5A, count: 2 * 1024 * 1024) // 2 MiB + let packet = BitchatFilePacket(fileName: "big.jpg", fileSize: UInt64(content.count), mimeType: "image/jpeg", content: content) + + // BLE cap unchanged. + #expect(packet.encode() == nil) + + let encoded = try #require(packet.encode(limit: FileTransferLimits.maxWifiBulkPayloadBytes)) + #expect(BitchatFilePacket.decode(encoded) == nil) // BLE-cap decode still rejects + let decoded = try #require(BitchatFilePacket.decode(encoded, limit: FileTransferLimits.maxWifiBulkPayloadBytes)) + #expect(decoded.content == content) + #expect(decoded.fileName == "big.jpg") + } +} diff --git a/bitchatTests/WifiBulk/WifiBulkTransferServiceTests.swift b/bitchatTests/WifiBulk/WifiBulkTransferServiceTests.swift new file mode 100644 index 00000000..d14f5dd9 --- /dev/null +++ b/bitchatTests/WifiBulk/WifiBulkTransferServiceTests.swift @@ -0,0 +1,246 @@ +// +// WifiBulkTransferServiceTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation +import Network +import Testing +@testable import bitchat + +/// Thread-safe capture box for closures invoked on service queues. +final class WifiBulkTestBox: @unchecked Sendable { + private let lock = NSLock() + private var storage: [Value] = [] + + func append(_ value: Value) { + lock.lock() + storage.append(value) + lock.unlock() + } + + var values: [Value] { + lock.lock() + defer { lock.unlock() } + return storage + } + + var count: Int { values.count } +} + +func wifiBulkWait( + timeout: TimeInterval = 5.0, + _ condition: @escaping () -> Bool +) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { return true } + try? await Task.sleep(nanoseconds: 20_000_000) + } + return condition() +} + +/// Negotiation/fallback decisions exercised through the real service with the +/// network kept on loopback and Bonjour publication disabled. +@Suite("WifiBulk transfer service negotiation", .serialized) +struct WifiBulkTransferServiceTests { + private static let peer = PeerID(str: "aabbccddeeff0011") + + private func makeConfig() -> WifiBulkTransferServiceConfig { + var config = WifiBulkTransferServiceConfig() + config.usePeerToPeer = false + config.publishBonjourService = false + config.offerTimeout = 0.25 + config.transferWindow = 2.0 + return config + } + + private func makeEnvironment( + sendNoisePayload: @escaping (Data, PeerID) -> Bool, + deliver: @escaping (Data, PeerID, Int) -> Void = { _, _, _ in }, + progressEvents: WifiBulkTestBox? = nil + ) -> WifiBulkTransferServiceEnvironment { + WifiBulkTransferServiceEnvironment( + sendNoisePayload: sendNoisePayload, + isPeerConnected: { _ in true }, + deliverReceivedFile: deliver, + progressStart: { id, total in progressEvents?.append("start:\(id):\(total)") }, + progressChunkSent: { id in progressEvents?.append("chunk:\(id)") }, + progressReset: { id in progressEvents?.append("reset:\(id)") }, + progressCancel: { id in progressEvents?.append("cancel:\(id)") } + ) + } + + private var payload: Data { Data(repeating: 0x42, count: 100_000) } + + @Test("No established Noise session falls straight back to BLE") + func noSessionFallsBack() async { + let fallbacks = WifiBulkTestBox() + let service = WifiBulkTransferService( + environment: makeEnvironment(sendNoisePayload: { _, _ in false }), + config: makeConfig() + ) + service.sendFile(payload: payload, to: Self.peer, transferId: "t-nosession") { + fallbacks.append(true) + } + #expect(await wifiBulkWait { fallbacks.count == 1 }) + #expect(service._test_activeOutgoingCount == 0) + } + + @Test("Unanswered offer times out and falls back exactly once") + func offerTimeoutFallsBackOnce() async { + let fallbacks = WifiBulkTestBox() + let progress = WifiBulkTestBox() + let service = WifiBulkTransferService( + environment: makeEnvironment(sendNoisePayload: { _, _ in true }, progressEvents: progress), + config: makeConfig() + ) + service.sendFile(payload: payload, to: Self.peer, transferId: "t-timeout") { + fallbacks.append(true) + } + #expect(await wifiBulkWait { fallbacks.count == 1 }) + // Wait past the transfer window: the expiry must not double-fire. + try? await Task.sleep(nanoseconds: 2_300_000_000) + #expect(fallbacks.count == 1) + #expect(service._test_activeOutgoingCount == 0) + // Progress state was silently reset ahead of the BLE re-start. + #expect(progress.values.contains("reset:t-timeout")) + #expect(!progress.values.contains("cancel:t-timeout")) + } + + @Test("Declined offer falls back exactly once, even on duplicate declines") + func declineFallsBackOnce() async { + let fallbacks = WifiBulkTestBox() + let offers = WifiBulkTestBox() + var service: WifiBulkTransferService? + let environment = makeEnvironment(sendNoisePayload: { typed, peer in + guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue, + let offer = WifiBulkOffer.decode(typed.dropFirst()) else { return true } + offers.append(offer.transferID) + guard let decline = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return true } + // Deliver the decline twice; the fallback must still fire once. + service?.handleResponsePayload(decline, from: peer) + service?.handleResponsePayload(decline, from: peer) + return true + }) + let sut = WifiBulkTransferService(environment: environment, config: makeConfig()) + service = sut + sut.sendFile(payload: payload, to: Self.peer, transferId: "t-decline") { + fallbacks.append(true) + } + #expect(await wifiBulkWait { fallbacks.count == 1 }) + try? await Task.sleep(nanoseconds: 300_000_000) + #expect(fallbacks.count == 1) + #expect(sut._test_activeOutgoingCount == 0) + } + + @Test("Responses from the wrong peer are ignored") + func wrongPeerResponseIgnored() async { + let fallbacks = WifiBulkTestBox() + var service: WifiBulkTransferService? + let environment = makeEnvironment(sendNoisePayload: { typed, _ in + guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue, + let offer = WifiBulkOffer.decode(typed.dropFirst()), + let decline = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return true } + // Decline arrives from an unrelated peer: must be ignored, so the + // transfer ends via offer timeout instead. + service?.handleResponsePayload(decline, from: PeerID(str: "1122334455667788")) + return true + }) + let sut = WifiBulkTransferService(environment: environment, config: makeConfig()) + service = sut + sut.sendFile(payload: payload, to: Self.peer, transferId: "t-wrongpeer") { + fallbacks.append(true) + } + // Not fallen back before the offer timeout window… + try? await Task.sleep(nanoseconds: 100_000_000) + #expect(fallbacks.count == 0) + // …but the timeout still cleans up. + #expect(await wifiBulkWait { fallbacks.count == 1 }) + } + + @Test("User cancel tears down without BLE fallback") + func userCancelDoesNotFallBack() async { + let fallbacks = WifiBulkTestBox() + let progress = WifiBulkTestBox() + let service = WifiBulkTransferService( + environment: makeEnvironment(sendNoisePayload: { _, _ in true }, progressEvents: progress), + config: makeConfig() + ) + service.sendFile(payload: payload, to: Self.peer, transferId: "t-cancel") { + fallbacks.append(true) + } + #expect(await wifiBulkWait { service._test_activeOutgoingCount == 1 }) + service.cancelTransfer(transferId: "t-cancel") + #expect(await wifiBulkWait { service._test_activeOutgoingCount == 0 }) + try? await Task.sleep(nanoseconds: 400_000_000) // past the offer timeout + #expect(fallbacks.count == 0) + #expect(progress.values.contains("cancel:t-cancel")) + } + + @Test("Receiver declines an offer that exceeds its size cap") + func receiverDeclinesOversizedOffer() async { + let responses = WifiBulkTestBox() + let service = WifiBulkTransferService( + environment: makeEnvironment(sendNoisePayload: { typed, _ in + if typed.first == NoisePayloadType.bulkTransferResponse.rawValue { + responses.append(Data(typed.dropFirst())) + } + return true + }), + config: makeConfig() + ) + let offer = WifiBulkOffer( + transferID: Data(repeating: 7, count: WifiBulkWire.transferIDLength), + fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1, + payloadHash: Data(repeating: 8, count: WifiBulkWire.hashLength), + token: Data(repeating: 9, count: WifiBulkWire.tokenLength), + serviceName: "0011223344556677" + ) + if let encoded = offer.encode() { + service.handleOfferPayload(encoded, from: Self.peer) + } + #expect(await wifiBulkWait { responses.count == 1 }) + let response = WifiBulkResponse.decode(responses.values[0]) + #expect(response?.accepted == false) + #expect(response?.transferID == offer.transferID) + #expect(service._test_activeIncomingCount == 0) + } + + @Test("Malformed offers are dropped without a response") + func malformedOfferDropped() async { + let responses = WifiBulkTestBox() + let service = WifiBulkTransferService( + environment: makeEnvironment(sendNoisePayload: { typed, _ in + responses.append(typed) + return true + }), + config: makeConfig() + ) + service.handleOfferPayload(Data([0x01, 0x02, 0x03]), from: Self.peer) + try? await Task.sleep(nanoseconds: 200_000_000) + #expect(responses.count == 0) + #expect(service._test_activeIncomingCount == 0) + } + + @Test("stop() tears down active transfers without falling back") + func stopTearsDownWithoutFallback() async { + let fallbacks = WifiBulkTestBox() + let service = WifiBulkTransferService( + environment: makeEnvironment(sendNoisePayload: { _, _ in true }), + config: makeConfig() + ) + service.sendFile(payload: payload, to: Self.peer, transferId: "t-stop") { + fallbacks.append(true) + } + #expect(await wifiBulkWait { service._test_activeOutgoingCount == 1 }) + service.stop() + #expect(await wifiBulkWait { service._test_activeOutgoingCount == 0 }) + try? await Task.sleep(nanoseconds: 400_000_000) + #expect(fallbacks.count == 0) + } +} diff --git a/docs/REQUEST_SYNC_MANAGER.md b/docs/REQUEST_SYNC_MANAGER.md index b304508e..a885f159 100644 --- a/docs/REQUEST_SYNC_MANAGER.md +++ b/docs/REQUEST_SYNC_MANAGER.md @@ -20,9 +20,11 @@ The new implementation introduces a **RequestSyncManager** to track outgoing syn ### Request Sync Payload The `REQUEST_SYNC` packet payload (TLV encoded) has been updated to include: -* **Future Filters**: - * `sinceTimestamp` (Type 0x05): To request packets since a certain time (UInt64 big-endian). - * `fragmentIdFilter` (Type 0x06): To request specific fragments (UTF-8 string). +* `sinceTimestamp` (Type 0x05): filter-coverage cursor (UInt64 big-endian). The requester's GCS filter only covers packets at or after this timestamp; the responder skips older packets instead of re-sending them every round. +* `fragmentIdFilter` (Type 0x06): targeted fragment resync (UTF-8 string). Comma-separated 16-hex-char (8-byte) fragment **stream IDs** — the ID that prefixes every fragment payload. + * **Requester**: when a broadcast reassembly stalls (no new fragment for 5 s), the fragment assembler reports the stream ID and a `REQUEST_SYNC` with `types = fragment` and this filter goes to each connected peer (re-requested at most every 10 s per stream). Directed reassemblies are excluded — peers only archive broadcast fragments for sync. + * **Responder**: when the filter is present, the fragment diff is restricted to exactly the named streams and the `sinceTimestamp` cursor is bypassed for them; the GCS filter still excludes pieces the requester already holds. Responses keep RSR marking, TTL 0, per-peer response rate limiting (8/30 s), and `REQUEST_SYNC` itself remains link-local (TTL 0, never relayed). + * **Bounds**: at most 60 IDs per request. Each ID encodes as 16 hex chars plus a comma separator, so the largest value is 60 × 17 − 1 = 1019 bytes, within the decoder's 1024-byte acceptance cap; oversized filter values are ignored (the rest of the request still decodes). ## Architecture diff --git a/docs/SOURCE_ROUTING.md b/docs/SOURCE_ROUTING.md index 6832a16e..55d00232 100644 --- a/docs/SOURCE_ROUTING.md +++ b/docs/SOURCE_ROUTING.md @@ -2,7 +2,7 @@ This document specifies the Source-Based Routing extension (v2) for the BitChat protocol. This upgrade enables efficient unicast routing across the mesh by allowing senders to specify an explicit path of intermediate relays. -**Status:** Implemented in Android and iOS. Backward compatible (v1 clients ignore routing data). +**Status:** Implemented in Android and iOS: both decode routed packets, forward along routes, and originate routes. iOS origination is policy-gated (see §8). Backward compatible (v1 clients never receive routed frames from iOS: routes are only originated when every node on the path has been observed speaking v2). --- @@ -144,3 +144,44 @@ When a node receives a packet **not** addressed to itself: * **Fallback:** If the Next Hop is unreachable, **fall back to broadcast/flood** to ensure delivery. 3. **If NO (Standard):** * Flood the packet to all connected neighbors (subject to TTL and probability rules). + +--- + +## 8. iOS Origination Policy + +iOS attaches a route (upgrading the packet to v2 and re-signing it) only when +**all** of the following hold at send time (`BLESourceRouteOriginationPolicy`): + +1. **Authored locally.** The packet's `SenderID` is our own peer ID. Relays + never rewrite someone else's packet — adding a route would force a + re-sign under the wrong key. Relays only *follow* existing routes + (`BLERouteForwardingPolicy`). +2. **Directed.** The packet has a single-peer `RecipientID` (not the + broadcast ID). In practice this covers Noise-encrypted private traffic, + private file transfers, and their fragments (fragments inherit the + parent's route and version, per §5). +3. **TTL headroom.** `TTL > 1`. Link-local packets (e.g. `REQUEST_SYNC`, + always TTL 0) never carry routes. +4. **Recipient not directly connected.** A direct write already delivers in + one hop; a route would only add bytes. +5. **Complete v2 path exists.** BFS over the confirmed-edge mesh graph + (`MeshTopologyTracker`, built from verified announce `directNeighbors` + claims, entries expiring after 60 s) finds a path with **at most 4 + intermediate hops** where every intermediate hop **and the recipient** + has been observed originating or relaying a v2 packet. Nodes never seen + speaking v2 are assumed v1-only and are excluded — a v1 client cannot + decode a v2 frame, so routing through it would silently drop the packet. +6. **No recent route failure.** See below. + +If any gate fails, behavior is exactly the pre-routing flood/direct-write +path — v1 peers observe no change. + +### Failure Fallback + +A routed unicast rides one path; a broken hop loses the packet where a flood +would heal around it. iOS keeps a small per-recipient health cache +(`BLESourceRouteFailureCache`): a routed send that sees no inbound packet +authored by the recipient within 10 s counts as a route failure, and directed +sends to that recipient fall back to flooding for the next 60 s before +routing is attempted again. Retransmission of the payload itself stays where +it always was (MessageRouter and higher layers). diff --git a/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift b/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift index b46a552d..ca29a2b2 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift @@ -14,7 +14,7 @@ import struct Foundation.Date /// including TTL for hop limiting and optional encryption. /// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented public struct BitchatPacket: Codable { - let version: UInt8 + public let version: UInt8 public let type: UInt8 public let senderID: Data public let recipientID: Data? diff --git a/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift b/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift index 4dea3873..acba7bf7 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift @@ -28,6 +28,13 @@ public struct CourierEnvelope: Equatable { /// the holder may still hand to other couriers (binary split on each /// spray). 1 means carry-only — deliver to the recipient, never re-spray. public let copies: UInt8 + /// Seal-format discriminator: nil means v1 (ciphertext is one-way Noise X + /// to the recipient's *static* key); a value means v2 (Noise X to the + /// recipient's one-time prekey with this ID, forward secret). Encoded as + /// an optional TLV so v1 decoders skip it as unknown: an old client still + /// carries and hands over v2 envelopes opaquely, and when one is addressed + /// to it the static-key open simply fails and is dropped quietly. + public let prekeyID: UInt32? public static let tagLength = 16 /// Couriered messages are text-sized; media transfers are out of scope. @@ -43,18 +50,20 @@ public struct CourierEnvelope: Equatable { case expiry = 0x02 case ciphertext = 0x03 case copies = 0x04 + case prekeyID = 0x05 } - public init(recipientTag: Data, expiry: UInt64, ciphertext: Data, copies: UInt8 = 1) { + public init(recipientTag: Data, expiry: UInt64, ciphertext: Data, copies: UInt8 = 1, prekeyID: UInt32? = nil) { self.recipientTag = recipientTag self.expiry = expiry self.ciphertext = ciphertext self.copies = min(max(copies, 1), Self.maxCopies) + self.prekeyID = prekeyID } /// The same envelope with a different remaining copy budget. public func withCopies(_ copies: UInt8) -> CourierEnvelope { - CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies) + CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID) } public var isExpired: Bool { @@ -97,6 +106,14 @@ public struct CourierEnvelope: Equatable { encoded.append(copies) } + // Omitted for v1 static-sealed envelopes so they stay byte-identical + // to the pre-prekey wire format. + if let prekeyID { + encoded.append(TLVType.prekeyID.rawValue) + appendBE(UInt16(4), into: &encoded) + appendBE(prekeyID, into: &encoded) + } + return encoded } @@ -108,6 +125,7 @@ public struct CourierEnvelope: Equatable { var expiry: UInt64? var ciphertext: Data? var copies: UInt8 = 1 + var prekeyID: UInt32? while cursor < end { let typeRaw = data[cursor] @@ -133,6 +151,9 @@ public struct CourierEnvelope: Equatable { case .copies: guard length == 1 else { return nil } copies = value.first ?? 1 + case .prekeyID: + guard length == 4 else { return nil } + prekeyID = value.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } case nil: // Unknown TLV: skip for forward compatibility. continue @@ -140,7 +161,7 @@ public struct CourierEnvelope: Equatable { } guard let recipientTag, let expiry, let ciphertext else { return nil } - return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies) + return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID) } // MARK: - Recipient Tags diff --git a/localPackages/BitFoundation/Sources/BitFoundation/FileTransferLimits.swift b/localPackages/BitFoundation/Sources/BitFoundation/FileTransferLimits.swift index 4c54f79c..ec89c4b6 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/FileTransferLimits.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/FileTransferLimits.swift @@ -2,6 +2,10 @@ public enum FileTransferLimits { /// Absolute ceiling enforced for any file payload (voice, image, other). public static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB + /// Ceiling for transfers negotiated onto the peer-to-peer Wi-Fi bulk + /// channel. The receiver enforces it against the size in the accepted + /// offer; the Bluetooth path keeps `maxPayloadBytes`. + public static let maxWifiBulkPayloadBytes: Int = 8 * 1024 * 1024 // 8 MiB /// Voice notes stay small for low-latency relays. public static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB /// Compressed images after downscaling should comfortably fit under this budget. @@ -17,7 +21,7 @@ public enum FileTransferLimits { return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead }() - public static func isValidPayload(_ size: Int) -> Bool { - size <= maxPayloadBytes + public static func isValidPayload(_ size: Int, limit: Int = maxPayloadBytes) -> Bool { + size <= limit } } diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MeshPingPayload.swift b/localPackages/BitFoundation/Sources/BitFoundation/MeshPingPayload.swift new file mode 100644 index 00000000..54265b8c --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/MeshPingPayload.swift @@ -0,0 +1,57 @@ +// +// MeshPingPayload.swift +// BitFoundation +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import struct Foundation.Data + +/// Wire payload shared by the `ping` (0x26) and `pong` (0x27) message types. +/// +/// Layout (9 bytes): +/// - 8 bytes: random nonce (a pong echoes the nonce of the ping it answers) +/// - 1 byte: origin TTL — the TTL the packet was launched with, so the +/// receiver can compute the hop count as `originTTL - receivedTTL`. +/// +/// Both directions are unencrypted and unsigned: the payload carries no +/// private data, and the unguessable nonce already binds a pong to a probe +/// the local device actually sent. +public struct MeshPingPayload: Equatable { + public static let nonceLength = 8 + private static let encodedLength = nonceLength + 1 + + public let nonce: Data + public let originTTL: UInt8 + + public init?(nonce: Data, originTTL: UInt8) { + guard nonce.count == Self.nonceLength else { return nil } + self.nonce = nonce + self.originTTL = originTTL + } + + public func encode() -> Data { + var data = Data(capacity: Self.encodedLength) + data.append(nonce) + data.append(originTTL) + return data + } + + /// Accepts payloads with trailing bytes so future revisions can extend + /// the format without breaking older clients. + public static func decode(_ data: Data) -> MeshPingPayload? { + guard data.count >= encodedLength else { return nil } + let nonce = Data(data.prefix(nonceLength)) + let originTTL = data[data.index(data.startIndex, offsetBy: nonceLength)] + return MeshPingPayload(nonce: nonce, originTTL: originTTL) + } + + /// Number of links a packet crossed, derived from TTL decrements plus the + /// final delivery link (a directly connected peer is 1 hop away). + /// Returns nil when the TTLs are inconsistent (received above origin). + public static func hopCount(originTTL: UInt8, receivedTTL: UInt8) -> Int? { + guard originTTL >= receivedTTL else { return nil } + return Int(originTTL - receivedTTL) + 1 + } +} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift index ceac0cd0..ca6ae947 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift @@ -7,7 +7,7 @@ // /// Simplified BitChat protocol message types. -/// Reduced from 24 types to just 6 essential ones. +/// Consolidated from the original 24 wire types down to the 9 cases below. /// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads. public enum MessageType: UInt8 { // Public messages (unencrypted) @@ -16,15 +16,27 @@ public enum MessageType: UInt8 { case leave = 0x03 // "I'm leaving" case courierEnvelope = 0x04 // Store-and-forward envelope carried by a trusted peer case requestSync = 0x21 // GCS filter-based sync request (local-only) - + // Noise encryption case noiseHandshake = 0x10 // Handshake (init or response determined by payload) case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.) - + // Fragmentation (simplified) case fragment = 0x20 // Single fragment type for large messages case fileTransfer = 0x22 // Binary file/audio/image payloads - + + case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone + case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped) + case groupMessage = 0x25 // Group-encrypted broadcast (cleartext group ID, ChaChaPoly body) + // Mesh diagnostics + case ping = 0x26 // Directed echo request (nonce + origin TTL) + case pong = 0x27 // Directed echo reply (echoed nonce + origin TTL) + // Gateway mode: signed Nostr event ferried between a mesh-only peer and + // an internet gateway peer. + case nostrCarrier = 0x28 + + + public var description: String { switch self { case .announce: return "announce" @@ -36,6 +48,12 @@ public enum MessageType: UInt8 { case .noiseEncrypted: return "noiseEncrypted" case .fragment: return "fragment" case .fileTransfer: return "fileTransfer" + case .boardPost: return "boardPost" + case .prekeyBundle: return "prekeyBundle" + case .groupMessage: return "groupMessage" + case .ping: return "ping" + case .pong: return "pong" + case .nostrCarrier: return "nostrCarrier" } } } diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift new file mode 100644 index 00000000..94858d9b --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Feature capabilities a peer advertises in its announce packet. +/// +/// Encoded as a little-endian bitfield with trailing zero bytes dropped, so the +/// wire form grows only when high bits are assigned. Decoders keep the low 64 +/// bits and ignore any longer field, and unknown bits are preserved verbatim — +/// old clients skip the TLV entirely, new clients degrade per-feature. +public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable { + public let rawValue: UInt64 + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } + + public static let prekeys = PeerCapabilities(rawValue: 1 << 0) + public static let wifiBulk = PeerCapabilities(rawValue: 1 << 1) + public static let gateway = PeerCapabilities(rawValue: 1 << 2) + public static let groups = PeerCapabilities(rawValue: 1 << 3) + public static let board = PeerCapabilities(rawValue: 1 << 4) + public static let vouch = PeerCapabilities(rawValue: 1 << 5) + public static let meshDiagnostics = PeerCapabilities(rawValue: 1 << 6) + + /// Minimal little-endian byte encoding; always at least one byte so an + /// empty set is distinguishable from an absent TLV. + public func encoded() -> Data { + var value = rawValue + var bytes = Data() + repeat { + bytes.append(UInt8(truncatingIfNeeded: value)) + value >>= 8 + } while value != 0 + return bytes + } + + /// Accepts any length; bytes beyond the low 64 bits are ignored for + /// forward compatibility. + public init(encoded data: Data) { + var value: UInt64 = 0 + for (index, byte) in data.prefix(8).enumerated() { + value |= UInt64(byte) << (8 * index) + } + self.init(rawValue: value) + } +} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift index 0b2721c2..f22fac18 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift @@ -34,6 +34,9 @@ public struct PeerID: Equatable, Hashable, Sendable { case geoDM = "nostr_" /// `"nostr:"` (+ 8 characters hex) case geoChat = "nostr:" + /// `"group_"` (+ 32 characters hex) — virtual conversation ID for a + /// private group (16-byte group ID). Never routed to a single peer. + case group = "group_" } public let prefix: Prefix @@ -96,6 +99,22 @@ public extension PeerID { } } +// MARK: - Group Conversation Helpers + +public extension PeerID { + /// Convenience init to create a virtual group conversation PeerID from a + /// 16-byte group ID ("group_" + 32 hex characters). + init(groupID: Data) { + self.init(str: Prefix.group.rawValue + groupID.hexEncodedString()) + } + + /// The 16-byte group ID behind a "group_" PeerID, if this is one. + var groupIDData: Data? { + guard isGroup, bare.count == 32 else { return nil } + return Data(hexString: bare) + } +} + // MARK: - Noise Public Key Helpers public extension PeerID { @@ -143,6 +162,11 @@ public extension PeerID { prefix == .geoDM } + /// Returns true if `id` starts with "`group_`" + var isGroup: Bool { + prefix == .group + } + func toPercentEncoded() -> String { id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id } diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PrekeyBundle.swift b/localPackages/BitFoundation/Sources/BitFoundation/PrekeyBundle.swift new file mode 100644 index 00000000..0e73a2c0 --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/PrekeyBundle.swift @@ -0,0 +1,195 @@ +// +// PrekeyBundle.swift +// BitFoundation +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// TLV payload for gossiped one-time prekey bundles (MessageType 0x24). +/// +/// A bundle publishes a batch of one-time Curve25519 public prekeys bound to +/// the owner's Noise static key by an Ed25519 signature over domain-prefixed +/// canonical bytes. Anyone holding the owner's announce-verified signing key +/// can verify a bundle offline, which is what lets bundles spread and persist +/// mesh-wide via gossip sync while the owner is away. Senders seal courier +/// mail to one of these prekeys (one-way Noise X) instead of the owner's +/// long-lived static key, restoring forward secrecy for async first contact. +public struct PrekeyBundle: Equatable { + public struct Prekey: Equatable { + public let id: UInt32 + /// Curve25519.KeyAgreement public key (32 bytes). + public let publicKey: Data + + public init(id: UInt32, publicKey: Data) { + self.id = id + self.publicKey = publicKey + } + } + + /// Noise static public key identifying whose prekeys these are (32 bytes). + public let noiseStaticPublicKey: Data + /// One-time prekeys, at most `maxPrekeys` per bundle. + public let prekeys: [Prekey] + /// Milliseconds since epoch when this bundle was generated; newer bundles + /// replace older ones for the same noise key. + public let generatedAt: UInt64 + /// Ed25519 signature over `signableBytes()` by the owner's announce-bound + /// signing key. + public let signature: Data + + public static let keyLength = 32 + public static let signatureLength = 64 + public static let maxPrekeys = 8 + private static let prekeyEntryLength = 4 + keyLength + + /// Domain separation for the bundle signature so it can never be confused + /// with announce or packet signatures. + private static let signingContext = Data("bitchat-prekey-bundle-v1".utf8) + + private enum TLVType: UInt8 { + case noiseStaticPublicKey = 0x01 + case prekeys = 0x02 + case generatedAt = 0x03 + case signature = 0x04 + } + + public init(noiseStaticPublicKey: Data, prekeys: [Prekey], generatedAt: UInt64, signature: Data) { + self.noiseStaticPublicKey = noiseStaticPublicKey + self.prekeys = prekeys + self.generatedAt = generatedAt + self.signature = signature + } + + /// Canonical bytes covered by the Ed25519 signature: domain context, + /// owner key, prekey count, each (id, key) pair, and the generation time. + /// Encoders and verifiers must derive these identically. + public func signableBytes() -> Data { + var out = Data() + out.reserveCapacity(1 + Self.signingContext.count + Self.keyLength + 1 + + prekeys.count * Self.prekeyEntryLength + 8) + out.append(UInt8(min(Self.signingContext.count, 255))) + out.append(Self.signingContext.prefix(255)) + out.append(paddedKey(noiseStaticPublicKey)) + out.append(UInt8(min(prekeys.count, 255))) + for prekey in prekeys.prefix(255) { + appendBE(prekey.id, into: &out) + out.append(paddedKey(prekey.publicKey)) + } + appendBE(generatedAt, into: &out) + return out + } + + public func encode() -> Data? { + guard noiseStaticPublicKey.count == Self.keyLength, + signature.count == Self.signatureLength, + !prekeys.isEmpty, prekeys.count <= Self.maxPrekeys, + prekeys.allSatisfy({ $0.publicKey.count == Self.keyLength }) else { + return nil + } + + var entries = Data() + entries.reserveCapacity(prekeys.count * Self.prekeyEntryLength) + for prekey in prekeys { + appendBE(prekey.id, into: &entries) + entries.append(prekey.publicKey) + } + + var encoded = Data() + encoded.reserveCapacity(4 * 3 + Self.keyLength + entries.count + 8 + Self.signatureLength) + + encoded.append(TLVType.noiseStaticPublicKey.rawValue) + appendBE(UInt16(noiseStaticPublicKey.count), into: &encoded) + encoded.append(noiseStaticPublicKey) + + encoded.append(TLVType.prekeys.rawValue) + appendBE(UInt16(entries.count), into: &encoded) + encoded.append(entries) + + encoded.append(TLVType.generatedAt.rawValue) + appendBE(UInt16(8), into: &encoded) + appendBE(generatedAt, into: &encoded) + + encoded.append(TLVType.signature.rawValue) + appendBE(UInt16(signature.count), into: &encoded) + encoded.append(signature) + + return encoded + } + + public static func decode(_ data: Data) -> PrekeyBundle? { + var cursor = data.startIndex + let end = data.endIndex + + var noiseStaticPublicKey: Data? + var prekeys: [Prekey]? + var generatedAt: UInt64? + var signature: Data? + + while cursor < end { + let typeRaw = data[cursor] + cursor = data.index(after: cursor) + + guard data.distance(from: cursor, to: end) >= 2 else { return nil } + let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)]) + cursor = data.index(cursor, offsetBy: 2) + guard data.distance(from: cursor, to: end) >= length else { return nil } + let value = data[cursor.. 0, length % prekeyEntryLength == 0, + length / prekeyEntryLength <= maxPrekeys else { return nil } + var parsed: [Prekey] = [] + var entryStart = value.startIndex + while entryStart < value.endIndex { + let idEnd = value.index(entryStart, offsetBy: 4) + let id = value[entryStart.. Data { + let fixed = key.prefix(Self.keyLength) + guard fixed.count < Self.keyLength else { return Data(fixed) } + return Data(fixed) + Data(repeating: 0, count: Self.keyLength - fixed.count) + } +} + +private func appendBE(_ value: T, into data: inout Data) { + var big = value.bigEndian + withUnsafeBytes(of: &big) { data.append(contentsOf: $0) } +} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/MeshPingPayloadTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/MeshPingPayloadTests.swift new file mode 100644 index 00000000..8032bd9c --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/MeshPingPayloadTests.swift @@ -0,0 +1,70 @@ +// +// MeshPingPayloadTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import BitFoundation + +struct MeshPingPayloadTests { + + @Test func encodeDecodeRoundTrip() throws { + let nonce = Data([0x01, 0x02, 0x03, 0x04, 0xAA, 0xBB, 0xCC, 0xFF]) + let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 7)) + + let encoded = payload.encode() + #expect(encoded.count == 9) + #expect(encoded.prefix(8) == nonce) + #expect(encoded.last == 7) + + let decoded = try #require(MeshPingPayload.decode(encoded)) + #expect(decoded == payload) + } + + @Test func decodeToleratesTrailingBytes() throws { + let nonce = Data(repeating: 0x42, count: 8) + let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 3)) + var extended = payload.encode() + extended.append(contentsOf: [0xDE, 0xAD]) + + let decoded = try #require(MeshPingPayload.decode(extended)) + #expect(decoded == payload) + } + + @Test func decodeRespectsSliceIndices() throws { + // Data slices keep their parent's indices; decoding must not assume + // startIndex == 0. + let nonce = Data(repeating: 0x11, count: 8) + let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 5)) + let framed = Data([0x00, 0x00]) + payload.encode() + let slice = framed.dropFirst(2) + + let decoded = try #require(MeshPingPayload.decode(slice)) + #expect(decoded == payload) + } + + @Test func rejectsTruncatedPayload() { + #expect(MeshPingPayload.decode(Data(repeating: 0x01, count: 8)) == nil) + #expect(MeshPingPayload.decode(Data()) == nil) + } + + @Test func rejectsWrongNonceLength() { + #expect(MeshPingPayload(nonce: Data(repeating: 0, count: 7), originTTL: 7) == nil) + #expect(MeshPingPayload(nonce: Data(repeating: 0, count: 9), originTTL: 7) == nil) + } + + @Test func hopCountMath() { + // Direct link: no TTL decrement, one hop. + #expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 7) == 1) + // One relay in between: two hops. + #expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 6) == 2) + // Full TTL consumed. + #expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 1) == 7) + // Inconsistent TTLs (received above origin) are rejected. + #expect(MeshPingPayload.hopCount(originTTL: 3, receivedTTL: 7) == nil) + } +} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift new file mode 100644 index 00000000..1c83530f --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift @@ -0,0 +1,43 @@ +// +// PeerCapabilitiesTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import BitFoundation + +struct PeerCapabilitiesTests { + @Test + func encodingIsMinimalAndRoundTrips() { + #expect(PeerCapabilities([]).encoded() == Data([0x00])) + #expect(PeerCapabilities.prekeys.encoded() == Data([0x01])) + #expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40])) + + let high = PeerCapabilities(rawValue: 1 << 9) + #expect(high.encoded() == Data([0x00, 0x02])) + + let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics] + #expect(PeerCapabilities(encoded: all.encoded()) == all) + #expect(PeerCapabilities(encoded: high.encoded()) == high) + #expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == []) + } + + @Test + func decodingToleratesUnknownBitsAndOversizedFields() { + // Unknown bits survive a round-trip untouched. + let unknown = PeerCapabilities(encoded: Data([0xFF, 0xFF])) + #expect(unknown.rawValue == 0xFFFF) + #expect(unknown.contains(.gateway)) + + // Fields longer than 8 bytes keep the low 64 bits and ignore the rest. + let oversized = Data([0x01] + [UInt8](repeating: 0x00, count: 7) + [0xAA, 0xBB]) + #expect(PeerCapabilities(encoded: oversized) == .prekeys) + + // Empty value decodes to no capabilities. + #expect(PeerCapabilities(encoded: Data()) == []) + } +} diff --git a/localPackages/BitLogger/Sources/LogExportBuffer.swift b/localPackages/BitLogger/Sources/LogExportBuffer.swift new file mode 100644 index 00000000..0f740256 --- /dev/null +++ b/localPackages/BitLogger/Sources/LogExportBuffer.swift @@ -0,0 +1,64 @@ +// +// LogExportBuffer.swift +// BitLogger +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +#if DEBUG +import Foundation + +/// In-memory ring buffer of the most recent sanitized log lines, so a tester +/// can export logs untethered when live streaming isn't available. +/// +/// DEBUG-only: never compiled into release builds, matching the rest of the +/// logging stack. Bounded by both a line count and a byte budget (oldest +/// evicted first). Appends run on a private serial queue so a hot logging path +/// never blocks on buffer maintenance and the main thread is never touched. +/// +/// It stores exactly the SecureLogger-sanitized text (fingerprints truncated, +/// base64 redacted, peer IDs shortened), so the export carries no secrets. +public final class LogExportBuffer { + public static let shared = LogExportBuffer() + + private let queue = DispatchQueue(label: "chat.bitchat.securelogger.export", qos: .utility) + private var lines: [String] = [] + private var byteCount = 0 + + private let maxLines: Int + private let maxBytes: Int + + init(maxLines: Int = 2000, maxBytes: Int = 512 * 1024) { + self.maxLines = maxLines + self.maxBytes = maxBytes + } + + /// Append one already-formatted, already-sanitized log line. Non-blocking + /// (async on the private queue). + func append(_ line: String) { + queue.async { + self.lines.append(line) + self.byteCount += line.utf8.count + 1 // + newline + while self.lines.count > self.maxLines || self.byteCount > self.maxBytes { + guard !self.lines.isEmpty else { break } + let removed = self.lines.removeFirst() + self.byteCount -= (removed.utf8.count + 1) + } + } + } + + /// A newline-joined snapshot of the buffered lines, oldest first. Safe to + /// call from the main thread (brief synchronous read). + public func snapshot() -> String { + queue.sync { lines.joined(separator: "\n") } + } + + public func clear() { + queue.async { + self.lines.removeAll(keepingCapacity: true) + self.byteCount = 0 + } + } +} +#endif diff --git a/localPackages/BitLogger/Sources/LogNetworkSink.swift b/localPackages/BitLogger/Sources/LogNetworkSink.swift new file mode 100644 index 00000000..84ea6e87 --- /dev/null +++ b/localPackages/BitLogger/Sources/LogNetworkSink.swift @@ -0,0 +1,104 @@ +// +// LogNetworkSink.swift +// BitLogger +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +#if DEBUG +import Foundation +#if canImport(Network) +import Network +#endif + +/// Best-effort, fire-and-forget UDP forwarder for sanitized log lines, so a +/// sideloaded device on iOS 17+ (where `idevicesyslog` Wi-Fi streaming no +/// longer works) can stream its logs to a LAN collector with no cable. +/// +/// Design constraints: +/// - DEBUG-only: never compiled into release. A privacy-first release build +/// carries zero network-log code. +/// - Opt-in and off by default: nothing is ever sent until a collector host +/// is configured. An empty host means no egress. +/// - Never blocks the caller and never throws into the app: every send runs +/// on a private queue and failures are silently dropped. A dead or absent +/// collector cannot affect app behavior (UDP is connectionless — datagrams +/// to nowhere are simply lost). +/// - Forwards the exact same sanitized text the ring buffer captures, so no +/// secrets leave the device. Each line is prefixed with the device's label +/// (`[nickname] …`) so one Mac-side `nc -lu ` can demux all devices. +public final class LogNetworkSink { + public static let shared = LogNetworkSink() + + /// UserDefaults keys shared with the in-app config UI (App Info sheet). + public static let hostDefaultsKey = "debug.logSink.host" + public static let portDefaultsKey = "debug.logSink.port" + public static let defaultPort = 9999 + /// Where the device label is read from (the app's chosen nickname). + public static let nicknameDefaultsKey = "bitchat.nickname" + + private let queue = DispatchQueue(label: "chat.bitchat.securelogger.netsink", qos: .utility) + private let defaults: UserDefaults + private var label = "" + private var enabled = false + #if canImport(Network) + private var connection: NWConnection? + #endif + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + /// Re-read collector host/port and the device label from UserDefaults and + /// (re)build the UDP connection. Call on launch and whenever the config UI + /// changes. An empty host tears the sink down (no egress). + public func reloadConfiguration() { + let host = (defaults.string(forKey: Self.hostDefaultsKey) ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + let storedPort = defaults.integer(forKey: Self.portDefaultsKey) + let port = storedPort > 0 ? storedPort : Self.defaultPort + let label = (defaults.string(forKey: Self.nicknameDefaultsKey) ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + configure(host: host, port: port, label: label) + } + + /// Point the sink at `host:port` with a device `label`. Empty host or an + /// invalid port disables egress. + public func configure(host: String, port: Int, label: String) { + queue.async { + self.label = label + #if canImport(Network) + self.connection?.cancel() + self.connection = nil + guard !host.isEmpty, + (1...65535).contains(port), + let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { + self.enabled = false + return + } + let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .udp) + connection.start(queue: self.queue) + self.connection = connection + self.enabled = true + #else + self.enabled = false + #endif + } + } + + /// Forward one already-formatted, already-sanitized line. Non-blocking; + /// drops silently when disabled or on any send failure. + func send(_ line: String) { + queue.async { + guard self.enabled else { return } + #if canImport(Network) + guard let connection = self.connection else { return } + let prefixed = self.label.isEmpty ? line : "[\(self.label)] \(line)" + guard let data = (prefixed + "\n").data(using: .utf8) else { return } + connection.send(content: data, completion: .idempotent) + #endif + } + } +} +#endif diff --git a/localPackages/BitLogger/Sources/OSLog+Categories.swift b/localPackages/BitLogger/Sources/OSLog+Categories.swift index 1d74e27e..7fe19ed7 100644 --- a/localPackages/BitLogger/Sources/OSLog+Categories.swift +++ b/localPackages/BitLogger/Sources/OSLog+Categories.swift @@ -20,4 +20,11 @@ public extension OSLog { static let security = OSLog(subsystem: subsystem, category: "security") static let handshake = OSLog(subsystem: subsystem, category: "handshake") static let sync = OSLog(subsystem: subsystem, category: "sync") + // Added for the observability pass: coarse filtering buckets for the mesh + // routing layer, the internet gateway bridge, and the Wi-Fi bulk transport. + // The bracket tags in the messages ([ROUTE]/[GW]/[WIFI]/…) remain the + // primary filter; these just let `log stream --category` narrow further. + static let mesh = OSLog(subsystem: subsystem, category: "mesh") + static let gateway = OSLog(subsystem: subsystem, category: "gateway") + static let transport = OSLog(subsystem: subsystem, category: "transport") } diff --git a/localPackages/BitLogger/Sources/SecureLogger.swift b/localPackages/BitLogger/Sources/SecureLogger.swift index 5aaffd53..4377eb4a 100644 --- a/localPackages/BitLogger/Sources/SecureLogger.swift +++ b/localPackages/BitLogger/Sources/SecureLogger.swift @@ -86,6 +86,17 @@ public final class SecureLogger { case .fault: return 4 } } + + /// Short uppercase label for the in-memory export buffer line prefix. + var label: String { + switch self { + case .debug: return "DEBUG" + case .info: return "INFO" + case .warning: return "WARN" + case .error: return "ERROR" + case .fault: return "FAULT" + } + } var osLogType: OSLogType { switch self { @@ -100,18 +111,34 @@ public final class SecureLogger { // MARK: - Global Threshold - /// Minimum level that will be logged. Defaults to .info. Override via env BITCHAT_LOG_LEVEL. + /// Minimum level that will be logged. Override via env BITCHAT_LOG_LEVEL + /// (env always wins). The default is `.debug` in DEBUG builds and `.info` + /// otherwise: a sideloaded, tap-launched build can't receive the env var, + /// so DEBUG must surface the `.debug` decision logs out of the box. Release + /// emits nothing regardless (all log paths are `#if DEBUG`). /// Internal-settable so tests can verify level filtering; app code should not mutate it. - internal static var minimumLevel: LogLevel = { + internal static var minimumLevel: LogLevel = defaultMinimumLevel + + /// The level used when nothing mutates `minimumLevel`. Honors the + /// BITCHAT_LOG_LEVEL env override; otherwise `.debug` in DEBUG builds (so a + /// tap-launched sideload surfaces decision logs) and `.info` elsewhere. + /// Exposed for tests. + internal static var defaultMinimumLevel: LogLevel { let env = ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"]?.lowercased() switch env { case "debug": return .debug + case "info": return .info case "warning": return .warning case "error": return .error case "fault": return .fault - default: return .info + default: + #if DEBUG + return .debug + #else + return .info + #endif } - }() + } private static func shouldLog(_ level: LogLevel) -> Bool { return level.order >= minimumLevel.order @@ -168,6 +195,7 @@ public extension SecureLogger { #if DEBUG os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc) + record(level: .error, message: "\(location) Error in \(sanitized): \(errorDesc)") #else os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc) #endif @@ -258,9 +286,10 @@ private extension SecureLogger { let location = formatLocation(file: file, line: line, function: function) let sanitized = "\(location) \(message())".sanitized() os_log("%{public}@", log: category, type: level.osLogType, sanitized) + record(level: level, message: sanitized) #endif } - + /// Log a security event static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info, file: String, line: Int, function: String) { @@ -269,6 +298,18 @@ private extension SecureLogger { let location = formatLocation(file: file, line: line, function: function) let message = "\(location) \(event.message)" os_log("%{public}@", log: .security, type: level.osLogType, message) + record(level: level, message: message) + #endif + } + + /// Fan a sanitized line out to the in-memory export buffer and the + /// (opt-in, off by default) UDP network sink. DEBUG-only; both consumers + /// are non-blocking. Same formatted text everywhere. + static func record(level: LogLevel, message: String) { + #if DEBUG + let line = "[\(level.label)] \(message)" + LogExportBuffer.shared.append(line) + LogNetworkSink.shared.send(line) #endif } diff --git a/localPackages/BitLogger/Tests/LogLevelFilteringTests.swift b/localPackages/BitLogger/Tests/LogLevelFilteringTests.swift index db04759f..47b28dea 100644 --- a/localPackages/BitLogger/Tests/LogLevelFilteringTests.swift +++ b/localPackages/BitLogger/Tests/LogLevelFilteringTests.swift @@ -51,4 +51,25 @@ final class LogLevelFilteringTests: XCTestCase { XCTAssertEqual(counter.count, 1, "Enabled levels must still log") } + + /// A sideloaded, tap-launched build can't receive BITCHAT_LOG_LEVEL, so the + /// DEBUG default must be `.debug` (not `.info`) or the `.debug` decision + /// logs stay invisible on device. Release still defaults to `.info` and + /// emits nothing regardless (all log paths are `#if DEBUG`). + func testDebugBuildDefaultsToDebugLevelUnlessOverridden() throws { + // Only meaningful when the env override isn't set (CI sometimes sets it). + try XCTSkipUnless( + ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"] == nil, + "BITCHAT_LOG_LEVEL is set; default-level assertion doesn't apply" + ) + #if DEBUG + XCTAssertEqual( + SecureLogger.defaultMinimumLevel, + .debug, + "DEBUG builds must default to .debug so tap-launched sideloads surface decision logs" + ) + #else + XCTAssertEqual(SecureLogger.defaultMinimumLevel, .info) + #endif + } }