diff --git a/Configs/Release.xcconfig b/Configs/Release.xcconfig index b87e9064..e4035c9e 100644 --- a/Configs/Release.xcconfig +++ b/Configs/Release.xcconfig @@ -1,4 +1,4 @@ -MARKETING_VERSION = 1.5.3 +MARKETING_VERSION = 1.5.4 CURRENT_PROJECT_VERSION = 1 IPHONEOS_DEPLOYMENT_TARGET = 16.0 diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 5da57510..ef135255 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -561,7 +561,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5.3; + MARKETING_VERSION = 1.5.4; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -620,7 +620,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5.3; + MARKETING_VERSION = 1.5.4; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -655,7 +655,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; - MARKETING_VERSION = 1.5.3; + MARKETING_VERSION = 1.5.4; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; @@ -749,7 +749,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; - MARKETING_VERSION = 1.5.3; + MARKETING_VERSION = 1.5.4; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; diff --git a/bitchat/Noise/NoiseProtocol.swift b/bitchat/Noise/NoiseProtocol.swift index 6c960742..be130f10 100644 --- a/bitchat/Noise/NoiseProtocol.swift +++ b/bitchat/Noise/NoiseProtocol.swift @@ -93,6 +93,7 @@ enum NoisePattern { case XX // Most versatile, mutual authentication case IK // Initiator knows responder's static key case NK // Anonymous initiator + case X // One-way: single message to a known static key (no response) } enum NoiseRole { @@ -601,7 +602,7 @@ final class NoiseHandshakeState { switch pattern { case .XX: break // No pre-message keys - case .IK, .NK: + case .IK, .NK, .X: if role == .initiator, let remoteStatic = remoteStaticPublic { symmetricState.mixHash(remoteStatic.rawRepresentation) } else if role == .responder, let localStatic = localStaticPublic { @@ -904,6 +905,7 @@ extension NoisePattern { case .XX: return "XX" case .IK: return "IK" case .NK: return "NK" + case .X: return "X" } } @@ -925,6 +927,10 @@ extension NoisePattern { [.e, .es], // -> e, es [.e, .ee] // <- e, ee ] + case .X: + return [ + [.e, .es, .s, .ss] // -> e, es, s, ss (single one-way message) + ] } } } diff --git a/bitchat/Services/BLE/BLEAnnounceHandler.swift b/bitchat/Services/BLE/BLEAnnounceHandler.swift index fbd594a2..e33248e3 100644 --- a/bitchat/Services/BLE/BLEAnnounceHandler.swift +++ b/bitchat/Services/BLE/BLEAnnounceHandler.swift @@ -59,6 +59,15 @@ struct BLEAnnounceHandlerEnvironment { let scheduleAfterglow: (TimeInterval) -> Void } +/// Outcome of an accepted announce, surfaced so the service can run +/// follow-up work (e.g. courier handover) that keys off the announce. +struct BLEAnnounceHandlingResult { + let peerID: PeerID + let announcement: AnnouncementPacket + let isDirectAnnounce: Bool + let isVerified: Bool +} + /// Orchestrates inbound announce packets: preflight validation, signature /// trust, registry/topology updates, identity persistence, UI notification, /// gossip tracking, and the reciprocal announce response. @@ -69,7 +78,8 @@ final class BLEAnnounceHandler { self.environment = environment } - func handle(_ packet: BitchatPacket, from peerID: PeerID) { + @discardableResult + func handle(_ packet: BitchatPacket, from peerID: PeerID) -> BLEAnnounceHandlingResult? { let env = environment let now = env.now() let preflight = BLEAnnouncePreflightPolicy.evaluate( @@ -85,15 +95,15 @@ final class BLEAnnounceHandler { announcement = acceptance.announcement case .reject(.malformed): SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session) - return + return nil case .reject(.senderMismatch(let derivedFromKey)): SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security) - return + return nil case .reject(.selfAnnounce): - return + return nil case .reject(.stale(let ageSeconds)): SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session) - return + return nil } // Suppress announce logs to reduce noise @@ -210,5 +220,12 @@ final class BLEAnnounceHandler { let delay = Double.random(in: 0.3...0.6) env.scheduleAfterglow(delay) } + + return BLEAnnounceHandlingResult( + peerID: peerID, + announcement: announcement, + isDirectAnnounce: isDirectAnnounce, + isVerified: verifiedAnnounce + ) } } diff --git a/bitchat/Services/BLE/BLEFanoutSelector.swift b/bitchat/Services/BLE/BLEFanoutSelector.swift index aa99a8f1..6cabcdd2 100644 --- a/bitchat/Services/BLE/BLEFanoutSelector.swift +++ b/bitchat/Services/BLE/BLEFanoutSelector.swift @@ -19,13 +19,35 @@ enum BLEFanoutSelector { packetType: UInt8, messageID: String ) -> BLEFanoutSelection { + let rawAllowed = allowedLinks( + peripheralIDs: peripheralIDs, + centralIDs: centralIDs, + ingressLink: ingressLink, + excludedLinks: excludedLinks + ) + + if let directedPeerHint, + let directedSelection = directLinks( + to: directedPeerHint, + links: rawAllowed, + peripheralPeerBindings: peripheralPeerBindings, + centralPeerBindings: centralPeerBindings + ) { + return directedSelection + } + if let directedPeerHint, + hasBoundLink( + to: directedPeerHint, + peripheralIDs: peripheralIDs, + centralIDs: centralIDs, + peripheralPeerBindings: peripheralPeerBindings, + centralPeerBindings: centralPeerBindings + ) { + return BLEFanoutSelection(peripheralIDs: [], centralIDs: []) + } + let allowed = collapseDuplicateLinksPerPeer( - allowedLinks( - peripheralIDs: peripheralIDs, - centralIDs: centralIDs, - ingressLink: ingressLink, - excludedLinks: excludedLinks - ), + rawAllowed, peripheralPeerBindings: peripheralPeerBindings, centralPeerBindings: centralPeerBindings ) @@ -71,6 +93,42 @@ enum BLEFanoutSelector { return (allowedPeripheralIDs, allowedCentralIDs) } + private static func directLinks( + to peerID: PeerID, + links: (peripheralIDs: [String], centralIDs: [String]), + peripheralPeerBindings: [String: PeerID], + centralPeerBindings: [String: PeerID] + ) -> BLEFanoutSelection? { + let directLinks = collapseDuplicateLinksPerPeer( + ( + peripheralIDs: links.peripheralIDs.filter { peripheralPeerBindings[$0] == peerID }, + centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID } + ), + peripheralPeerBindings: peripheralPeerBindings, + centralPeerBindings: centralPeerBindings + ) + + guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else { + return nil + } + + return BLEFanoutSelection( + peripheralIDs: Set(directLinks.peripheralIDs), + centralIDs: Set(directLinks.centralIDs) + ) + } + + private static func hasBoundLink( + to peerID: PeerID, + peripheralIDs: [String], + centralIDs: [String], + peripheralPeerBindings: [String: PeerID], + centralPeerBindings: [String: PeerID] + ) -> Bool { + peripheralIDs.contains { peripheralPeerBindings[$0] == peerID } + || centralIDs.contains { centralPeerBindings[$0] == peerID } + } + // Dual-role pairs hold two live links (we-as-central writing to their // peripheral, and they-as-central subscribed to ours). Sending the same // packet down both doubles airtime for nothing — the receiver's assembler diff --git a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift index f7117353..18b144d2 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: + case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope: return false } } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 6147633b..531fe9f8 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -46,6 +46,20 @@ final class BLEService: NSObject { // 4. Efficient Message Deduplication private let messageDeduplicator = MessageDeduplicator() + + // Courier store-and-forward: envelopes this device carries for offline + // third parties, and the trust gate for accepting deposits. Injectable + // for tests; main-actor policy because favorites live on the main actor. + var courierStore: CourierStore = .shared + var courierDepositPolicy: @MainActor (Data) -> Bool = { depositorNoiseKey in + FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) + } + + #if DEBUG + // Test-only tap on the outbound pipeline so multi-node tests can ferry + // packets between in-process service instances. + var _test_onOutboundPacket: ((BitchatPacket) -> Void)? + #endif private var selfBroadcastTracker = BLESelfBroadcastTracker() private let meshTopology = MeshTopologyTracker() @@ -888,6 +902,10 @@ final class BLEService: NSObject { } else { packetToSend = packet } + + #if DEBUG + _test_onOutboundPacket?(packetToSend) + #endif // Encode once using a small per-type padding policy, then delegate by type let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type) @@ -1047,6 +1065,9 @@ final class BLEService: NSObject { // Directed send helper (unicast to a specific peerID) without altering packet contents private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) { + #if DEBUG + _test_onOutboundPacket?(packet) + #endif guard let data = packet.toBinaryData(padding: false) else { return } sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID) } @@ -2468,7 +2489,147 @@ extension BLEService { ttl: messageTTL ) } - + + // 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. + func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { + let connected = couriers.filter { isPeerConnected($0) } + guard !connected.isEmpty, + let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else { + return false + } + + let payload: Data + do { + let now = Date() + let sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey) + let envelope = CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: recipientNoiseKey, + epochDay: CourierEnvelope.epochDay(for: now) + ), + expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000), + ciphertext: sealed + ) + guard let encoded = envelope.encode() else { return false } + payload = encoded + } catch { + SecureLogger.error("Failed to seal courier envelope: \(error)", category: .encryption) + return false + } + + messageQueue.async { [weak self] in + guard let self else { return } + for courier in connected { + SecureLogger.debug("📦 Depositing courier envelope with \(courier.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) + self.sendPacketDirected(self.makeCourierPacket(payload, to: courier), to: courier) + } + } + return true + } + + private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket { + BitchatPacket( + type: MessageType.courierEnvelope.rawValue, + senderID: myPeerIDData, + recipientID: Data(hexString: peerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: messageTTL + ) + } + + /// Handles both courier roles for an incoming envelope addressed to us: + /// recipient (the rotating tag matches our static key → open and deliver) + /// or courier (a trusted peer is depositing mail for someone else → store). + private func handleCourierEnvelope(_ packet: BitchatPacket, from peerID: PeerID) { + // Directed packets only; envelopes addressed elsewhere ride the + // generic relay path untouched. + guard packet.recipientID == myPeerIDData else { return } + guard let envelope = CourierEnvelope.decode(packet.payload), !envelope.isExpired else { return } + + let myKey = noiseService.getStaticPublicKeyData() + if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) { + openCourierEnvelope(envelope) + } else { + acceptCourierDeposit(envelope, from: peerID) + } + } + + private func openCourierEnvelope(_ envelope: CourierEnvelope) { + do { + let (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext) + guard let typeRaw = typedPayload.first, + let payloadType = NoisePayloadType(rawValue: typeRaw), + payloadType == .privateMessage else { + SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session) + return + } + // Couriered mail arrives while the sender is absent, so the UI's + // block check can't resolve their fingerprint from a live session. + // Gate here, where the full static key is in hand. + guard !identityManager.isBlocked(fingerprint: senderStaticKey.sha256Fingerprint()) else { + SecureLogger.debug("🚫 Dropping courier envelope from blocked sender", category: .security) + return + } + // A present sender resolves to their live mesh thread via the + // derived short ID. An absent sender — the usual courier case — + // uses the full noise-key ID so the message lands on the stable + // favorite conversation instead of an unresolvable short-ID + // thread labeled "Unknown". + let shortID = PeerID(publicKey: senderStaticKey) + let isKnownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil } + let senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: senderStaticKey) + let payload = Data(typedPayload.dropFirst()) + SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session) + notifyUI { [weak self] in + self?.deliverTransportEvent(.noisePayloadReceived( + peerID: senderPeerID, + type: payloadType, + payload: payload, + timestamp: Date() + )) + } + } catch { + // Tag collision or stale key: not addressed to us after all. + SecureLogger.debug("📦 Courier envelope failed to open: \(error)", category: .encryption) + } + } + + private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID) { + guard let depositorKey = collectionsQueue.sync(execute: { peerRegistry.info(for: peerID)?.noisePublicKey }) else { + SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session) + return + } + let store = courierStore + let policy = courierDepositPolicy + Task { @MainActor in + guard policy(depositorKey) else { + SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (not a mutual favorite)", category: .session) + return + } + if store.deposit(envelope, from: depositorKey) { + SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))…", category: .session) + } + } + } + + /// Hand over any carried envelopes addressed to a peer we just heard from. + private func deliverCourierMail(to peerID: PeerID, noiseKey: Data) { + let envelopes = courierStore.takeEnvelopes(for: noiseKey) + guard !envelopes.isEmpty else { return } + SecureLogger.debug("📦 Handing over \(envelopes.count) courier envelope(s) to \(peerID.id.prefix(8))…", category: .session) + for envelope in envelopes { + guard let payload = envelope.encode() else { continue } + sendPacketDirected(makeCourierPacket(payload, to: peerID), to: peerID) + } + } + // MARK: Link capability snapshots (thread-safe via bleQueue) private func readLinkState(_ body: (BLELinkStateStore) -> T) -> T { @@ -2601,8 +2762,11 @@ extension BLEService { // MARK: Private Message Handling private func sendPrivateMessage(_ content: String, to recipientID: PeerID, messageID: String) { + // Sessions and wire recipient IDs are keyed by the short 16-hex form; + // callers may pass the full 64-hex noise key (mirrors sendFilePrivate). + let recipientID = recipientID.toShort() SecureLogger.debug("📨 Sending PM to \(recipientID.id.prefix(8))… id=\(messageID.prefix(8))… chars=\(content.count) bytes=\(content.utf8.count)", category: .session) - + // Check if we have an established Noise session if noiseService.hasEstablishedSession(with: recipientID) { // Encrypt and send @@ -2953,7 +3117,10 @@ extension BLEService { case .fileTransfer: handleFileTransfer(packet, from: senderID) - + + case .courierEnvelope: + handleCourierEnvelope(packet, from: peerID) + case .leave: handleLeave(packet, from: senderID) @@ -3015,7 +3182,18 @@ extension BLEService { } private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { - announceHandler.handle(packet, from: peerID) + let result = announceHandler.handle(packet, from: peerID) + + // Courier handover: an announce is the moment we learn a peer's Noise + // static key, so check whether we're carrying mail addressed to them. + // Direct announces only: envelopes are removed from the store + // optimistically, so handover must ride an established link rather + // than a speculative multi-hop send toward a relayed announce. + guard !courierStore.isEmpty, + let result, + result.isVerified, + result.isDirectAnnounce else { return } + deliverCourierMail(to: result.peerID, noiseKey: result.announcement.noisePublicKey) } /// Builds the announce handler environment. All queue hops stay here so diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index 680c20f8..9f2f8e9b 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -51,8 +51,9 @@ protocol CommandContextProvider: AnyObject { func addPublicSystemMessage(_ content: String) // 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) - func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) } /// Processes chat commands in a focused, efficient way @@ -335,34 +336,31 @@ final class CommandProcessor { guard !targetName.isEmpty else { return .error(message: "usage: /\(add ? "fav" : "unfav") ") } - + let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName - - guard let peerID = contextProvider?.getPeerIDForNickname(nickname), - let noisePublicKey = Data(hexString: peerID.id) else { + + guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else { return .error(message: "can't find peer: \(nickname)") } - - if add { - let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) - FavoritesPersistenceService.shared.addFavorite( - peerNoisePublicKey: noisePublicKey, - peerNostrPublicKey: existingFavorite?.peerNostrPublicKey, - peerNickname: nickname - ) - - contextProvider?.toggleFavorite(peerID: peerID) - contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true) - - return .success(message: "added \(nickname) to favorites") + + // Resolve current state by the peer's real noise key. The resolved + // peerID is either the short 16-hex mesh ID or the full 64-hex + // noise-key ID (offline favorite row) — never the noise key itself. + let isCurrentlyFavorite: Bool + if let noiseKey = peerID.noiseKey { + isCurrentlyFavorite = FavoritesPersistenceService.shared.isFavorite(noiseKey) } else { - FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey) - - contextProvider?.toggleFavorite(peerID: peerID) - contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false) - - return .success(message: "removed \(nickname) from favorites") + isCurrentlyFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.isFavorite ?? false } + + guard add != isCurrentlyFavorite else { + return .success(message: add ? "\(nickname) is already a favorite" : "\(nickname) is not a favorite") + } + + // toggleFavorite persists by the real noise key and notifies the peer. + contextProvider?.toggleFavorite(peerID: peerID) + + return .success(message: add ? "added \(nickname) to favorites" : "removed \(nickname) from favorites") } } diff --git a/bitchat/Services/Courier/CourierStore.swift b/bitchat/Services/Courier/CourierStore.swift new file mode 100644 index 00000000..e5523257 --- /dev/null +++ b/bitchat/Services/Courier/CourierStore.swift @@ -0,0 +1,219 @@ +// +// CourierStore.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 + +/// Holds courier envelopes this device is carrying for offline third parties. +/// +/// Envelopes are opaque ciphertext deposited by mutual favorites; this store +/// never learns sender, recipient, or content. Strict quotas keep the device +/// from becoming a public mailbag: bounded count, bounded per-depositor +/// count, bounded size, and a 24-hour lifetime aligned with the outbox +/// retention policy. Carried mail is included in the panic wipe. +final class CourierStore { + struct StoredEnvelope: Codable, Equatable { + let recipientTag: Data + let expiry: UInt64 + let ciphertext: Data + let depositorNoiseKey: Data + let storedAt: Date + + var envelope: CourierEnvelope { + CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext) + } + } + + enum Limits { + static let maxEnvelopes = 20 + static let maxPerDepositor = 5 + /// Slack on top of the 24h lifetime for depositor clock skew. + static let maxExpirySlack: TimeInterval = 60 * 60 + } + + static let shared = CourierStore() + + /// Number of envelopes currently carried, published on the main thread + /// so the UI can show a "carrying mail" indicator. + @Published private(set) var carriedCount: Int = 0 + + /// Fast path so hot code (announce handling) can skip tag computation. + var isEmpty: Bool { + queue.sync { envelopes.isEmpty } + } + + private var envelopes: [StoredEnvelope] = [] + private let queue = DispatchQueue(label: "chat.bitchat.courier.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: - Depositing (courier side) + + /// Accept an envelope from a depositor. Returns false when quotas or + /// validity checks reject it. Trust policy (mutual favorite) is the + /// caller's responsibility; this store only enforces resource bounds. + @discardableResult + func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data) -> Bool { + let date = now() + guard envelope.recipientTag.count == CourierEnvelope.tagLength, + !envelope.ciphertext.isEmpty, + envelope.ciphertext.count <= CourierEnvelope.maxCiphertextBytes, + !envelope.isExpired(at: date) else { + return false + } + // Reject expiries beyond the policy lifetime so depositors can't pin + // storage longer than the outbox would retain the message itself. + let maxExpiry = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + Limits.maxExpirySlack) + guard envelope.expiry <= UInt64(maxExpiry.timeIntervalSince1970 * 1000) else { + return false + } + + return queue.sync { + pruneExpiredLocked(at: date) + + // Identical ciphertext is the same envelope; accept idempotently. + if envelopes.contains(where: { $0.ciphertext == envelope.ciphertext }) { + return true + } + guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < Limits.maxPerDepositor else { + SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached", category: .session) + return false + } + if envelopes.count >= Limits.maxEnvelopes { + // Oldest-first eviction, matching outbox overflow behavior. + let evicted = envelopes.removeFirst() + SecureLogger.debug("📦 Courier store full - evicted envelope stored at \(evicted.storedAt)", category: .session) + } + + envelopes.append(StoredEnvelope( + recipientTag: envelope.recipientTag, + expiry: envelope.expiry, + ciphertext: envelope.ciphertext, + depositorNoiseKey: depositorNoiseKey, + storedAt: date + )) + persistLocked() + return true + } + } + + // MARK: - Handover (on encountering a peer) + + /// Remove and return all envelopes addressed to the given peer, matching + /// the rotating recipient tag across adjacent days. Envelopes are removed + /// optimistically: handover happens over a live link, and the depositor's + /// outbox still retains the original for direct delivery. + func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] { + let date = now() + let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date) + return queue.sync { + pruneExpiredLocked(at: date) + let matched = envelopes.filter { candidates.contains($0.recipientTag) } + guard !matched.isEmpty else { return [] } + envelopes.removeAll { stored in matched.contains(stored) } + persistLocked() + return matched.map(\.envelope) + } + } + + // MARK: - Maintenance + + func pruneExpired() { + let date = now() + queue.sync { + pruneExpiredLocked(at: date) + persistLocked() + } + } + + /// Panic wipe: drop all carried mail from memory and disk. + func wipe() { + queue.sync { + envelopes.removeAll() + if let fileURL { + try? FileManager.default.removeItem(at: fileURL) + } + publishCountLocked() + } + } + + // MARK: - Internals (call only on `queue`) + + private func pruneExpiredLocked(at date: Date) { + let before = envelopes.count + envelopes.removeAll { $0.envelope.isExpired(at: date) } + if envelopes.count != before { + SecureLogger.debug("📦 Courier store pruned \(before - envelopes.count) expired envelope(s)", category: .session) + } + } + + private func publishCountLocked() { + let count = envelopes.count + DispatchQueue.main.async { [weak self] in + self?.carriedCount = count + } + } + + private func persistLocked() { + publishCountLocked() + guard let fileURL else { return } + do { + if envelopes.isEmpty { + try? FileManager.default.removeItem(at: fileURL) + return + } + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(envelopes) + 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 courier store: \(error)", category: .session) + } + } + + private func loadFromDisk() { + guard let fileURL else { return } + queue.sync { + guard let data = try? Data(contentsOf: fileURL), + let stored = try? JSONDecoder().decode([StoredEnvelope].self, from: data) else { + return + } + envelopes = stored + pruneExpiredLocked(at: now()) + publishCountLocked() + } + } + + 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("courier", isDirectory: true) + .appendingPathComponent("envelopes.json") + } +} diff --git a/bitchat/Services/FavoritesPersistenceService.swift b/bitchat/Services/FavoritesPersistenceService.swift index b53d640e..aa045841 100644 --- a/bitchat/Services/FavoritesPersistenceService.swift +++ b/bitchat/Services/FavoritesPersistenceService.swift @@ -141,7 +141,13 @@ final class FavoritesPersistenceService: ObservableObject { peerNostrPublicKey: String? = nil ) { let existing = favorites[peerNoisePublicKey] - let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown" + // Callers that can't resolve the live nickname pass the "Unknown" + // placeholder (e.g. a notification arriving before the announce); + // never let it clobber a real stored nickname. + let incoming = peerNickname.flatMap { name in + (name.isEmpty || name == "Unknown") ? nil : name + } + let displayName = incoming ?? existing?.peerNickname ?? "Unknown" SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session) diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index ace112bc..a15b9b10 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -2,11 +2,37 @@ import BitLogger import BitFoundation import Foundation +/// Trust and identity lookups the router needs to pick couriers. Backed by +/// the favorites store in production; injectable for tests. +struct CourierDirectory { + /// Noise static key for a peer we can address while they're offline. + var noiseKey: (PeerID) -> Data? + /// Whether a peer (by Noise static key) may carry our mail. + var isTrustedCourier: (Data) -> Bool + + @MainActor + static func favoritesBacked() -> CourierDirectory { + CourierDirectory( + noiseKey: { peerID in + // Offline favorites are addressed by the full 64-hex + // noise-key ID, which carries the key itself; the favorites + // lookup only resolves short 16-hex IDs. + peerID.noiseKey + ?? FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNoisePublicKey + }, + isTrustedCourier: { noiseKey in + FavoritesPersistenceService.shared.isMutualFavorite(noiseKey) + } + ) + } +} + /// Routes messages using available transports (Mesh, Nostr, etc.) @MainActor final class MessageRouter { private let transports: [Transport] private let now: () -> Date + private let courierDirectory: CourierDirectory /// Invoked whenever a retained private message is dropped without a /// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction) @@ -14,6 +40,12 @@ final class MessageRouter { /// stale "sending/sent" state forever. var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)? + /// Invoked when a message with no reachable transport was handed to at + /// least one courier (a connected mutual favorite who will physically + /// carry the sealed envelope). Delivery stays best-effort: the outbox + /// retains the message until an ack arrives. + var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)? + // Outbox entry with timestamp for TTL-based eviction private struct QueuedMessage { let content: String @@ -31,10 +63,17 @@ final class MessageRouter { // Bound resends of messages sent on a weak reachability signal that never // get a delivery ack (e.g. peer on an old client that doesn't ack). private static let maxSendAttempts = 8 + // Redundant couriers improve delivery odds; receivers dedup by message ID. + private static let maxCouriersPerMessage = 3 - init(transports: [Transport], now: @escaping () -> Date = Date.init) { + init( + transports: [Transport], + now: @escaping () -> Date = Date.init, + courierDirectory: CourierDirectory? = nil + ) { self.transports = transports self.now = now + self.courierDirectory = courierDirectory ?? .favoritesBacked() // Observe favorites changes to learn Nostr mapping and flush queued messages NotificationCenter.default.addObserver( @@ -94,6 +133,33 @@ final class MessageRouter { unsent.sendAttempts = 0 enqueue(unsent, for: peerID) SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session) + attemptCourierDeposit(content: content, messageID: messageID, for: peerID) + } + } + + /// Last resort when no transport can reach the peer: seal the message to + /// their known static key and hand it to connected mutual favorites who + /// may physically encounter them. The queued copy above stays retained, + /// so direct delivery still wins if the peer reappears first (receivers + /// dedup by message ID). + private func attemptCourierDeposit(content: String, messageID: String, for peerID: PeerID) { + guard let recipientKey = courierDirectory.noiseKey(peerID) else { return } + for transport in transports { + let couriers = transport.currentPeerSnapshots() + .filter { snapshot in + guard snapshot.isConnected, + let key = snapshot.noisePublicKey, + key != recipientKey else { return false } + return courierDirectory.isTrustedCourier(key) + } + .prefix(Self.maxCouriersPerMessage) + .map(\.peerID) + guard !couriers.isEmpty else { continue } + if transport.sendCourierMessage(content, messageID: messageID, recipientNoiseKey: recipientKey, via: Array(couriers)) { + SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))…", category: .session) + onMessageCarried?(messageID, peerID) + return + } } } diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index a19e3da2..8a99d9ec 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -369,6 +369,49 @@ final class NoiseEncryptionService { func getPeerPublicKeyData(_ peerID: PeerID) -> Data? { return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation } + + // MARK: - Courier Envelopes (one-way Noise X) + + /// Domain separation for courier envelopes so X-pattern transcripts can + /// never be confused with interactive XX handshakes. + private static let courierPrologue = Data("bitchat-courier-v1".utf8) + + /// Encrypt a payload to a peer's known static key without an interactive + /// handshake (Noise X pattern). Used for store-and-forward envelopes + /// carried by couriers while the recipient is offline. + /// - Warning: One-way messages have no forward secrecy: a later compromise + /// of the recipient's static key exposes envelopes captured in transit. + /// Use established sessions whenever the peer is reachable. + func sealCourierPayload(_ payload: Data, recipientStaticKey: Data) throws -> Data { + let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientStaticKey) + let handshake = NoiseHandshakeState( + role: .initiator, + pattern: .X, + keychain: keychain, + localStaticKey: staticIdentityKey, + remoteStaticKey: remoteKey, + prologue: Self.courierPrologue + ) + return try handshake.writeMessage(payload: payload) + } + + /// Decrypt a courier envelope addressed to our static key. Returns the + /// payload and the sender's authenticated static public key (the `ss` + /// DH in the X pattern binds the sender's identity to the ciphertext). + func openCourierPayload(_ envelopeCiphertext: Data) throws -> (payload: Data, senderStaticKey: Data) { + let handshake = NoiseHandshakeState( + role: .responder, + pattern: .X, + keychain: keychain, + localStaticKey: staticIdentityKey, + prologue: Self.courierPrologue + ) + let payload = try handshake.readMessage(envelopeCiphertext) + guard let senderKey = handshake.getRemoteStaticPublicKey() else { + throw NoiseError.missingKeys + } + return (payload: payload, senderStaticKey: senderKey.rawRepresentation) + } /// Clear persistent identity (for panic mode) func clearPersistentIdentity() { diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index 35c96547..d6b98a61 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -125,14 +125,12 @@ final class NostrTransport: Transport, @unchecked Sendable { func isPeerConnected(_ peerID: PeerID) -> Bool { false } func isPeerReachable(_ peerID: PeerID) -> Bool { - queue.sync { - // Check if exact match + // Callers address peers by either the short 16-hex ID or the full + // 64-hex noise key (offline favorites), so compare in short form. + let short = peerID.toShort() + return queue.sync { if reachablePeers.contains(peerID) { return true } - // Check for short ID match - if peerID.isShort { - return reachablePeers.contains(where: { $0.toShort() == peerID }) - } - return false + return reachablePeers.contains(where: { $0.toShort() == short }) } } diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift index 4633698e..c979b073 100644 --- a/bitchat/Services/PrivateChatManager.swift +++ b/bitchat/Services/PrivateChatManager.swift @@ -209,7 +209,7 @@ final class PrivateChatManager: ObservableObject { case .read, .delivered: externalReceipts.insert(message.id) sentReadReceipts.insert(message.id) - case .failed, .partiallyDelivered, .sending, .sent: + case .failed, .partiallyDelivered, .sending, .sent, .carried: break } } diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 36ca832b..afd1680e 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -95,6 +95,12 @@ protocol Transport: AnyObject { func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) func cancelTransfer(_ transferId: String) + // Courier store-and-forward (mesh transports only): seal a message to the + // recipient's static key and hand it to connected couriers for physical + // delivery while the recipient is offline. Returns false when the + // transport cannot courier (no connected courier, or unsupported). + func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool + // QR verification (optional for transports) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) @@ -120,6 +126,7 @@ extension Transport { func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} + func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false } func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} func cancelTransfer(_ transferId: String) {} diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index 82a51f3f..a5d64621 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -86,45 +86,44 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { var enrichedPeers: [BitchatPeer] = [] var connected: Set = [] var addedPeerIDs: Set = [] - + var meshNoiseKeys: Set = [] + // Phase 1: Add all mesh peers (connected and reachable) for peerInfo in meshPeers { let peerID = peerInfo.peerID guard peerID != meshService.myPeerID else { continue } // Never add self - + let peer = buildPeerFromMesh( peerInfo: peerInfo, favorites: favorites, meshAttached: hasAnyConnected ) - + enrichedPeers.append(peer) if peer.isConnected { connected.insert(peerID) } addedPeerIDs.insert(peerID) - + // Update fingerprint cache if let publicKey = peerInfo.noisePublicKey { + meshNoiseKeys.insert(publicKey) fingerprintCache[peerID] = publicKey.sha256Fingerprint() } } - - // Phase 2: Add offline favorites that we actively favorite + + // Phase 2: Add offline favorites that we actively favorite. + // Mesh rows use the short 16-hex peer ID while favorites are keyed by + // the full 32-byte noise key, so dedup must compare noise keys — a + // PeerID comparison between the two forms can never match. for (favoriteKey, favorite) in favorites where favorite.isFavorite { + if meshNoiseKeys.contains(favoriteKey) { continue } + let peerID = PeerID(hexData: favoriteKey) - - // Skip if already added (connected peer) if addedPeerIDs.contains(peerID) { continue } - - // Skip if connected under different ID but same nickname - let isConnectedByNickname = enrichedPeers.contains { - $0.nickname == favorite.peerNickname && $0.isConnected - } - if isConnectedByNickname { continue } - + let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID) enrichedPeers.append(peer) addedPeerIDs.insert(peerID) - + // Update fingerprint cache fingerprintCache[peerID] = favoriteKey.sha256Fingerprint() } diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index 19261314..430485a0 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -20,6 +20,9 @@ struct SyncTypeFlags: OptionSet { case .fragment: return 5 case .requestSync: return 6 case .fileTransfer: return 7 + // Courier envelopes are directed deposits between trusted peers and + // must never spread via gossip sync. + case .courierEnvelope: return nil } } diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index e93b7eb9..bea1b5b2 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -355,9 +355,10 @@ private extension ChatLifecycleCoordinator { case .failed: return 1 case .sending: return 2 case .sent: return 3 - case .partiallyDelivered: return 4 - case .delivered: return 5 - case .read: return 6 + case .carried: return 4 + case .partiallyDelivered: return 5 + case .delivered: return 6 + case .read: return 7 } } } diff --git a/bitchat/ViewModels/ChatNostrCoordinator.swift b/bitchat/ViewModels/ChatNostrCoordinator.swift index 37ac4a50..ae54966d 100644 --- a/bitchat/ViewModels/ChatNostrCoordinator.swift +++ b/bitchat/ViewModels/ChatNostrCoordinator.swift @@ -21,13 +21,9 @@ protocol ChatNostrContext: GeohashSubscriptionContext, NostrInboundPipelineConte func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) - // MARK: Favorites & notifications (shared with the other contexts) + // MARK: Favorites (shared with the other contexts) /// The persisted favorite relationship for the peer's Noise static key, if any. func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? - /// Adds (or updates) a favorite in the favorites store. - func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) - /// Posts a generic local user notification. - func postLocalNotification(title: String, body: String, identifier: String) } extension ChatViewModel: ChatNostrContext { @@ -98,57 +94,6 @@ final class ChatNostrCoordinator { } } - @MainActor - func handleFavoriteNotification(content: String, from nostrPubkey: String) { - guard let context else { return } - guard let senderNoiseKey = inbound.findNoiseKey(for: nostrPubkey) else { return } - - let isFavorite = content.contains("FAVORITE:TRUE") - let senderNickname = content.components(separatedBy: "|").last ?? "Unknown" - - if isFavorite { - context.addFavorite( - noiseKey: senderNoiseKey, - nostrPublicKey: nostrPubkey, - nickname: senderNickname - ) - } - - var extractedNostrPubkey: String? - if let range = content.range(of: "NPUB:") { - let suffix = content[range.upperBound...] - let parts = suffix.components(separatedBy: "|") - if let key = parts.first { - extractedNostrPubkey = String(key) - } - } else if content.contains(":") { - let parts = content.components(separatedBy: ":") - if parts.count >= 3 { - extractedNostrPubkey = String(parts[2]) - } - } - - SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session) - - if isFavorite && extractedNostrPubkey != nil { - SecureLogger.info( - "💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...", - category: .session - ) - context.addFavorite( - noiseKey: senderNoiseKey, - nostrPublicKey: extractedNostrPubkey, - nickname: senderNickname - ) - } - - context.postLocalNotification( - title: isFavorite ? "New Favorite" : "Favorite Removed", - body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you", - identifier: "fav-\(UUID().uuidString)" - ) - } - @MainActor func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { guard let context else { return } diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index f1d9e1f7..e577e406 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -92,7 +92,6 @@ protocol ChatPrivateConversationContext: AnyObject { func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) - func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?) // MARK: System messages func addSystemMessage(_ content: String) @@ -101,6 +100,9 @@ protocol ChatPrivateConversationContext: AnyObject { // MARK: Favorites & notifications /// The persisted favorite relationship for the peer's Noise static key, if any. func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? + /// The persisted favorite relationship resolved from a short 16-hex mesh + /// peer ID (matched against the IDs derived from stored noise keys). + func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? /// Persists that the peer favorited/unfavorited us (favorites store write). func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) /// Posts the incoming-private-message local notification. @@ -197,6 +199,10 @@ extension ChatViewModel: ChatPrivateConversationContext { FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) } + // `favoriteRelationship(forPeerID:)` is shared with + // `ChatPeerIdentityContext`; its witness lives in + // `ChatPeerIdentityCoordinator.swift`. + func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) { FavoritesPersistenceService.shared.updatePeerFavoritedUs( peerNoisePublicKey: noiseKey, @@ -245,10 +251,15 @@ final class ChatPrivateConversationCoordinator { return } - guard let noiseKey = Data(hexString: peerID.id) else { return } + // Resolve the favorite behind this conversation. It may be keyed by + // the full 64-hex noise-key ID (offline favorite row) or the short + // 16-hex mesh ID — the raw hex bytes of a short ID are a routing ID, + // never a noise key, so they must not be used as a favorites key. + let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID) let isConnected = context.isPeerConnected(peerID) let isReachable = context.isPeerReachable(peerID) - let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey) + let favoriteStatus = noiseKey.flatMap { context.favoriteRelationship(forNoiseKey: $0) } + ?? context.favoriteRelationship(forPeerID: peerID) let isMutualFavorite = favoriteStatus?.isMutual ?? false let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil @@ -405,9 +416,32 @@ final class ChatPrivateConversationCoordinator { return } + // Prefer the favorite's stored nickname when the sender resolved to a + // known noise key; the Nostr display name is a geohash-scoped + // fallback (e.g. "anon#678e") that would mislabel favorite-transport + // DMs. Geohash conversations (nostr_ keys) keep the geo name. + let senderName: String = { + if let noiseKey = convKey.noiseKey, + let favoriteNickname = context.favoriteRelationship(forNoiseKey: noiseKey)?.peerNickname, + !favoriteNickname.isEmpty { + return favoriteNickname + } + return context.displayNameForNostrPubkey(senderPubkey) + }() + + // Favorite notifications ride the PM channel over Nostr too; intercept + // them so they update the relationship instead of rendering as text. + if pm.content.hasPrefix("[FAVORITED]") || pm.content.hasPrefix("[UNFAVORITED]") { + handleFavoriteNotification( + pm.content, + from: convKey, + senderNickname: senderName + ) + return + } + if context.privateChatsContainMessage(withID: messageId) { return } - let senderName = context.displayNameForNostrPubkey(senderPubkey) let message = BitchatMessage( id: messageId, sender: senderName, @@ -486,93 +520,6 @@ final class ChatPrivateConversationCoordinator { context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id) } - func handlePrivateMessage( - _ payload: NoisePayload, - actualSenderNoiseKey: Data?, - senderNickname: String, - targetPeerID: PeerID, - messageTimestamp: Date, - senderPubkey: String - ) { - guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } - let messageId = pm.messageID - let messageContent = pm.content - - if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") { - if let key = actualSenderNoiseKey { - handleFavoriteNotificationFromMesh( - messageContent, - from: PeerID(hexData: key), - senderNickname: senderNickname - ) - } - return - } - - if isDuplicateMessage(messageId, targetPeerID: targetPeerID) { - return - } - - let wasReadBefore = context.sentReadReceipts.contains(messageId) - - var isViewingThisChat = false - if context.selectedPrivateChatPeer == targetPeerID { - isViewingThisChat = true - } else if let selectedPeer = context.selectedPrivateChatPeer, - let selectedPeerNoiseKey = context.noisePublicKey(for: selectedPeer), - let key = actualSenderNoiseKey, - selectedPeerNoiseKey == key { - isViewingThisChat = true - } - - let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 - let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && isRecentMessage - - let message = BitchatMessage( - id: messageId, - sender: senderNickname, - content: messageContent, - timestamp: messageTimestamp, - isRelay: false, - isPrivate: true, - recipientNickname: context.nickname, - senderPeerID: targetPeerID, - deliveryStatus: .delivered(to: context.nickname, at: Date()) - ) - - addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID) - mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey) - - context.sendDeliveryAckViaNostrEmbedded( - message, - wasReadBefore: wasReadBefore, - senderPubkey: senderPubkey, - key: actualSenderNoiseKey - ) - - if wasReadBefore { - // No-op. - } else if isViewingThisChat { - handleViewingThisChat( - message, - targetPeerID: targetPeerID, - key: actualSenderNoiseKey, - senderPubkey: senderPubkey - ) - } else { - markAsUnreadIfNeeded( - shouldMarkAsUnread: shouldMarkAsUnread, - targetPeerID: targetPeerID, - key: actualSenderNoiseKey, - isRecentMessage: isRecentMessage, - senderNickname: senderNickname, - messageContent: messageContent - ) - } - - context.notifyUIChanged() - } - func handlePrivateMessage(_ message: BitchatMessage) { SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session) let senderPeerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender) @@ -583,7 +530,7 @@ final class ChatPrivateConversationCoordinator { } if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") { - handleFavoriteNotificationFromMesh(message.content, from: peerID, senderNickname: message.sender) + handleFavoriteNotification(message.content, from: peerID, senderNickname: message.sender) return } @@ -706,7 +653,10 @@ final class ChatPrivateConversationCoordinator { } } - func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) { + /// Applies an inbound `[FAVORITED]`/`[UNFAVORITED]` marker from either + /// transport. `peerID` must resolve to a noise key — a full 64-hex ID or + /// one the unified peer list knows; otherwise the notification is dropped. + func handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) { let isFavorite = content.hasPrefix("[FAVORITED]") let parts = content.split(separator: ":") diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 705cd194..803fec7f 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1189,6 +1189,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Clear persistent favorites from keychain FavoritesPersistenceService.shared.clearAllFavorites() + // Drop courier mail carried for third parties (memory and disk) + CourierStore.shared.wipe() + // Identity manager has cleared persisted identity data above // Clear autocomplete state diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 76b8d55c..8897d7c8 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -105,6 +105,23 @@ private extension ChatViewModelBootstrapper { ) } } + // A message with no reachable transport that was handed to a courier + // shows a distinct "carried" state instead of sitting in "sending" + // forever. Never downgrade a confirmed receipt: the courier copy can + // race direct delivery when the peer reappears. + viewModel.messageRouter.onMessageCarried = { [weak viewModel] messageID, peerID in + guard let viewModel else { return } + switch viewModel.conversations.deliveryStatus(forMessageID: messageID) { + case .delivered, .read: + break + default: + SecureLogger.debug( + "📦 Message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… handed to courier → marked carried", + category: .session + ) + viewModel.conversations.setDeliveryStatus(.carried, forMessageID: messageID) + } + } viewModel.commandProcessor.contextProvider = viewModel viewModel.commandProcessor.meshService = viewModel.meshService viewModel.participantTracker.configure(context: viewModel) diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift index bf2051bc..a63d6672 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift @@ -109,11 +109,6 @@ extension ChatViewModel { ) } - @MainActor - func handleFavoriteNotification(content: String, from nostrPubkey: String) { - nostrCoordinator.handleFavoriteNotification(content: content, from: nostrPubkey) - } - @MainActor func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: isFavorite) diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift index fb288dbc..af8d06aa 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift @@ -121,25 +121,6 @@ extension ChatViewModel { mediaTransferCoordinator.deleteMediaMessage(messageID: messageID) } - @MainActor - func handlePrivateMessage( - _ payload: NoisePayload, - actualSenderNoiseKey: Data?, - senderNickname: String, - targetPeerID: PeerID, - messageTimestamp: Date, - senderPubkey: String - ) { - privateConversationCoordinator.handlePrivateMessage( - payload, - actualSenderNoiseKey: actualSenderNoiseKey, - senderNickname: senderNickname, - targetPeerID: targetPeerID, - messageTimestamp: messageTimestamp, - senderPubkey: senderPubkey - ) - } - @MainActor func handlePrivateMessage(_ message: BitchatMessage) { privateConversationCoordinator.handlePrivateMessage(message) @@ -190,8 +171,8 @@ extension ChatViewModel { } @MainActor - func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) { - privateConversationCoordinator.handleFavoriteNotificationFromMesh( + func handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) { + privateConversationCoordinator.handleFavoriteNotification( content, from: peerID, senderNickname: senderNickname diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index 95d9b234..337fa474 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -442,8 +442,7 @@ final class NostrInboundPipeline { } /// Resolves the Noise static key behind a Nostr pubkey via the favorites - /// store. Lives here because the inbound DM path needs it per message; - /// the favorites glue in `ChatNostrCoordinator` delegates to it. + /// store. Lives here because the inbound DM path needs it per message. @MainActor func findNoiseKey(for nostrPubkey: String) -> Data? { guard let context else { return nil } diff --git a/bitchat/Views/Components/DeliveryStatusView.swift b/bitchat/Views/Components/DeliveryStatusView.swift index 597e2409..e106d2c1 100644 --- a/bitchat/Views/Components/DeliveryStatusView.swift +++ b/bitchat/Views/Components/DeliveryStatusView.swift @@ -19,6 +19,8 @@ extension DeliveryStatus { return String(localized: "content.delivery.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") + 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"), @@ -80,6 +82,11 @@ struct DeliveryStatusView: View { .font(.bitchatSystem(size: 10)) .foregroundColor(secondaryTextColor.opacity(0.6)) + case .carried: + Image(systemName: "figure.walk") + .font(.bitchatSystem(size: 10)) + .foregroundColor(secondaryTextColor.opacity(0.8)) + case .delivered: HStack(spacing: -2) { Image(systemName: "checkmark") @@ -120,6 +127,7 @@ struct DeliveryStatusView: View { let statuses: [DeliveryStatus] = [ .sending, .sent, + .carried, .delivered(to: "John Doe", at: Date()), .read(by: "Jane Doe", at: Date()), .failed(reason: "Offline"), diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index 71c36c74..eb188ed7 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -22,6 +22,9 @@ struct ContentHeaderView: View { let headerPeerIconSize: CGFloat let headerPeerCountFontSize: CGFloat + /// Courier envelopes this device is carrying for offline third parties. + @State private var carriedMailCount = 0 + var body: some View { HStack(spacing: 0) { Text(verbatim: "bitchat/") @@ -88,6 +91,23 @@ struct ContentHeaderView: View { }() HStack(spacing: 2) { + if carriedMailCount > 0 { + Image(systemName: "figure.walk") + .font(.bitchatSystem(size: 12)) + .foregroundColor(palette.secondary.opacity(0.8)) + .headerTapTarget() + .accessibilityLabel( + String( + format: String(localized: "content.accessibility.carrying_mail", defaultValue: "Carrying %lld sealed messages for friends", comment: "Accessibility label for the courier mail indicator"), + locale: .current, + carriedMailCount + ) + ) + .help( + String(localized: "content.header.carrying_mail", defaultValue: "Carrying sealed messages for friends to deliver", comment: "Tooltip for the courier mail indicator") + ) + } + if appChromeModel.hasUnreadPrivateMessages { Button(action: { appChromeModel.openMostRelevantPrivateChat() }) { Image(systemName: "envelope.fill") @@ -214,6 +234,9 @@ struct ContentHeaderView: View { // Type. .frame(height: headerHeight) .padding(.horizontal, 12) + .onReceive(CourierStore.shared.$carriedCount) { count in + carriedMailCount = count + } .sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) { LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) .environmentObject(locationChannelsModel) diff --git a/bitchat/Views/Media/MediaMessageView.swift b/bitchat/Views/Media/MediaMessageView.swift index cf5194af..90fd13c4 100644 --- a/bitchat/Views/Media/MediaMessageView.swift +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -139,7 +139,7 @@ struct MediaMessageView: View { isSending = true progress = Double(reached) / Double(total) } - case .sent, .read, .delivered, .failed: + case .sent, .carried, .read, .delivered, .failed: break } } diff --git a/bitchatTests/ChatNostrCoordinatorContextTests.swift b/bitchatTests/ChatNostrCoordinatorContextTests.swift index 7244f366..7777310e 100644 --- a/bitchatTests/ChatNostrCoordinatorContextTests.swift +++ b/bitchatTests/ChatNostrCoordinatorContextTests.swift @@ -608,34 +608,6 @@ struct GeoPresenceTrackerTests { #expect(stamped > stale) #expect(context.appendedGeohashMessages.count == 1) } - @Test @MainActor - func handleFavoriteNotification_persistsFavoriteAndPostsLocalNotification() async throws { - let context = MockChatNostrContext() - let coordinator = ChatNostrCoordinator(context: context) - let sender = try NostrIdentity.generate() - let noiseKey = Data(repeating: 0x42, count: 32) - // The favorites store bridges the sender's npub back to a Noise key. - context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( - noiseKey: noiseKey, - nostrPublicKey: sender.npub - ) - - coordinator.handleFavoriteNotification(content: "FAVORITE:TRUE|alice", from: sender.publicKeyHex) - - #expect(context.addedFavorites.count == 1) - #expect(context.addedFavorites.first?.noiseKey == noiseKey) - #expect(context.addedFavorites.first?.nostrPublicKey == sender.publicKeyHex) - #expect(context.addedFavorites.first?.nickname == "alice") - #expect(context.postedLocalNotifications.count == 1) - #expect(context.postedLocalNotifications.first?.title == "New Favorite") - #expect(context.postedLocalNotifications.first?.body == "alice favorited you") - - // Unfavorite: no store write, but the removal notification still posts. - coordinator.handleFavoriteNotification(content: "FAVORITE:FALSE|alice", from: sender.publicKeyHex) - #expect(context.addedFavorites.count == 1) - #expect(context.postedLocalNotifications.last?.title == "Favorite Removed") - #expect(context.postedLocalNotifications.last?.body == "alice unfavorited you") - } @Test @MainActor func geoPresence_sampledActivityNotificationRespectsPerGeohashCooldown() async throws { diff --git a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift index 60f913fa..9e96cabe 100644 --- a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift @@ -225,6 +225,10 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC favoriteRelationshipsByNoiseKey[noiseKey] } + func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? { + favoriteRelationshipsByNoiseKey.first(where: { PeerID(publicKey: $0.key) == peerID })?.value + } + func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) { peerFavoritedUsUpdates.append((noiseKey, favorited, nickname, nostrPublicKey)) } @@ -549,14 +553,14 @@ struct ChatPrivateConversationCoordinatorContextTests { } @Test @MainActor - func handleFavoriteNotificationFromMesh_persistsAndAnnouncesTransitionsOnly() async { + func handleFavoriteNotification_persistsAndAnnouncesTransitionsOnly() async { let context = MockChatPrivateConversationContext() let coordinator = ChatPrivateConversationCoordinator(context: context) let noiseKey = Data(repeating: 0xAB, count: 32) let peerID = PeerID(hexData: noiseKey) // First [FAVORITED] flips theyFavoritedUs: store write + announcement. - coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice") + coordinator.handleFavoriteNotification("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice") #expect(context.peerFavoritedUsUpdates.count == 1) #expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey) #expect(context.peerFavoritedUsUpdates.first?.favorited == true) @@ -568,16 +572,79 @@ struct ChatPrivateConversationCoordinatorContextTests { noiseKey: noiseKey, theyFavoritedUs: true ) - coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice") + coordinator.handleFavoriteNotification("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice") #expect(context.peerFavoritedUsUpdates.count == 2) #expect(context.meshOnlySystemMessages == ["alice favorited you"]) // [UNFAVORITED] transition announces again. - coordinator.handleFavoriteNotificationFromMesh("[UNFAVORITED]", from: peerID, senderNickname: "alice") + coordinator.handleFavoriteNotification("[UNFAVORITED]", from: peerID, senderNickname: "alice") #expect(context.peerFavoritedUsUpdates.last?.favorited == false) #expect(context.meshOnlySystemMessages == ["alice favorited you", "alice unfavorited you"]) } + /// A Nostr DM whose sender resolved to a known noise key must be labeled + /// with the favorite's nickname, not the geohash-scoped anon fallback. + @Test @MainActor + func nostrPrivateMessage_noiseKeyedConversationUsesFavoriteNickname() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let noiseKey = Data(repeating: 0xDA, count: 32) + let convKey = PeerID(hexData: noiseKey) + let senderPubkey = "0badc0de00112233" + // No displayNamesByPubkey entry: the geo fallback would be "anon". + context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( + noiseKey: noiseKey, + nostrPublicKey: "npub1bob", + nickname: "bob", + isFavorite: true, + theyFavoritedUs: true + ) + + let payloadData = PrivateMessagePacket(messageID: "nostr-dm-1", content: "hello from afar").encode()! + let payload = NoisePayload(type: .privateMessage, data: payloadData) + + coordinator.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: convKey, + id: MockChatPrivateConversationContext.dummyIdentity, + messageTimestamp: Date() + ) + + #expect(context.privateChats[convKey]?.first?.sender == "bob") + } + + /// Over Nostr, [FAVORITED] markers arrive as embedded PMs on the convKey + /// path; they must update the relationship, not render as chat text. + @Test @MainActor + func nostrPrivateMessage_favoritedMarkerUpdatesRelationshipInsteadOfAppending() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let noiseKey = Data(repeating: 0xEE, count: 32) + // The inbound pipeline resolves known favorites to their noise-key ID. + let convKey = PeerID(hexData: noiseKey) + let senderPubkey = "feedface99887766" + context.displayNamesByPubkey[senderPubkey] = "alice#1234" + + let payloadData = PrivateMessagePacket(messageID: "fav-1", content: "[FAVORITED]:npub1alice").encode()! + let payload = NoisePayload(type: .privateMessage, data: payloadData) + + coordinator.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: convKey, + id: MockChatPrivateConversationContext.dummyIdentity, + messageTimestamp: Date() + ) + + #expect(context.peerFavoritedUsUpdates.count == 1) + #expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey) + #expect(context.peerFavoritedUsUpdates.first?.favorited == true) + #expect(context.peerFavoritedUsUpdates.first?.nostrPublicKey == "npub1alice") + #expect(context.privateChats[convKey, default: []].isEmpty) + #expect(context.meshOnlySystemMessages == ["alice#1234 favorited you"]) + } + @Test @MainActor func sendPrivateMessage_routesViaMutualFavoriteNostrWhenPeerOffline() async { let context = MockChatPrivateConversationContext() @@ -602,6 +669,32 @@ struct ChatPrivateConversationCoordinatorContextTests { #expect(context.systemMessages.isEmpty) } + /// Same as above, but the conversation is keyed by the SHORT mesh ID — + /// the DM window was opened while the peer was on mesh, then they went + /// out of range. The favorite must resolve via the derived short ID and + /// route over Nostr instead of failing "peer not reachable". + @Test @MainActor + func sendPrivateMessage_routesViaNostrWhenMeshKeyedPeerGoesOffline() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let noiseKey = Data(repeating: 0xCE, count: 32) + let shortID = PeerID(publicKey: noiseKey) + context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( + noiseKey: noiseKey, + nostrPublicKey: "npub1bob", + nickname: "bob", + isFavorite: true, + theyFavoritedUs: true + ) + + coordinator.sendPrivateMessage("hello again", to: shortID) + + #expect(context.routedPrivateMessages.map(\.content) == ["hello again"]) + #expect(context.privateChats[shortID]?.first?.deliveryStatus == .sent) + #expect(context.privateChats[shortID]?.first?.recipientNickname == "bob") + #expect(context.systemMessages.isEmpty) + } + @Test @MainActor func sendPrivateMessage_failsWhenOfflineWithoutMutualFavorite() async { let context = MockChatPrivateConversationContext() diff --git a/bitchatTests/ChatViewModelDeliveryStatusTests.swift b/bitchatTests/ChatViewModelDeliveryStatusTests.swift index 78365b66..7d68a3dc 100644 --- a/bitchatTests/ChatViewModelDeliveryStatusTests.swift +++ b/bitchatTests/ChatViewModelDeliveryStatusTests.swift @@ -428,12 +428,13 @@ struct ChatViewModelDeliveryStatusTests { @Test @MainActor func statusRank_orderingIsCorrect() async { // This tests the implicit ordering used in refreshVisibleMessages - // failed < sending < sent < partiallyDelivered < delivered < read + // failed < sending < sent < carried < partiallyDelivered < delivered < read let statuses: [DeliveryStatus] = [ .failed(reason: "test"), .sending, .sent, + .carried, .partiallyDelivered(reached: 1, total: 3), .delivered(to: "B", at: Date()), .read(by: "C", at: Date()) @@ -446,9 +447,10 @@ struct ChatViewModelDeliveryStatusTests { case .failed: #expect(index == 0) case .sending: #expect(index == 1) case .sent: #expect(index == 2) - case .partiallyDelivered: #expect(index == 3) - case .delivered: #expect(index == 4) - case .read: #expect(index == 5) + case .carried: #expect(index == 3) + case .partiallyDelivered: #expect(index == 4) + case .delivered: #expect(index == 5) + case .read: #expect(index == 6) } } } diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index b993471d..031247bf 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -655,8 +655,10 @@ struct ChatViewModelNostrExtensionTests { #expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey) } + /// An inbound Nostr [FAVORITED] marker must flip theyFavoritedUs and stay + /// out of the conversation transcript. @Test @MainActor - func handleFavoriteNotification_updatesFavoriteAssociation() async throws { + func handlePrivateMessage_nostrFavoritedMarkerUpdatesRelationship() async throws { let (viewModel, _) = makeTestableViewModel() let identity = try NostrIdentity.generate() let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) }) @@ -664,19 +666,33 @@ struct ChatViewModelNostrExtensionTests { FavoritesPersistenceService.shared.addFavorite( peerNoisePublicKey: noiseKey, peerNostrPublicKey: identity.npub, - peerNickname: "Before" + peerNickname: "Alice" ) - defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) } + defer { + FavoritesPersistenceService.shared.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false) + FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) + } - viewModel.handleFavoriteNotification( - content: "FAVORITE:TRUE|NPUB:\(identity.npub)|Alice", - from: identity.publicKeyHex + // The inbound pipeline resolves a known sender to their noise-key ID. + let convKey = PeerID(hexData: noiseKey) + let payloadData = try #require( + PrivateMessagePacket(messageID: "fav-e2e-1", content: "[FAVORITED]:\(identity.npub)").encode() + ) + let payload = NoisePayload(type: .privateMessage, data: payloadData) + + viewModel.handlePrivateMessage( + payload, + senderPubkey: identity.publicKeyHex, + convKey: convKey, + id: identity, + messageTimestamp: Date() ) let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) - #expect(relationship?.peerNickname == "Alice") + #expect(relationship?.theyFavoritedUs == true) + #expect(relationship?.isMutual == true) #expect(relationship?.peerNostrPublicKey == identity.npub) - #expect(relationship?.isFavorite == true) + #expect(viewModel.privateChats[convKey, default: []].isEmpty) } @Test @MainActor diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index 641fdfdd..99bed2b5 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -303,6 +303,50 @@ struct CommandProcessorTests { #expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64))) } + /// /fav must go through toggleFavorite (which persists by the real noise + /// key) — not write the hex peer ID into the favorites store, and not + /// send a second favorite notification. + @MainActor + @Test func favoriteCommandTogglesWithoutDirectStoreWrite() async { + let identityManager = MockIdentityManager(MockKeychain()) + let context = MockCommandContextProvider() + let processor = CommandProcessor( + contextProvider: context, + meshService: MockTransport(), + identityManager: identityManager + ) + let peerID = PeerID(str: "00aa00bb00cc00dd") + context.nicknameToPeerID["alice"] = peerID + + let result = await withSelectedChannel(.mesh, context: context) { + processor.process("/fav alice") + } + + switch result { + case .success(let message): + #expect(message == "added alice to favorites") + default: + Issue.record("Expected success result") + } + #expect(context.toggledFavorites == [peerID]) + #expect(context.favoriteNotifications.isEmpty) + // The 8-byte routing ID must never be stored as a "noise key". + let bogusKey = Data(hexString: peerID.id)! + #expect(FavoritesPersistenceService.shared.getFavoriteStatus(for: bogusKey) == nil) + + // Unfavoriting someone who is not a favorite is a no-op. + let unfavResult = await withSelectedChannel(.mesh, context: context) { + processor.process("/unfav alice") + } + switch unfavResult { + case .success(let message): + #expect(message == "alice is not a favorite") + default: + Issue.record("Expected success result") + } + #expect(context.toggledFavorites == [peerID]) + } + @MainActor @Test func favoriteCommandIsRejectedOutsideMesh() async { let identityManager = MockIdentityManager(MockKeychain()) diff --git a/bitchatTests/CourierStoreTests.swift b/bitchatTests/CourierStoreTests.swift new file mode 100644 index 00000000..9e675649 --- /dev/null +++ b/bitchatTests/CourierStoreTests.swift @@ -0,0 +1,176 @@ +// +// CourierStoreTests.swift +// bitchat +// +// 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 CourierStoreTests { + + private static let baseDate = Date(timeIntervalSince1970: 1_750_000_000) + + private func makeStore(now: Date = baseDate) -> CourierStore { + CourierStore(persistsToDisk: false, now: { now }) + } + + /// Store whose clock can be advanced by tests. + private final class Clock { + var now: Date + init(_ now: Date) { self.now = now } + } + + private func makeEnvelope( + recipientKey: Data = Data(repeating: 0xB0, count: 32), + sealedAt: Date = baseDate, + lifetime: TimeInterval = 60 * 60, + ciphertext: Data = Data((0..<96).map { _ in UInt8.random(in: 0...255) }) + ) -> CourierEnvelope { + CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: recipientKey, + epochDay: CourierEnvelope.epochDay(for: sealedAt) + ), + expiry: UInt64((sealedAt.timeIntervalSince1970 + lifetime) * 1000), + ciphertext: ciphertext + ) + } + + private let depositorA = Data(repeating: 0xA1, count: 32) + private let depositorB = Data(repeating: 0xA2, count: 32) + + // MARK: - Deposit and handover + + @Test func depositThenTakeForRecipient() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey) + + #expect(store.deposit(envelope, from: depositorA)) + let taken = store.takeEnvelopes(for: recipientKey) + #expect(taken == [envelope]) + // Handover removes the envelope. + #expect(store.takeEnvelopes(for: recipientKey).isEmpty) + } + + @Test func takeIgnoresOtherRecipients() { + let store = makeStore() + let envelope = makeEnvelope(recipientKey: Data(repeating: 0xB0, count: 32)) + store.deposit(envelope, from: depositorA) + #expect(store.takeEnvelopes(for: Data(repeating: 0xCC, count: 32)).isEmpty) + #expect(store.takeEnvelopes(for: Data(repeating: 0xB0, count: 32)).count == 1) + } + + @Test func duplicateDepositIsIdempotent() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey) + #expect(store.deposit(envelope, from: depositorA)) + #expect(store.deposit(envelope, from: depositorA)) + #expect(store.takeEnvelopes(for: recipientKey).count == 1) + } + + // MARK: - Validity + + @Test func rejectsExpiredAndOversizedAndMalformed() { + let store = makeStore() + let expired = makeEnvelope(sealedAt: Self.baseDate.addingTimeInterval(-7200), lifetime: 3600) + #expect(!store.deposit(expired, from: depositorA)) + + let oversized = makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1)) + #expect(!store.deposit(oversized, from: depositorA)) + + let badTag = CourierEnvelope( + recipientTag: Data(repeating: 0, count: 4), + expiry: UInt64((Self.baseDate.timeIntervalSince1970 + 3600) * 1000), + ciphertext: Data(repeating: 1, count: 16) + ) + #expect(!store.deposit(badTag, from: depositorA)) + } + + @Test func rejectsExpiryBeyondPolicyLifetime() { + let store = makeStore() + let pinned = makeEnvelope(lifetime: 7 * 24 * 60 * 60) + #expect(!store.deposit(pinned, from: depositorA)) + } + + // MARK: - Quotas + + @Test func perDepositorQuota() { + let store = makeStore() + for _ in 0.. +// + +import Testing +import Foundation +import Combine +import CoreBluetooth +import BitFoundation +@testable import bitchat + +/// Three-node courier flow exercised through real BLEService instances with +/// packets ferried in-process: Alice deposits a sealed envelope with Carol +/// while Bob is unreachable; Carol hands it over when Bob announces; Bob +/// opens it and sees Alice's message in the right DM thread. +struct CourierEndToEndTests { + + // 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 } + } + + func count(ofType type: MessageType) -> Int { + lock.lock(); defer { lock.unlock() } + return packets.filter { $0.type == type.rawValue }.count + } + + func all(ofType type: MessageType) -> [BitchatPacket] { + lock.lock(); defer { lock.unlock() } + return packets.filter { $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(identityManager: MockIdentityManager? = nil) -> BLEService { + let keychain = MockKeychain() + let identityManager = identityManager ?? MockIdentityManager(keychain) + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + let service = BLEService( + keychain: keychain, + idBridge: idBridge, + identityManager: identityManager, + initializeBluetoothManagers: false + ) + service.courierStore = CourierStore(persistsToDisk: false) + return service + } + + /// Handling any packet from a peer preseeds it as a connected, + /// verified entry in the receiving service's registry. + 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) + } + + // MARK: - Tests + + @Test func courierCarriesMessageAcrossDisjointConnectivity() async throws { + let alice = makeService() + let carol = makeService() + let bob = makeService() + // Alice and Carol are mutual favorites; trust policy is exercised + // separately in depositFromUntrustedPeerIsRejected. + carol.courierDepositPolicy = { _ in true } + + 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 + + // Alice can see Carol; Bob is nowhere on the mesh. + preseedConnectedPeer(carol, in: alice) + + // 1. Alice seals to Bob's static key and deposits with Carol. + #expect(alice.sendCourierMessage( + "the camp moved north", + messageID: "courier-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)) + + // 2. Ferry the deposit to Carol; she carries it (opaque to her). + carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID) + let carried = await TestHelpers.waitUntil( + { !carol.courierStore.isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(carried) + + // 3. Later, Bob announces near Carol → handover fires. + bob.sendBroadcastAnnounce() + let announced = await TestHelpers.waitUntil( + { bobOut.first(ofType: .announce) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(announced) + let announcePacket = try #require(bobOut.first(ofType: .announce)) + carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID, preseedPeer: false) + + let handedOver = await TestHelpers.waitUntil( + { carolOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(handedOver) + #expect(carol.courierStore.isEmpty) + let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope)) + #expect(PeerID(hexData: handoverPacket.recipientID) == bob.myPeerID) + + // 4. Ferry the handover to Bob; he opens the envelope. + 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) + // Alice is absent from Bob's mesh, so the sender resolves to her + // full noise-key ID — the stable favorite conversation — not the + // short mesh ID (which Bob couldn't resolve to a nickname) and not + // the courier's identity. + #expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData())) + #expect(delivered.peerID != carol.myPeerID) + let message = try #require(PrivateMessagePacket.decode(from: delivered.payload)) + #expect(message.messageID == "courier-msg-1") + #expect(message.content == "the camp moved north") + } + + @Test func courieredMailFromBlockedSenderIsDropped() async throws { + let alice = makeService() + let carol = makeService() + let bobIdentity = MockIdentityManager(MockKeychain()) + let bob = makeService(identityManager: bobIdentity) + carol.courierDepositPolicy = { _ in true } + + 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) + + // Bob blocked Alice by her stable Noise identity while she was away. + bobIdentity.setBlocked(alice.noiseStaticPublicKeyData().sha256Fingerprint(), isBlocked: true) + + #expect(alice.sendCourierMessage( + "you should not see this", + messageID: "courier-msg-blocked-sender", + 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)) + + carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID) + let carried = await TestHelpers.waitUntil( + { !carol.courierStore.isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(carried) + + bob.sendBroadcastAnnounce() + let announced = await TestHelpers.waitUntil( + { bobOut.first(ofType: .announce) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(announced) + let announcePacket = try #require(bobOut.first(ofType: .announce)) + carol._test_handlePacket(announcePacket, 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)) + + // Bob opens the envelope — but the sealed sender is blocked, and it + // must never reach the UI. The live block check can't cover this: the + // sender is absent from Bob's registry, so no fingerprint resolves at + // delivery time. + bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID) + let delivered = await TestHelpers.waitUntil( + { !bobDelegate.snapshot().isEmpty }, + timeout: TestConstants.shortTimeout + ) + #expect(!delivered) + } + + @Test func unverifiedAnnounceDoesNotTriggerCourierHandover() async throws { + let alice = makeService() + let carol = makeService() + let bob = makeService() + carol.courierDepositPolicy = { _ in true } + + 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) + + #expect(alice.sendCourierMessage( + "hold until verified", + messageID: "courier-msg-unverified-announce", + 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)) + + carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID) + let carried = await TestHelpers.waitUntil( + { !carol.courierStore.isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(carried) + + let forgedAnnounce = try makeUnsignedAnnounce(from: bob) + carol._test_handlePacket(forgedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false) + + let leakedOnUnverifiedAnnounce = await TestHelpers.waitUntil( + { carolOut.count(ofType: .courierEnvelope) > 0 }, + timeout: TestConstants.shortTimeout + ) + #expect(!leakedOnUnverifiedAnnounce) + #expect(!carol.courierStore.isEmpty) + + bob.sendBroadcastAnnounce() + let announced = await TestHelpers.waitUntil( + { bobOut.first(ofType: .announce) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(announced) + let verifiedAnnounce = try #require(bobOut.first(ofType: .announce)) + carol._test_handlePacket(verifiedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false) + + let handedOver = await TestHelpers.waitUntil( + { carolOut.count(ofType: .courierEnvelope) == 1 }, + timeout: TestConstants.defaultTimeout + ) + #expect(handedOver) + #expect(carol.courierStore.isEmpty) + } + + @Test func relayedAnnounceDoesNotTriggerCourierHandover() async throws { + let alice = makeService() + let carol = makeService() + let bob = makeService() + carol.courierDepositPolicy = { _ in true } + + 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) + + #expect(alice.sendCourierMessage( + "hold for a direct encounter", + messageID: "courier-msg-relayed-announce", + 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)) + + carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID) + let carried = await TestHelpers.waitUntil( + { !carol.courierStore.isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(carried) + + bob.sendBroadcastAnnounce() + let announced = await TestHelpers.waitUntil( + { bobOut.first(ofType: .announce) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(announced) + let directAnnounce = try #require(bobOut.first(ofType: .announce)) + + // A relayed copy has a decremented TTL but a still-valid signature + // (TTL is excluded from announce signatures). Envelopes are removed + // from the store optimistically, so handover must wait for a direct + // encounter instead of chasing a multi-hop path. + var relayedAnnounce = directAnnounce + relayedAnnounce.ttl = directAnnounce.ttl - 1 + carol._test_handlePacket(relayedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false) + + let leakedOnRelayedAnnounce = await TestHelpers.waitUntil( + { carolOut.count(ofType: .courierEnvelope) > 0 }, + timeout: TestConstants.shortTimeout + ) + #expect(!leakedOnRelayedAnnounce) + #expect(!carol.courierStore.isEmpty) + + // The relayed copy consumed the original announce's dedup key + // (sender/timestamp/payload — TTL excluded), so the direct handover + // needs a fresh announce. Wait out the 1s announce throttle first. + try await Task.sleep(nanoseconds: 1_100_000_000) + bob.sendBroadcastAnnounce() + let reannounced = await TestHelpers.waitUntil( + { bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp } }, + timeout: TestConstants.defaultTimeout + ) + #expect(reannounced) + let freshAnnounce = try #require( + bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp } + ) + carol._test_handlePacket(freshAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false) + + let handedOver = await TestHelpers.waitUntil( + { carolOut.count(ofType: .courierEnvelope) == 1 }, + timeout: TestConstants.defaultTimeout + ) + #expect(handedOver) + #expect(carol.courierStore.isEmpty) + } + + @Test func sendCourierMessageRejectsInvalidRecipientKeyBeforeQueueing() async throws { + let alice = makeService() + let carol = makeService() + preseedConnectedPeer(carol, in: alice) + + let aliceOut = PacketTap() + alice._test_onOutboundPacket = aliceOut.record + + #expect(!alice.sendCourierMessage( + "this cannot be sealed", + messageID: "courier-msg-invalid-key", + recipientNoiseKey: Data(repeating: 0x01, count: 8), + via: [carol.myPeerID] + )) + + let queuedPacket = await TestHelpers.waitUntil( + { aliceOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.shortTimeout + ) + #expect(!queuedPacket) + } + + @Test func depositFromUntrustedPeerIsRejected() async throws { + let carol = makeService() + carol.courierDepositPolicy = { _ in false } // depositor is not a mutual favorite + + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData() + let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "x", messageID: "m1")) + let sealed = try alice.sealCourierPayload(typedPayload, recipientStaticKey: bobKey) + let now = Date() + let envelope = CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: bobKey, + epochDay: CourierEnvelope.epochDay(for: now) + ), + expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000), + ciphertext: sealed + ) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let packet = BitchatPacket( + type: MessageType.courierEnvelope.rawValue, + senderID: Data(hexString: alicePeerID.id) ?? Data(), + recipientID: Data(hexString: carol.myPeerID.id), + timestamp: UInt64(now.timeIntervalSince1970 * 1000), + payload: try #require(envelope.encode()), + signature: nil, + ttl: 1 + ) + + carol._test_handlePacket(packet, fromPeerID: alicePeerID) + let stored = await TestHelpers.waitUntil( + { !carol.courierStore.isEmpty }, + timeout: TestConstants.shortTimeout + ) + #expect(!stored) + } + + @Test func courierDepositTrustUsesIngressPeerNotClaimedSender() async throws { + let alice = makeService() + let carol = makeService() + let mallory = makeService() + preseedConnectedPeer(alice, in: carol) + preseedConnectedPeer(mallory, in: carol) + + let trustedAliceKey = Data(hexString: alice.myPeerID.id) ?? Data() + carol.courierDepositPolicy = { depositorKey in + depositorKey == trustedAliceKey + } + + let aliceNoise = NoiseEncryptionService(keychain: MockKeychain()) + let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData() + let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "spoofed", messageID: "m-spoof")) + let sealed = try aliceNoise.sealCourierPayload(typedPayload, recipientStaticKey: bobKey) + let now = Date() + let envelope = CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: bobKey, + epochDay: CourierEnvelope.epochDay(for: now) + ), + expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000), + ciphertext: sealed + ) + let packet = BitchatPacket( + type: MessageType.courierEnvelope.rawValue, + senderID: Data(hexString: alice.myPeerID.id) ?? Data(), + recipientID: Data(hexString: carol.myPeerID.id), + timestamp: UInt64(now.timeIntervalSince1970 * 1000), + payload: try #require(envelope.encode()), + signature: nil, + ttl: 1 + ) + + carol._test_handlePacket(packet, fromPeerID: mallory.myPeerID, preseedPeer: false) + let stored = await TestHelpers.waitUntil( + { !carol.courierStore.isEmpty }, + timeout: TestConstants.shortTimeout + ) + #expect(!stored) + } + + private func makeUnsignedAnnounce(from service: BLEService) throws -> BitchatPacket { + let announcement = AnnouncementPacket( + nickname: "Unsigned", + noisePublicKey: service.noiseStaticPublicKeyData(), + signingPublicKey: service.noiseSigningPublicKeyData(), + directNeighbors: nil + ) + let payload = try #require(announcement.encode()) + + return BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: service.myPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + } +} + +// MARK: - Router courier selection + +/// Minimal transport stub for exercising MessageRouter's courier deposit +/// logic without BLE plumbing. +private final class CourierCaptureTransport: Transport { + weak var delegate: BitchatDelegate? + weak var eventDelegate: TransportEventDelegate? + weak var peerEventsDelegate: TransportPeerEventsDelegate? + + var snapshots: [TransportPeerSnapshot] = [] + private(set) var courierSends: [(messageID: String, recipientKey: Data, couriers: [PeerID])] = [] + private(set) var directSends: [String] = [] + + var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { + Just(snapshots).eraseToAnyPublisher() + } + func currentPeerSnapshots() -> [TransportPeerSnapshot] { snapshots } + + var myPeerID = PeerID(str: "00000000000000aa") + var myNickname = "stub" + func setNickname(_ nickname: String) {} + + func startServices() {} + func stopServices() {} + func emergencyDisconnectAll() {} + + func isPeerConnected(_ peerID: PeerID) -> Bool { + snapshots.contains { $0.peerID == peerID && $0.isConnected } + } + func isPeerReachable(_ peerID: PeerID) -> Bool { isPeerConnected(peerID) } + func peerNickname(peerID: PeerID) -> String? { nil } + func getPeerNicknames() -> [PeerID: String] { [:] } + + func getFingerprint(for peerID: PeerID) -> String? { nil } + func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none } + func triggerHandshake(with peerID: PeerID) {} + + func sendMessage(_ content: String, mentions: [String]) {} + func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { + directSends.append(messageID) + } + func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {} + func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {} + func sendBroadcastAnnounce() {} + func sendDeliveryAck(for messageID: String, to peerID: PeerID) {} + + func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { + courierSends.append((messageID, recipientNoiseKey, couriers)) + return true + } +} + +struct MessageRouterCourierTests { + + @Test @MainActor + func unreachablePeerMessageGoesToTrustedCouriersOnly() { + let bobKey = Data(repeating: 0xB0, count: 32) + let bobID = PeerID(publicKey: bobKey) + let carolKey = Data(repeating: 0xC0, count: 32) + let carolID = PeerID(publicKey: carolKey) + let daveKey = Data(repeating: 0xD0, count: 32) + let daveID = PeerID(publicKey: daveKey) + + let transport = CourierCaptureTransport() + transport.snapshots = [ + // Carol: connected mutual favorite → eligible courier. + TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date()), + // Dave: connected but not trusted → never a courier. + TransportPeerSnapshot(peerID: daveID, nickname: "dave", isConnected: true, noisePublicKey: daveKey, lastSeen: Date()) + ] + + let directory = CourierDirectory( + noiseKey: { peerID in peerID == bobID ? bobKey : nil }, + isTrustedCourier: { $0 == carolKey } + ) + let router = MessageRouter(transports: [transport], courierDirectory: directory) + var carried: [String] = [] + router.onMessageCarried = { messageID, _ in carried.append(messageID) } + + router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m1") + + #expect(transport.directSends.isEmpty) + #expect(transport.courierSends.count == 1) + #expect(transport.courierSends.first?.messageID == "m1") + #expect(transport.courierSends.first?.recipientKey == bobKey) + #expect(transport.courierSends.first?.couriers == [carolID]) + #expect(carried == ["m1"]) + } + + @Test @MainActor + func noCourierDepositWithoutKnownRecipientKey() { + let transport = CourierCaptureTransport() + transport.snapshots = [ + TransportPeerSnapshot(peerID: PeerID(str: "00000000000000cc"), nickname: "carol", isConnected: true, noisePublicKey: Data(repeating: 0xC0, count: 32), lastSeen: Date()) + ] + let directory = CourierDirectory(noiseKey: { _ in nil }, isTrustedCourier: { _ in true }) + let router = MessageRouter(transports: [transport], courierDirectory: directory) + var carried: [String] = [] + router.onMessageCarried = { messageID, _ in carried.append(messageID) } + + router.sendPrivate("hi", to: PeerID(str: "00000000000000bb"), recipientNickname: "bob", messageID: "m2") + + #expect(transport.courierSends.isEmpty) + #expect(carried.isEmpty) + } + + /// The production directory must resolve both ID forms: a 64-hex + /// noise-key ID (offline favorite row) carries the key itself, and a + /// short 16-hex ID resolves through the favorites store. + @Test @MainActor + func favoritesBackedDirectoryResolvesBothIDForms() { + let directory = CourierDirectory.favoritesBacked() + let bobKey = Data(repeating: 0xB7, count: 32) + + #expect(directory.noiseKey(PeerID(hexData: bobKey)) == bobKey) + + FavoritesPersistenceService.shared.addFavorite(peerNoisePublicKey: bobKey, peerNickname: "bob") + defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: bobKey) } + #expect(directory.noiseKey(PeerID(publicKey: bobKey)) == bobKey) + } + + @Test @MainActor + func reachablePeerSkipsCourier() { + let bobKey = Data(repeating: 0xB0, count: 32) + let bobID = PeerID(publicKey: bobKey) + let transport = CourierCaptureTransport() + transport.snapshots = [ + TransportPeerSnapshot(peerID: bobID, nickname: "bob", isConnected: true, noisePublicKey: bobKey, lastSeen: Date()) + ] + let directory = CourierDirectory(noiseKey: { _ in bobKey }, isTrustedCourier: { _ in true }) + let router = MessageRouter(transports: [transport], courierDirectory: directory) + + router.sendPrivate("hi", to: bobID, recipientNickname: "bob", messageID: "m3") + + #expect(transport.directSends == ["m3"]) + #expect(transport.courierSends.isEmpty) + } +} diff --git a/bitchatTests/NoiseCourierTests.swift b/bitchatTests/NoiseCourierTests.swift new file mode 100644 index 00000000..1403e520 --- /dev/null +++ b/bitchatTests/NoiseCourierTests.swift @@ -0,0 +1,107 @@ +// +// NoiseCourierTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import bitchat + +/// One-way Noise X envelopes: encryption to a known static key without an +/// interactive handshake, used by the courier store-and-forward path. +struct NoiseCourierTests { + + @Test func sealAndOpenRoundTrip() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + + let payload = Data("meet at the north gate".utf8) + let sealed = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData()) + + let opened = try bob.openCourierPayload(sealed) + #expect(opened.payload == payload) + // The X pattern authenticates the sender: Bob learns Alice's real static key. + #expect(opened.senderStaticKey == alice.getStaticPublicKeyData()) + } + + @Test func wrongRecipientCannotOpen() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let carol = NoiseEncryptionService(keychain: MockKeychain()) + + let sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData()) + + #expect(throws: (any Error).self) { + _ = try carol.openCourierPayload(sealed) + } + } + + @Test func tamperedEnvelopeFailsToOpen() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + + var sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData()) + sealed[sealed.count - 1] ^= 0x01 + + #expect(throws: (any Error).self) { + _ = try bob.openCourierPayload(sealed) + } + } + + @Test func senderIdentityCannotBeForged() throws { + // The encrypted static key inside the envelope is bound by the ss DH; + // splicing one envelope's ephemeral prefix onto another must fail. + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let mallory = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + + let bobKey = bob.getStaticPublicKeyData() + let fromAlice = try alice.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey) + let fromMallory = try mallory.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey) + + // e (32 bytes) from Mallory's envelope + rest from Alice's. + let spliced = fromMallory.prefix(32) + fromAlice.dropFirst(32) + #expect(throws: (any Error).self) { + _ = try bob.openCourierPayload(Data(spliced)) + } + } + + @Test func sealRejectsInvalidRecipientKey() { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + #expect(throws: (any Error).self) { + _ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 0, count: 32)) + } + #expect(throws: (any Error).self) { + _ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 1, count: 8)) + } + } + + @Test func emptyAndLargePayloadsRoundTrip() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bobKey = bob.getStaticPublicKeyData() + + let empty = try alice.sealCourierPayload(Data(), recipientStaticKey: bobKey) + #expect(try bob.openCourierPayload(empty).payload.isEmpty) + + let large = Data((0..<8192).map { UInt8($0 % 251) }) + let sealed = try alice.sealCourierPayload(large, recipientStaticKey: bobKey) + #expect(try bob.openCourierPayload(sealed).payload == large) + } + + @Test func envelopesAreNotLinkableAcrossSends() throws { + // Fresh ephemeral per seal: same payload to the same recipient must + // produce entirely different ciphertexts. + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let payload = Data("same message".utf8) + + let a = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData()) + let b = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData()) + #expect(a != b) + #expect(a.prefix(32) != b.prefix(32)) + } +} diff --git a/bitchatTests/Services/BLEAnnounceHandlerTests.swift b/bitchatTests/Services/BLEAnnounceHandlerTests.swift index 80944ed4..d53a50b3 100644 --- a/bitchatTests/Services/BLEAnnounceHandlerTests.swift +++ b/bitchatTests/Services/BLEAnnounceHandlerTests.swift @@ -98,8 +98,12 @@ struct BLEAnnounceHandlerTests { recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result?.peerID == peerID) + #expect(result?.announcement.noisePublicKey == noiseKey) + #expect(result?.isDirectAnnounce == true) + #expect(result?.isVerified == true) #expect(recorder.verifySignatureCalls.count == 1) #expect(recorder.verifySignatureCalls.first?.signingPublicKey == Data(repeating: 0x99, count: 32)) #expect(recorder.barrierCount == 1) @@ -161,8 +165,11 @@ struct BLEAnnounceHandlerTests { let recorder = Recorder() let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result?.peerID == peerID) + #expect(result?.announcement.noisePublicKey == noiseKey) + #expect(result?.isVerified == false) #expect(recorder.verifySignatureCalls.isEmpty) #expect(recorder.barrierCount == 1) #expect(recorder.upsertCalls.isEmpty) @@ -197,8 +204,9 @@ struct BLEAnnounceHandlerTests { recorder.signatureValid = false let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result?.isVerified == false) #expect(recorder.verifySignatureCalls.count == 1) #expect(recorder.upsertCalls.isEmpty) #expect(recorder.uiEventDeliveries.count == 1) @@ -222,8 +230,9 @@ struct BLEAnnounceHandlerTests { let recorder = Recorder() let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result == nil) expectNoSideEffects(recorder) } @@ -242,8 +251,9 @@ struct BLEAnnounceHandlerTests { let recorder = Recorder() let handler = makeHandler(recorder: recorder, localPeerID: peerID, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result == nil) expectNoSideEffects(recorder) } @@ -263,8 +273,9 @@ struct BLEAnnounceHandlerTests { let recorder = Recorder() let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result == nil) expectNoSideEffects(recorder) } @@ -311,8 +322,10 @@ struct BLEAnnounceHandlerTests { recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result?.isDirectAnnounce == false) + #expect(result?.isVerified == true) #expect(recorder.upsertCalls.count == 1) #expect(recorder.upsertCalls.first?.isConnected == false) #expect(recorder.uiEventDeliveries.count == 1) @@ -384,8 +397,9 @@ struct BLEAnnounceHandlerTests { recorder.existingNoisePublicKey = Data(repeating: 0xAA, count: 32) let handler = makeHandler(recorder: recorder, now: now) - handler.handle(packet, from: peerID) + let result = handler.handle(packet, from: peerID) + #expect(result?.isVerified == false) #expect(recorder.upsertCalls.isEmpty) #expect(recorder.uiEventDeliveries.count == 1) #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) diff --git a/bitchatTests/Services/BLEFanoutSelectorTests.swift b/bitchatTests/Services/BLEFanoutSelectorTests.swift index 43146365..deec44f5 100644 --- a/bitchatTests/Services/BLEFanoutSelectorTests.swift +++ b/bitchatTests/Services/BLEFanoutSelectorTests.swift @@ -19,6 +19,79 @@ struct BLEFanoutSelectorTests { #expect(selection.centralIDs == Set(["c2"])) } + @Test + func directedSendUsesOnlyBoundPeripheralLinkWhenAvailable() { + let target = PeerID(str: "1122334455667788") + let bystander = PeerID(str: "8877665544332211") + let selection = BLEFanoutSelector.selectLinks( + peripheralIDs: ["target-p", "bystander-p"], + centralIDs: ["target-c", "bystander-c"], + ingressLink: nil, + peripheralPeerBindings: [ + "target-p": target, + "bystander-p": bystander + ], + centralPeerBindings: [ + "target-c": target, + "bystander-c": bystander + ], + directedPeerHint: target, + packetType: MessageType.courierEnvelope.rawValue, + messageID: "message-1" + ) + + #expect(selection.peripheralIDs == Set(["target-p"])) + #expect(selection.centralIDs.isEmpty) + } + + @Test + func directedSendUsesBoundCentralLinkWhenNoPeripheralLinkExists() { + let target = PeerID(str: "1122334455667788") + let bystander = PeerID(str: "8877665544332211") + let selection = BLEFanoutSelector.selectLinks( + peripheralIDs: ["bystander-p"], + centralIDs: ["target-c", "bystander-c"], + ingressLink: nil, + peripheralPeerBindings: [ + "bystander-p": bystander + ], + centralPeerBindings: [ + "target-c": target, + "bystander-c": bystander + ], + directedPeerHint: target, + packetType: MessageType.courierEnvelope.rawValue, + messageID: "message-1" + ) + + #expect(selection.peripheralIDs.isEmpty) + #expect(selection.centralIDs == Set(["target-c"])) + } + + @Test + func directedSendToKnownPeerDoesNotFallBackWhenOnlyDirectLinkIsExcluded() { + let target = PeerID(str: "1122334455667788") + let bystander = PeerID(str: "8877665544332211") + let selection = BLEFanoutSelector.selectLinks( + peripheralIDs: ["bystander-p"], + centralIDs: ["target-c", "bystander-c"], + ingressLink: .central("target-c"), + peripheralPeerBindings: [ + "bystander-p": bystander + ], + centralPeerBindings: [ + "target-c": target, + "bystander-c": bystander + ], + directedPeerHint: target, + packetType: MessageType.courierEnvelope.rawValue, + messageID: "message-1" + ) + + #expect(selection.peripheralIDs.isEmpty) + #expect(selection.centralIDs.isEmpty) + } + @Test func directedSendExcludesAllLinksToIngressPeer() { let selection = BLEFanoutSelector.selectLinks( diff --git a/bitchatTests/Services/FavoritesPersistenceServiceTests.swift b/bitchatTests/Services/FavoritesPersistenceServiceTests.swift index 729fda15..aea80020 100644 --- a/bitchatTests/Services/FavoritesPersistenceServiceTests.swift +++ b/bitchatTests/Services/FavoritesPersistenceServiceTests.swift @@ -49,6 +49,21 @@ final class FavoritesPersistenceServiceTests: XCTestCase { XCTAssertFalse(service.isMutualFavorite(peerKey)) } + func test_updatePeerFavoritedUs_keepsStoredNicknameOverUnknownPlaceholder() { + let service = FavoritesPersistenceService(keychain: MockKeychain()) + let peerKey = Data((128..<160).map(UInt8.init)) + + service.addFavorite(peerNoisePublicKey: peerKey, peerNickname: "Erin") + + // A notification arriving before the peer is known passes "Unknown". + service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Unknown") + XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Erin") + + // A real nickname still updates the stored one. + service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Erin2") + XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Erin2") + } + func test_getFavoriteStatus_forPeerID_returnsMutualFavorite() { let service = FavoritesPersistenceService(keychain: MockKeychain()) let peerKey = Data((96..<128).map(UInt8.init)) diff --git a/bitchatTests/Services/NostrTransportTests.swift b/bitchatTests/Services/NostrTransportTests.swift index 1af2bc46..9a359fb1 100644 --- a/bitchatTests/Services/NostrTransportTests.swift +++ b/bitchatTests/Services/NostrTransportTests.swift @@ -42,7 +42,9 @@ struct NostrTransportTests { ) ) - #expect(!transport.isPeerReachable(fullPeerID)) + // Offline favorites are addressed by the full 64-hex noise key, so + // both forms must resolve to the same reachability answer. + #expect(transport.isPeerReachable(fullPeerID)) #expect(transport.isPeerReachable(shortPeerID)) #expect(!transport.isPeerReachable(PeerID(str: "feedfeedfeedfeed"))) } diff --git a/bitchatTests/Services/UnifiedPeerServiceTests.swift b/bitchatTests/Services/UnifiedPeerServiceTests.swift index bf94a0ba..f5a5fd6c 100644 --- a/bitchatTests/Services/UnifiedPeerServiceTests.swift +++ b/bitchatTests/Services/UnifiedPeerServiceTests.swift @@ -66,6 +66,117 @@ struct UnifiedPeerServiceTests { #expect(!service.isBlocked(peerID)) } + // MARK: - Offline-favorite dedup (updatePeers phase 2) + + /// A mutual favorite that is also on the mesh must collapse to a single + /// row keyed by the short mesh ID — even when the announced nickname no + /// longer matches the one stored with the favorite. + @Test @MainActor + func updatePeers_mutualFavoriteOnMeshYieldsSingleRow() async { + let favoritesService = FavoritesPersistenceService.shared + + let transport = MockTransport() + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager()) + + let noiseKey = Data(repeating: 0xAB, count: 32) + favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "alice") + favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true) + defer { + favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false) + favoritesService.removeFavorite(peerNoisePublicKey: noiseKey) + } + + let meshID = PeerID(publicKey: noiseKey) + let snapshots = [TransportPeerSnapshot( + peerID: meshID, + nickname: "alice-renamed", + isConnected: true, + noisePublicKey: noiseKey, + lastSeen: Date() + )] + transport.updatePeerSnapshots(snapshots) + service.didUpdatePeerSnapshots(snapshots) + + let rows = service.peers.filter { $0.noisePublicKey == noiseKey } + #expect(rows.count == 1) + #expect(rows.first?.peerID == meshID) + #expect(rows.first?.isMutualFavorite == true) + #expect(service.favorites.filter { $0.noisePublicKey == noiseKey }.count == 1) + } + + /// Same collapse must hold for a reachable-but-not-connected favorite + /// (relayed peers linger as "reachable" after their link drops). + @Test @MainActor + func updatePeers_reachableMutualFavoriteYieldsSingleRow() async { + let favoritesService = FavoritesPersistenceService.shared + + let transport = MockTransport() + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager()) + + let noiseKey = Data(repeating: 0xCD, count: 32) + favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "bob") + favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true) + defer { + favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false) + favoritesService.removeFavorite(peerNoisePublicKey: noiseKey) + } + + let otherKey = Data(repeating: 0x11, count: 32) + let snapshots = [ + // A live link is required for anyone to count as reachable. + TransportPeerSnapshot( + peerID: PeerID(publicKey: otherKey), + nickname: "carol", + isConnected: true, + noisePublicKey: otherKey, + lastSeen: Date() + ), + TransportPeerSnapshot( + peerID: PeerID(publicKey: noiseKey), + nickname: "bob", + isConnected: false, + noisePublicKey: noiseKey, + lastSeen: Date() + ) + ] + transport.updatePeerSnapshots(snapshots) + service.didUpdatePeerSnapshots(snapshots) + + let bobRows = service.peers.filter { $0.noisePublicKey == noiseKey } + #expect(bobRows.count == 1) + #expect(bobRows.first?.peerID == PeerID(publicKey: noiseKey)) + #expect(bobRows.first?.isReachable == true) + } + + /// A mutual favorite with no mesh presence still gets its offline row, + /// keyed by the full noise-key PeerID. + @Test @MainActor + func updatePeers_offlineMutualFavoriteGetsOfflineRow() async { + let favoritesService = FavoritesPersistenceService.shared + + let transport = MockTransport() + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager()) + + let noiseKey = Data(repeating: 0xEF, count: 32) + favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "dave") + favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true) + defer { + favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false) + favoritesService.removeFavorite(peerNoisePublicKey: noiseKey) + } + + transport.updatePeerSnapshots([]) + service.didUpdatePeerSnapshots([]) + + let rows = service.peers.filter { $0.noisePublicKey == noiseKey } + #expect(rows.count == 1) + #expect(rows.first?.peerID == PeerID(hexData: noiseKey)) + #expect(rows.first?.isMutualFavorite == true) + } + @Test @MainActor func setBlocked_unknownIdentityReturnsNil() async { let transport = MockTransport() diff --git a/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift b/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift new file mode 100644 index 00000000..4bb99de3 --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift @@ -0,0 +1,147 @@ +// +// CourierEnvelope.swift +// BitFoundation +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +private import CryptoKit + +/// TLV payload for store-and-forward courier envelopes. +/// +/// A courier envelope lets a mutual favorite physically carry an encrypted +/// message to a peer who is currently offline. The envelope is opaque to the +/// courier: the only routing information is a rotating recipient tag derived +/// from the recipient's Noise static public key and the UTC day, so envelopes +/// addressed to the same peer on different days do not correlate for +/// observers who don't already know that peer's public key. +public struct CourierEnvelope: Equatable { + /// Rotating recipient hint: HMAC-SHA256(recipient static key, context || epoch day), truncated. + public let recipientTag: Data + /// Milliseconds since epoch after which the envelope must be discarded. + public let expiry: UInt64 + /// Opaque one-way Noise X ciphertext (sender identity rides inside). + public let ciphertext: Data + + public static let tagLength = 16 + /// Couriered messages are text-sized; media transfers are out of scope. + public static let maxCiphertextBytes = 16 * 1024 + /// Matches the outbox retention policy in MessageRouter. + public static let maxLifetimeSeconds: TimeInterval = 24 * 60 * 60 + + private enum TLVType: UInt8 { + case recipientTag = 0x01 + case expiry = 0x02 + case ciphertext = 0x03 + } + + public init(recipientTag: Data, expiry: UInt64, ciphertext: Data) { + self.recipientTag = recipientTag + self.expiry = expiry + self.ciphertext = ciphertext + } + + public var isExpired: Bool { + isExpired(at: Date()) + } + + public func isExpired(at date: Date) -> Bool { + UInt64(max(0, date.timeIntervalSince1970 * 1000)) >= expiry + } + + public func encode() -> Data? { + guard recipientTag.count == Self.tagLength else { return nil } + guard !ciphertext.isEmpty, ciphertext.count <= Self.maxCiphertextBytes else { return nil } + + func appendBE(_ value: T, into data: inout Data) { + var big = value.bigEndian + withUnsafeBytes(of: &big) { data.append(contentsOf: $0) } + } + + var encoded = Data() + encoded.reserveCapacity(3 * 3 + Self.tagLength + 8 + ciphertext.count) + + encoded.append(TLVType.recipientTag.rawValue) + appendBE(UInt16(recipientTag.count), into: &encoded) + encoded.append(recipientTag) + + encoded.append(TLVType.expiry.rawValue) + appendBE(UInt16(8), into: &encoded) + appendBE(expiry, into: &encoded) + + encoded.append(TLVType.ciphertext.rawValue) + appendBE(UInt16(ciphertext.count), into: &encoded) + encoded.append(ciphertext) + + return encoded + } + + public static func decode(_ data: Data) -> CourierEnvelope? { + var cursor = data.startIndex + let end = data.endIndex + + var recipientTag: Data? + var expiry: UInt64? + var ciphertext: 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 <= maxCiphertextBytes else { return nil } + ciphertext = Data(value) + case nil: + // Unknown TLV: skip for forward compatibility. + continue + } + } + + guard let recipientTag, let expiry, let ciphertext else { return nil } + return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext) + } + + // MARK: - Recipient Tags + + private static let tagContext = Data("bitchat-courier-tag-v1".utf8) + + /// UTC day number used to rotate recipient tags. + public static func epochDay(for date: Date) -> UInt32 { + UInt32(max(0, date.timeIntervalSince1970) / 86_400) + } + + /// Rotating recipient hint for a given day. Computable only by parties + /// who already know the recipient's Noise static public key. + public static func recipientTag(noiseStaticKey: Data, epochDay: UInt32) -> Data { + var message = tagContext + withUnsafeBytes(of: epochDay.bigEndian) { message.append(contentsOf: $0) } + let mac = HMAC.authenticationCode(for: message, using: SymmetricKey(data: noiseStaticKey)) + return Data(mac).prefix(tagLength) + } + + /// Tags to test when checking whether an envelope is addressed to a peer. + /// Covers the adjacent days so envelopes sealed near midnight (or across + /// modest clock skew) still match while being carried. + public static func candidateTags(noiseStaticKey: Data, around date: Date) -> [Data] { + let day = epochDay(for: date) + return [day == 0 ? 0 : day - 1, day, day + 1].map { + recipientTag(noiseStaticKey: noiseStaticKey, epochDay: $0) + } + } +} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift b/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift index 8f06ff74..32fa4e76 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift @@ -11,17 +11,20 @@ import struct Foundation.Date public enum DeliveryStatus: Codable, Equatable, Hashable { case sending case sent // Left our device + case carried // Sealed envelope handed to a courier; best-effort physical delivery case delivered(to: String, at: Date) // Confirmed by recipient case read(by: String, at: Date) // Seen by recipient case failed(reason: String) case partiallyDelivered(reached: Int, total: Int) // For rooms - + public var displayText: String { switch self { case .sending: return "Sending..." case .sent: return "Sent" + case .carried: + return "Carried by a friend" case .delivered(let nickname, _): return "Delivered to \(nickname)" case .read(let nickname, _): diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift index 5c54781f..ceac0cd0 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift @@ -12,8 +12,9 @@ public enum MessageType: UInt8 { // Public messages (unencrypted) case announce = 0x01 // "I'm here" with nickname - case message = 0x02 // Public chat message + case message = 0x02 // Public chat message 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 @@ -29,6 +30,7 @@ public enum MessageType: UInt8 { case .announce: return "announce" case .message: return "message" case .leave: return "leave" + case .courierEnvelope: return "courierEnvelope" case .requestSync: return "requestSync" case .noiseHandshake: return "noiseHandshake" case .noiseEncrypted: return "noiseEncrypted" diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift new file mode 100644 index 00000000..a64551ab --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift @@ -0,0 +1,122 @@ +// +// CourierEnvelopeTests.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 CourierEnvelopeTests { + + private func makeEnvelope( + tag: Data = Data(repeating: 0xAB, count: CourierEnvelope.tagLength), + expiry: UInt64 = 1_900_000_000_000, + ciphertext: Data = Data(repeating: 0x42, count: 128) + ) -> CourierEnvelope { + CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext) + } + + // MARK: - Codec + + @Test func roundTrip() throws { + let envelope = makeEnvelope() + let encoded = try #require(envelope.encode()) + let decoded = try #require(CourierEnvelope.decode(encoded)) + #expect(decoded == envelope) + } + + @Test func roundTripAtMaxCiphertextSize() throws { + let envelope = makeEnvelope(ciphertext: Data(repeating: 0x01, count: CourierEnvelope.maxCiphertextBytes)) + let encoded = try #require(envelope.encode()) + let decoded = try #require(CourierEnvelope.decode(encoded)) + #expect(decoded == envelope) + } + + @Test func encodeRejectsInvalidFields() { + #expect(makeEnvelope(tag: Data(repeating: 0, count: 8)).encode() == nil) + #expect(makeEnvelope(ciphertext: Data()).encode() == nil) + #expect(makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1)).encode() == nil) + } + + @Test func decodeRejectsMissingFields() throws { + // Strip the trailing ciphertext TLV: tag(3+16) + expiry(3+8) only. + let encoded = try #require(makeEnvelope().encode()) + let truncated = encoded.prefix(3 + CourierEnvelope.tagLength + 3 + 8) + #expect(CourierEnvelope.decode(Data(truncated)) == nil) + } + + @Test func decodeRejectsTruncatedValue() throws { + let encoded = try #require(makeEnvelope().encode()) + #expect(CourierEnvelope.decode(encoded.dropLast(1)) == nil) + } + + @Test func decodeSkipsUnknownTLVs() throws { + var encoded = try #require(makeEnvelope().encode()) + // Append an unknown TLV (type 0x7F, 2-byte value); decoder must tolerate it. + encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD]) + let decoded = try #require(CourierEnvelope.decode(encoded)) + #expect(decoded == makeEnvelope()) + } + + @Test func decodeOffsetSlice() throws { + // Decoder must handle slices with non-zero startIndex. + let encoded = try #require(makeEnvelope().encode()) + let padded = Data([0xFF, 0xFF]) + encoded + let slice = padded.dropFirst(2) + #expect(CourierEnvelope.decode(Data(slice)) == makeEnvelope()) + #expect(CourierEnvelope.decode(slice) == makeEnvelope()) + } + + // MARK: - Expiry + + @Test func expiryComparison() { + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + #expect(!makeEnvelope(expiry: nowMs + 60_000).isExpired) + #expect(makeEnvelope(expiry: nowMs - 60_000).isExpired) + #expect(makeEnvelope(expiry: 0).isExpired) + } + + // MARK: - Recipient Tags + + @Test func tagIsDeterministicPerKeyAndDay() { + let key = Data(repeating: 0x11, count: 32) + let tag1 = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: 20_000) + let tag2 = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: 20_000) + #expect(tag1 == tag2) + #expect(tag1.count == CourierEnvelope.tagLength) + } + + @Test func tagRotatesAcrossDaysAndKeys() { + let key = Data(repeating: 0x11, count: 32) + let otherKey = Data(repeating: 0x22, count: 32) + let day: UInt32 = 20_000 + #expect(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day) + != CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day + 1)) + #expect(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day) + != CourierEnvelope.recipientTag(noiseStaticKey: otherKey, epochDay: day)) + } + + @Test func candidateTagsCoverAdjacentDays() { + let key = Data(repeating: 0x33, count: 32) + let date = Date(timeIntervalSince1970: 1_750_000_000) + let day = CourierEnvelope.epochDay(for: date) + let candidates = CourierEnvelope.candidateTags(noiseStaticKey: key, around: date) + #expect(candidates.count == 3) + #expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day - 1))) + #expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day))) + #expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day + 1))) + } + + @Test func sealedYesterdayMatchesToday() { + // An envelope sealed late on day D must still match the recipient on day D+1. + let key = Data(repeating: 0x44, count: 32) + let sealedAt = Date(timeIntervalSince1970: 1_750_000_000) + let deliveredAt = sealedAt.addingTimeInterval(20 * 60 * 60) + let tag = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: CourierEnvelope.epochDay(for: sealedAt)) + #expect(CourierEnvelope.candidateTags(noiseStaticKey: key, around: deliveredAt).contains(tag)) + } +}