From 1e766b8d4f29332adecdaf11ef4d492762f2bea8 Mon Sep 17 00:00:00 2001 From: Dev Date: Mon, 27 Jul 2026 20:17:16 +0300 Subject: [PATCH] feat: add pairwise Nostr double ratchet --- .github/workflows/swift-tests.yml | 2 +- .gitignore | 2 +- .gitmodules | 6 +- README.md | 8 +- bitchat/Nostr/NostrRelayManager.swift | 57 +- ...BLEOutboundFragmentTransferScheduler.swift | 24 +- bitchat/Services/BLE/BLEService.swift | 190 +- bitchat/Services/DoubleRatchetFeature.swift | 6 +- .../FavoritesPersistenceService.swift | 714 +++- bitchat/Services/KeychainManager.swift | 26 +- bitchat/Services/NdrNostrService.swift | 1838 ++++++---- bitchat/Services/NostrTransport.swift | 159 +- bitchat/Services/Transport.swift | 12 +- .../ChatPrivateConversationCoordinator.swift | 43 - .../ChatTransportEventCoordinator.swift | 420 ++- bitchat/ViewModels/ChatViewModel.swift | 58 +- .../ChatViewModelBootstrapper.swift | 12 + .../Extensions/ChatViewModel+Nostr.swift | 50 +- .../ChatViewModel+PrivateChat.swift | 15 - bitchat/ViewModels/NostrInboundPipeline.swift | 206 +- .../ChatNostrCoordinatorContextTests.swift | 100 + ...eConversationCoordinatorContextTests.swift | 42 - bitchatTests/ChatViewModelTests.swift | 117 + .../NdrOutOfBandTransportTests.swift | 3200 +++++++++++++---- .../EndToEnd/PrivateMediaEndToEndTests.swift | 30 +- bitchatTests/Mocks/MockKeychain.swift | 8 + bitchatTests/Mocks/MockTransport.swift | 37 + .../BLEOutboundFragmentPlannerTests.swift | 80 + .../BLEOutboundWriteBufferTests.swift | 40 + .../FavoritesPersistenceServiceTests.swift | 530 +++ .../Services/NostrRelayManagerTests.swift | 153 + .../Services/NostrTransportTests.swift | 526 ++- localPackages/NdrFfi/Package.swift | 2 +- localPackages/NdrFfi/README.md | 27 +- localPackages/NdrFfi/SOURCE_REVISION | 2 +- .../NdrFfi/Sources/NdrFfi/NdrFfi.swift | 1056 +++--- localPackages/NdrFfi/Tests/NdrFfiTests.swift | 505 ++- localPackages/NdrFfi/VENDORED_FROM.md | 18 +- localPackages/NdrFfi/build-apple.sh | 43 +- vendor/iris-chat-rs | 1 - vendor/nostr-double-ratchet | 1 + 41 files changed, 7931 insertions(+), 2435 deletions(-) delete mode 160000 vendor/iris-chat-rs create mode 160000 vendor/nostr-double-ratchet diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml index 31cb7b66..e8aee5ea 100644 --- a/.github/workflows/swift-tests.yml +++ b/.github/workflows/swift-tests.yml @@ -37,7 +37,7 @@ jobs: ~/.cargo/registry ~/.cargo/git .cache/ndr-ffi/apple/target - key: ndr-ffi-apple-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('localPackages/NdrFfi/RUST_TOOLCHAIN', 'localPackages/NdrFfi/SOURCE_REVISION', 'localPackages/NdrFfi/build-apple.sh', 'vendor/iris-chat-rs/protocol-ffi/Cargo.lock') }} + key: ndr-ffi-apple-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('localPackages/NdrFfi/RUST_TOOLCHAIN', 'localPackages/NdrFfi/SOURCE_REVISION', 'localPackages/NdrFfi/build-apple.sh', 'vendor/nostr-double-ratchet/rust/Cargo.lock') }} - name: Build Apple FFI from source run: ./localPackages/NdrFfi/build-apple.sh diff --git a/.gitignore b/.gitignore index 0c94a2a2..b4e59fc6 100644 --- a/.gitignore +++ b/.gitignore @@ -82,5 +82,5 @@ build.log Local.xcconfig *.profraw -# Built from the pinned iris-chat-rs submodule; never commit native crypto. +# Built from the pinned nostr-double-ratchet submodule; never commit native crypto. localPackages/NdrFfi/Frameworks/NdrFfi.xcframework/ diff --git a/.gitmodules b/.gitmodules index 51a9ad20..743725be 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ -[submodule "vendor/iris-chat-rs"] - path = vendor/iris-chat-rs - url = https://github.com/irislib/iris-chat-rs.git +[submodule "vendor/nostr-double-ratchet"] + path = vendor/nostr-double-ratchet + url = https://github.com/irislib/nostr-double-ratchet.git shallow = true diff --git a/README.md b/README.md index c821a22c..7013d8c6 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ Initialize the pinned Rust source and build its generated Apple framework once before opening the project or running `swift test` directly: ```bash -git submodule update --init --checkout vendor/iris-chat-rs +git submodule update --init --checkout vendor/nostr-double-ratchet rustup toolchain install "$(cat localPackages/NdrFfi/RUST_TOOLCHAIN)" --profile minimal rustup target add \ aarch64-apple-darwin \ @@ -119,9 +119,9 @@ rustup target add \ ``` The generated XCFramework is ignored; every checkout builds it from the exact -`iris-chat-rs` source revision and locked Rust dependency graph recorded in the -repository. `just build`, `just run`, and `just test` perform the build step -automatically. +`nostr-double-ratchet` source revision and locked Rust dependency graph recorded +in the repository. `just build`, `just run`, and `just test` perform the build +step automatically. ### Option 1: Using Xcode diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index ce096d3b..6013e5e6 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -221,9 +221,6 @@ final class NostrRelayManager: ObservableObject { private var duplicateInboundEventDropCount = 0 private var duplicateInboundEventDropCountBySubscription: [String: Int] = [:] private var inboundEventLogCount = 0 - // Coalesce duplicate subscribe requests for the same id within a short window. - private let subscribeCoalesceInterval: TimeInterval = 1.0 - private var subscribeCoalesce: [String: Date] = [:] private var pendingTorConnectionURLs = Set() private var awaitingTorForConnections = false private var torReadyWaitAttempts = 0 @@ -489,7 +486,6 @@ final class NostrRelayManager: ObservableObject { pendingSubscriptions.removeAll() messageHandlers.removeAll() subscriptionRequestState.removeAll() - subscribeCoalesce.removeAll() eoseTrackers.removeAll() pendingEOSECallbacks.removeAll() pendingTorConnectionURLs.removeAll() @@ -735,23 +731,19 @@ final class NostrRelayManager: ObservableObject { return connection } - /// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays. + /// Subscribe to events matching a filter. If `relayUrls` provided, targets + /// only those relays. Returns true only after the replayable subscription + /// intent has been registered, even when every target is still offline. + @discardableResult func subscribe( filter: NostrFilter, id: String = UUID().uuidString, relayUrls: [String]? = nil, handler: @escaping (NostrEvent) -> Void, onEOSE: (() -> Void)? = nil - ) { + ) -> Bool { // Global network policy gate - guard dependencies.activationAllowed() else { return } - // Coalesce rapid duplicate subscribe requests even while Tor readiness is pending. - let now = dependencies.now() - if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval { - return - } - subscribeCoalesce[id] = now - messageHandlers[id] = handler + guard dependencies.activationAllowed() else { return false } let req = NostrRequest.subscribe(id: id, filters: [filter]) @@ -759,7 +751,7 @@ final class NostrRelayManager: ObservableObject { let message = try encoder.encode(req) guard let messageString = String(data: message, encoding: .utf8) else { SecureLogger.error("❌ Failed to encode subscription request", category: .session) - return + return false } // SecureLogger.debug("📋 Subscription filter JSON: \(messageString.prefix(200))...", category: .session) @@ -767,10 +759,16 @@ final class NostrRelayManager: ObservableObject { // Target specific relays if provided; else default. Filter permanently failed relays. let baseUrls = relayUrls ?? defaultRelays let urls = allowedRelayList(from: baseUrls).filter { !isPermanentlyFailed($0) } - let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls)) - if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) { - return + guard !urls.isEmpty else { + onEOSE?() + return false } + let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls)) + messageHandlers[id] = handler + if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) { + return true + } + subscriptionRequestState[id] = requestState // Always queue subscriptions; sending happens when a relay reports connected @@ -801,8 +799,13 @@ final class NostrRelayManager: ObservableObject { flushPendingSubscriptions(for: url) } } + // `subscriptionRequestState` is the canonical replay intent. + // Pending REQs are bounded/expiring accelerators and may be + // evicted; reconnect rehydrates them from this exact state. + return subscriptionRequestState[id] == requestState } catch { SecureLogger.error("❌ Failed to encode subscription request: \(error)", category: .session) + return false } } @@ -873,8 +876,6 @@ final class NostrRelayManager: ObservableObject { messageHandlers.removeValue(forKey: id) removeRecentInboundEvents(forSubscriptionID: id) duplicateInboundEventDropCountBySubscription.removeValue(forKey: id) - // Allow immediate re-subscription by clearing coalescer timestamp - subscribeCoalesce.removeValue(forKey: id) subscriptionRequestState.removeValue(forKey: id) pendingEOSECallbacks.removeValue(forKey: id) eoseTrackers.removeValue(forKey: id) @@ -1344,10 +1345,20 @@ final class NostrRelayManager: ObservableObject { } } case .ok(let eventId, let success, let reason): - resolveConfirmedSend(eventID: eventId, relayURL: relayUrl, accepted: success) - if success { + let durablyAccepted = + success + || (!success && reason.hasPrefix("duplicate:")) + resolveConfirmedSend( + eventID: eventId, + relayURL: relayUrl, + accepted: durablyAccepted + ) + if durablyAccepted { _ = Self.pendingGiftWrapIDs.remove(eventId) - SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session) + SecureLogger.debug( + "✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", + category: .session + ) } else { let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil if isGiftWrap { diff --git a/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift b/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift index 722e55c4..0febbb0b 100644 --- a/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift +++ b/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift @@ -9,6 +9,8 @@ struct BLEOutboundFragmentTransferRequest { let transferId: String? let requireDirectPeerLink: Bool let requireNoiseAuthenticatedPeerLink: Bool + let requiredAuthenticatedTransportState: + AuthenticatedPeerTransportState? init( packet: BitchatPacket, @@ -17,7 +19,9 @@ struct BLEOutboundFragmentTransferRequest { directedPeer: PeerID?, transferId: String?, requireDirectPeerLink: Bool = false, - requireNoiseAuthenticatedPeerLink: Bool = false + requireNoiseAuthenticatedPeerLink: Bool = false, + requiredAuthenticatedTransportState: + AuthenticatedPeerTransportState? = nil ) { self.packet = packet self.pad = pad @@ -26,6 +30,8 @@ struct BLEOutboundFragmentTransferRequest { self.transferId = transferId self.requireDirectPeerLink = requireDirectPeerLink self.requireNoiseAuthenticatedPeerLink = requireNoiseAuthenticatedPeerLink + self.requiredAuthenticatedTransportState = + requiredAuthenticatedTransportState } var resolvedTransferId: String? { @@ -43,6 +49,22 @@ struct BLEOutboundFragmentTransferRequest { } } +enum BLEAuthenticatedTransportAdmission { + static func isCurrent( + expected: AuthenticatedPeerTransportState, + current: AuthenticatedPeerTransportState? + ) -> Bool { + current == expected + } + + static func writePriority( + ordinaryPriority: BLEOutboundWritePriority, + requiresExactGeneration: Bool + ) -> BLEOutboundWritePriority { + requiresExactGeneration ? .high : ordinaryPriority + } +} + /// Transactional admission for strict fragment trains. Durable callers may /// commit only when every fragment was accepted; the first rejection stops /// the train and reports failure so the original remains retryable. diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index ec01bd60..36cc2315 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -2132,12 +2132,17 @@ final class BLEService: NSObject { private func broadcastPacket( _ packet: BitchatPacket, transferId: String? = nil, - requiresPrivateMediaAdmission: Bool = false + requiresPrivateMediaAdmission: Bool = false, + requireNoiseAuthenticatedPeerLink: Bool = false, + requiredAuthenticatedTransportState: + AuthenticatedPeerTransportState? = nil, + admissionCompletion: ((Bool) -> Void)? = nil ) { guard !isPanicSuspended else { if requiresPrivateMediaAdmission, let transferId { privateMediaTransferAdmissions.finish(transferId) } + admissionCompletion?(false) return } if requiresPrivateMediaAdmission { @@ -2146,12 +2151,33 @@ final class BLEService: NSObject { if let transferId { privateMediaTransferAdmissions.finish(transferId) } + admissionCompletion?(false) + return + } + } + if let requiredAuthenticatedTransportState { + guard requireNoiseAuthenticatedPeerLink, + let recipientPeerID = + PeerID(hexData: packet.recipientID), + BLEAuthenticatedTransportAdmission.isCurrent( + expected: requiredAuthenticatedTransportState, + current: authenticatedPeerTransportState( + recipientPeerID + ) + ) + else { + admissionCompletion?(false) return } } // Apply route if recipient exists (centralized route application) let packetToSend: BitchatPacket - if let recipientPeerID = PeerID(hexData: packet.recipientID) { + if requireNoiseAuthenticatedPeerLink { + // Durable NDR handoff must remain on the exact authenticated + // direct link. Routing or process-local spooling would make a + // positive admission result ambiguous. + packetToSend = packet + } else if let recipientPeerID = PeerID(hexData: packet.recipientID) { packetToSend = applyRouteIfAvailable(packet, to: recipientPeerID) } else { packetToSend = packet @@ -2207,6 +2233,7 @@ final class BLEService: NSObject { if requiresPrivateMediaAdmission { privateMediaTransferAdmissions.finish(transferId) } + admissionCompletion?(false) return } } @@ -2220,6 +2247,7 @@ final class BLEService: NSObject { if let transferId { privateMediaTransferAdmissions.finish(transferId) } + admissionCompletion?(false) return } } @@ -2237,6 +2265,7 @@ final class BLEService: NSObject { transferId: transferId, requiresPrivateMediaAdmission: requiresPrivateMediaAdmission ) + admissionCompletion?(true) return } // App-initiated private media is already one opaque Noise ciphertext. @@ -2253,6 +2282,7 @@ final class BLEService: NSObject { transferId: transferId, requiresPrivateMediaAdmission: requiresPrivateMediaAdmission ) + admissionCompletion?(true) return } if requiresPrivateMediaAdmission { @@ -2263,24 +2293,49 @@ final class BLEService: NSObject { "Private media admission reached an unsupported non-directed packet shape", category: .security ) + admissionCompletion?(false) return } guard let data = packetToSend.toBinaryData(padding: padForBLE) else { SecureLogger.error("❌ Failed to convert packet to binary data", category: .session) + admissionCompletion?(false) + return + } + if requireNoiseAuthenticatedPeerLink { + guard packetToSend.type == MessageType.noiseEncrypted.rawValue, + let recipientPeerID = + PeerID(hexData: packetToSend.recipientID) + else { + admissionCompletion?(false) + return + } + let accepted = sendOnAllLinks( + packet: packetToSend, + data: data, + pad: padForBLE, + directedOnlyPeer: recipientPeerID, + requireNoiseAuthenticatedPeerLink: true, + requiredAuthenticatedTransportState: + requiredAuthenticatedTransportState + ) + admissionCompletion?(accepted) return } if packetToSend.type == MessageType.noiseEncrypted.rawValue { sendEncrypted(packetToSend, data: data, pad: padForBLE) + admissionCompletion?(true) return } sendGenericBroadcast(packetToSend, data: data, pad: padForBLE) + admissionCompletion?(true) } private func sendEncrypted(_ packet: BitchatPacket, data: Data, pad: Bool) { guard let recipientPeerID = PeerID(hexData: packet.recipientID) else { return } var sentEncrypted = false - let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data) + let outboundPriority = + BLEOutboundPacketPolicy.priority(for: packet, data: data) // Per-link limits for the specific peer let directPeripheralState = snapshotDirectPeripheralState(for: recipientPeerID) @@ -2389,11 +2444,21 @@ final class BLEService: NSObject { centrals: [CBCentral], characteristic: CBMutableCharacteristic, context: String, - requiredAuthenticatedPeer: PeerID? + requiredAuthenticatedPeer: PeerID?, + requiredAuthenticatedTransportState: + AuthenticatedPeerTransportState? ) -> Bool { let accept = { [self] in let eligible: [CBCentral] if let peerID = requiredAuthenticatedPeer { + if let requiredAuthenticatedTransportState { + guard BLEAuthenticatedTransportAdmission.isCurrent( + expected: requiredAuthenticatedTransportState, + current: authenticatedPeerTransportState(peerID) + ) else { + return false + } + } eligible = centrals.filter { central in let link = BLEIngressLinkID.central(central.identifier.uuidString) return noiseAuthenticatedLinkOwners[link] == peerID @@ -2430,9 +2495,24 @@ final class BLEService: NSObject { pad: Bool, directedOnlyPeer: PeerID?, requireDirectPeerLink: Bool = false, - requireNoiseAuthenticatedPeerLink: Bool = false + requireNoiseAuthenticatedPeerLink: Bool = false, + requiredAuthenticatedTransportState: + AuthenticatedPeerTransportState? = nil ) -> Bool { guard !isPanicSuspended else { return false } + if let requiredAuthenticatedTransportState { + guard requireNoiseAuthenticatedPeerLink, + let directedOnlyPeer, + BLEAuthenticatedTransportAdmission.isCurrent( + expected: requiredAuthenticatedTransportState, + current: authenticatedPeerTransportState( + directedOnlyPeer + ) + ) + else { + return false + } + } let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) } var excludedPeerLinks = links(to: ingressRecord?.peerID) if requireNoiseAuthenticatedPeerLink { @@ -2442,7 +2522,20 @@ final class BLEService: NSObject { guard !authenticatedLinks.isEmpty else { return false } excludedPeerLinks.formUnion(boundLinks.subtracting(authenticatedLinks)) } - let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data) + // Once an authenticated OOB fragment is admitted, the native action + // may be durably acknowledged. Keep those frames at FIFO-high + // priority so later traffic rejects itself instead of evicting an + // already-committed fragment from the bounded write queue. + let outboundPriority = + BLEAuthenticatedTransportAdmission.writePriority( + ordinaryPriority: + BLEOutboundPacketPolicy.priority( + for: packet, + data: data + ), + requiresExactGeneration: + requireNoiseAuthenticatedPeerLink + ) let states = snapshotPeripheralStates() // A link without a discovered characteristic cannot be written to @@ -2487,12 +2580,16 @@ final class BLEService: NSObject { maxChunk: chunk, directedOnlyPeer: directedOnlyPeer, requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink, - requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink + requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink, + requiredAuthenticatedTransportState: + requiredAuthenticatedTransportState ) } // If directed and we currently have no links to forward on, spool for a short window - if let only = plan.directedPeerHint, + if !requireDirectPeerLink, + !requireNoiseAuthenticatedPeerLink, + let only = plan.directedPeerHint, plan.shouldSpoolDirectedPacket { spoolDirectedPacket(packet, recipientPeerID: only) } @@ -2510,7 +2607,9 @@ final class BLEService: NSObject { to: s.peripheral, characteristic: ch, priority: outboundPriority, - requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil + requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil, + requiredAuthenticatedTransportState: + requiredAuthenticatedTransportState ) || acceptedByPhysicalLink } else { writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority) @@ -2527,7 +2626,9 @@ final class BLEService: NSObject { centrals: targets, characteristic: ch, context: "directed", - requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil + requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil, + requiredAuthenticatedTransportState: + requiredAuthenticatedTransportState ) || acceptedByPhysicalLink } else { let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false @@ -2550,7 +2651,9 @@ final class BLEService: NSObject { _ packet: BitchatPacket, to peerID: PeerID, requireDirectPeerLink: Bool = false, - requireNoiseAuthenticatedPeerLink: Bool = false + requireNoiseAuthenticatedPeerLink: Bool = false, + requiredAuthenticatedTransportState: + AuthenticatedPeerTransportState? = nil ) -> Bool { #if DEBUG _test_onOutboundPacket?(packet) @@ -2562,7 +2665,9 @@ final class BLEService: NSObject { pad: false, directedOnlyPeer: peerID, requireDirectPeerLink: requireDirectPeerLink, - requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink + requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink, + requiredAuthenticatedTransportState: + requiredAuthenticatedTransportState ) } @@ -3052,18 +3157,23 @@ final class BLEService: NSObject { func sendNdrEvent( to peerID: PeerID, eventJson: String, - expectedTransportState: AuthenticatedPeerTransportState + expectedTransportState: AuthenticatedPeerTransportState, + completion: @escaping @MainActor (Bool) -> Void ) { guard let data = eventJson.data(using: .utf8), !data.isEmpty, data.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { + Task { @MainActor in completion(false) } return } let normalizedPeerID = peerID.toShort() messageQueue.async { [weak self] in - guard let self, - self.doubleRatchetEnabled, + guard let self else { + Task { @MainActor in completion(false) } + return + } + guard self.doubleRatchetEnabled, let authenticated = self.authenticatedPeerTransportState(normalizedPeerID), authenticated == expectedTransportState, authenticated.capabilities.contains(.doubleRatchet) @@ -3072,6 +3182,7 @@ final class BLEService: NSObject { "NDR: dropping OOB send without the expected authenticated capability proof", category: .security ) + Task { @MainActor in completion(false) } return } let typedPayload = NoisePayload(type: .ndrEvent, data: data).encode() @@ -3081,12 +3192,21 @@ final class BLEService: NSObject { to: normalizedPeerID, expectedSessionGeneration: authenticated.sessionGeneration ) - self.broadcastPacket(packet) + self.broadcastPacket( + packet, + requireNoiseAuthenticatedPeerLink: true, + requiredAuthenticatedTransportState: + expectedTransportState, + admissionCompletion: { accepted in + Task { @MainActor in completion(accepted) } + } + ) } catch { SecureLogger.warning( "NDR: dropping OOB send because the authenticated Noise generation changed", category: .security ) + Task { @MainActor in completion(false) } } } } @@ -6372,7 +6492,9 @@ extension BLEService { to peripheral: CBPeripheral, characteristic: CBCharacteristic, priority: BLEOutboundWritePriority, - requiredAuthenticatedPeer: PeerID? + requiredAuthenticatedPeer: PeerID?, + requiredAuthenticatedTransportState: + AuthenticatedPeerTransportState? ) -> Bool { let accept = { [self] in let uuid = peripheral.identifier.uuidString @@ -6382,6 +6504,14 @@ extension BLEService { return false } if let peerID = requiredAuthenticatedPeer { + if let requiredAuthenticatedTransportState { + guard BLEAuthenticatedTransportAdmission.isCurrent( + expected: requiredAuthenticatedTransportState, + current: authenticatedPeerTransportState(peerID) + ) else { + return false + } + } let link = BLEIngressLinkID.peripheral(uuid) guard state.peerID == peerID, noiseAuthenticatedLinkOwners[link] == peerID else { @@ -6780,6 +6910,8 @@ extension BLEService { transferId: String? = nil, requireDirectPeerLink: Bool = false, requireNoiseAuthenticatedPeerLink: Bool = false, + requiredAuthenticatedTransportState: + AuthenticatedPeerTransportState? = nil, requiresPrivateMediaAdmission: Bool = false ) -> Bool { let request = BLEOutboundFragmentTransferRequest( @@ -6789,7 +6921,9 @@ extension BLEService { directedPeer: directedOnlyPeer, transferId: transferId, requireDirectPeerLink: requireDirectPeerLink, - requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink + requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink, + requiredAuthenticatedTransportState: + requiredAuthenticatedTransportState ) let result: BLEOutboundFragmentTransferScheduler.SubmitResult? = collectionsQueue.sync(flags: .barrier) { @@ -6916,12 +7050,30 @@ extension BLEService { let sendFragment: (BitchatPacket) -> Bool = { [weak self] fragmentPacket in guard let self else { return false } + if let expected = + request.requiredAuthenticatedTransportState + { + guard let directedPeer = request.directedPeer, + BLEAuthenticatedTransportAdmission.isCurrent( + expected: expected, + current: + self.authenticatedPeerTransportState( + directedPeer + ) + ) + else { + return false + } + } if request.requireDirectPeerLink, let directedPeer = request.directedPeer { return self.sendPacketDirected( fragmentPacket, to: directedPeer, requireDirectPeerLink: true, - requireNoiseAuthenticatedPeerLink: request.requireNoiseAuthenticatedPeerLink + requireNoiseAuthenticatedPeerLink: + request.requireNoiseAuthenticatedPeerLink, + requiredAuthenticatedTransportState: + request.requiredAuthenticatedTransportState ) } self.broadcastPacket(fragmentPacket) diff --git a/bitchat/Services/DoubleRatchetFeature.swift b/bitchat/Services/DoubleRatchetFeature.swift index 02c88242..72a6039a 100644 --- a/bitchat/Services/DoubleRatchetFeature.swift +++ b/bitchat/Services/DoubleRatchetFeature.swift @@ -2,9 +2,9 @@ import Foundation /// Rollout gate for the cross-platform double-ratchet transport. /// -/// Keep this disabled until the coordinated iOS/Android kind-1402 migration -/// tracked by PR #1437 is complete. Source builds and tests can exercise the -/// implementation without advertising or routing production traffic. +/// Keep this disabled until the pairwise NDR implementations are reviewed and +/// ready to be enabled together on iOS and Android. Source builds and tests can +/// exercise it without advertising or routing production traffic. enum DoubleRatchetFeature { #if BITCHAT_ENABLE_NDR static let isEnabled = true diff --git a/bitchat/Services/FavoritesPersistenceService.swift b/bitchat/Services/FavoritesPersistenceService.swift index 84abe5d3..e73845eb 100644 --- a/bitchat/Services/FavoritesPersistenceService.swift +++ b/bitchat/Services/FavoritesPersistenceService.swift @@ -7,7 +7,7 @@ import Combine @MainActor final class FavoritesPersistenceService: ObservableObject { - struct FavoriteRelationship: Codable { + struct FavoriteRelationship: Codable, Equatable { let peerNoisePublicKey: Data let peerNostrPublicKey: String? let peerNickname: String @@ -27,15 +27,43 @@ final class FavoritesPersistenceService: ObservableObject { private static let storageKey = "chat.bitchat.favorites" private static let keychainService = "chat.bitchat.favorites" + private static let pendingNostrIdentityRebindKey = + "chat.bitchat.favorites.ndr-rebind-journal" + private static let ndrRequiredNoiseKeysKey = + "chat.bitchat.favorites.ndr-required-noise-keys" + + private struct PendingNostrIdentityRebind: Codable, Equatable { + let peerNoisePublicKey: Data + let oldNostrPublicKey: String + let targetRelationship: FavoriteRelationship + } + + private struct NostrIdentityAssignment { + let requiresVerifiedCommit: Bool + } + private let keychain: KeychainManagerProtocol @Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship @Published private(set) var mutualFavorites: Set = [] + private var nostrIdentityRebindAuthorizationOwner: UUID? + private var nostrIdentityRebindAuthorizationRequired = + DoubleRatchetFeature.isEnabled + private var authorizeNostrIdentityRebind: + ((Data, String?, String) -> Bool)? + private var commitNostrIdentityRebind: + ((Data, String, String) -> Bool)? + private var pendingNostrIdentityRebind: + PendingNostrIdentityRebind? + private var ndrRequiredNoiseKeys = Set() + private var ndrBindingStorageUnreadable = false static let shared = FavoritesPersistenceService() init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) { self.keychain = keychain + loadNdrRequiredNoiseKeys() + loadPendingNostrIdentityRebind() loadFavorites() // Update mutual favorites when favorites change @@ -45,6 +73,33 @@ final class FavoritesPersistenceService: ObservableObject { } .assign(to: &$mutualFavorites) } + + /// Installs the single app-lifetime NDR rebind authority. Ownership keeps + /// a retiring view model from clearing a newer view model's guard. + func installNostrIdentityRebindAuthorization( + owner: UUID, + required: Bool, + authorize: @escaping (Data, String?, String) -> Bool, + commit: @escaping (Data, String, String) -> Bool + ) { + nostrIdentityRebindAuthorizationOwner = owner + nostrIdentityRebindAuthorizationRequired = + required || DoubleRatchetFeature.isEnabled + authorizeNostrIdentityRebind = authorize + commitNostrIdentityRebind = commit + recoverPendingNostrIdentityRebindIfPossible() + } + + func removeNostrIdentityRebindAuthorization(owner: UUID) { + guard nostrIdentityRebindAuthorizationOwner == owner else { + return + } + nostrIdentityRebindAuthorizationOwner = nil + authorizeNostrIdentityRebind = nil + commitNostrIdentityRebind = nil + nostrIdentityRebindAuthorizationRequired = + DoubleRatchetFeature.isEnabled + } /// Add or update a favorite func addFavorite( @@ -52,27 +107,74 @@ final class FavoritesPersistenceService: ObservableObject { peerNostrPublicKey: String? = nil, peerNickname: String ) { + guard pendingNostrIdentityRebind?.peerNoisePublicKey + != peerNoisePublicKey + else { + SecureLogger.error( + "Favorite mutation blocked by pending NDR rebind journal", + category: .security + ) + return + } SecureLogger.info("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session) let existing = favorites[peerNoisePublicKey] + let effectiveNostrPublicKey = Self.preservingEquivalentNostrKey( + existing: existing?.peerNostrPublicKey, + requested: peerNostrPublicKey + ) let relationship = FavoriteRelationship( peerNoisePublicKey: peerNoisePublicKey, - peerNostrPublicKey: peerNostrPublicKey ?? existing?.peerNostrPublicKey, + peerNostrPublicKey: + effectiveNostrPublicKey ?? existing?.peerNostrPublicKey, peerNickname: peerNickname, isFavorite: true, theyFavoritedUs: existing?.theyFavoritedUs ?? false, favoritedAt: existing?.favoritedAt ?? Date(), lastUpdated: Date() ) + + let assignment: NostrIdentityAssignment + if let effectiveNostrPublicKey, + existing?.peerNostrPublicKey != effectiveNostrPublicKey + { + guard let authorized = beginNostrIdentityAssignment( + peerNoisePublicKey: peerNoisePublicKey, + oldNostrPublicKey: existing?.peerNostrPublicKey, + newNostrPublicKey: effectiveNostrPublicKey, + targetRelationship: relationship + ) else { + SecureLogger.error( + "Refusing unauthorized favorite Nostr identity assignment", + category: .security + ) + return + } + assignment = authorized + } else { + assignment = NostrIdentityAssignment( + requiresVerifiedCommit: false + ) + } // Log if this creates a mutual favorite if relationship.isMutual { SecureLogger.info("💕 Mutual favorite relationship established with \(peerNickname)!", category: .session) } - favorites[peerNoisePublicKey] = relationship - saveFavorites() + var updatedFavorites = favorites + updatedFavorites[peerNoisePublicKey] = relationship + if assignment.requiresVerifiedCommit { + guard persistFavorites(updatedFavorites) else { + return + } + favorites = updatedFavorites + finishPendingNostrIdentityRebind() + } else { + favorites = updatedFavorites + saveFavorites() + } // Notify observers NotificationCenter.default.post( @@ -84,6 +186,15 @@ final class FavoritesPersistenceService: ObservableObject { /// Remove a favorite func removeFavorite(peerNoisePublicKey: Data) { + guard pendingNostrIdentityRebind?.peerNoisePublicKey + != peerNoisePublicKey + else { + SecureLogger.error( + "Favorite removal blocked by pending NDR rebind journal", + category: .security + ) + return + } guard let existing = favorites[peerNoisePublicKey] else { return } SecureLogger.info("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session) @@ -124,6 +235,15 @@ final class FavoritesPersistenceService: ObservableObject { peerNickname: String? = nil, peerNostrPublicKey: String? = nil ) { + guard pendingNostrIdentityRebind?.peerNoisePublicKey + != peerNoisePublicKey + else { + SecureLogger.error( + "Favorite mutation blocked by pending NDR rebind journal", + category: .security + ) + return + } let existing = favorites[peerNoisePublicKey] // Callers that can't resolve the live nickname pass the "Unknown" // placeholder (e.g. a notification arriving before the announce); @@ -135,22 +255,52 @@ final class FavoritesPersistenceService: ObservableObject { SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session) + let effectiveNostrPublicKey = Self.preservingEquivalentNostrKey( + existing: existing?.peerNostrPublicKey, + requested: peerNostrPublicKey + ) let relationship = FavoriteRelationship( peerNoisePublicKey: peerNoisePublicKey, - peerNostrPublicKey: peerNostrPublicKey ?? existing?.peerNostrPublicKey, + peerNostrPublicKey: + effectiveNostrPublicKey ?? existing?.peerNostrPublicKey, peerNickname: displayName, isFavorite: existing?.isFavorite ?? false, theyFavoritedUs: favorited, favoritedAt: existing?.favoritedAt ?? Date(), lastUpdated: Date() ) + + let assignment: NostrIdentityAssignment + if let effectiveNostrPublicKey, + existing?.peerNostrPublicKey != effectiveNostrPublicKey + { + guard let authorized = beginNostrIdentityAssignment( + peerNoisePublicKey: peerNoisePublicKey, + oldNostrPublicKey: existing?.peerNostrPublicKey, + newNostrPublicKey: effectiveNostrPublicKey, + targetRelationship: relationship + ) else { + SecureLogger.error( + "Refusing unauthorized favorite Nostr identity assignment", + category: .security + ) + return + } + assignment = authorized + } else { + assignment = NostrIdentityAssignment( + requiresVerifiedCommit: false + ) + } + + var updatedFavorites = favorites if !relationship.isFavorite && !relationship.theyFavoritedUs { // Neither side favorites, remove completely - favorites.removeValue(forKey: peerNoisePublicKey) + updatedFavorites.removeValue(forKey: peerNoisePublicKey) // Removed - neither side favorites anymore } else { - favorites[peerNoisePublicKey] = relationship + updatedFavorites[peerNoisePublicKey] = relationship // Check if this creates a mutual favorite if relationship.isMutual { @@ -158,7 +308,16 @@ final class FavoritesPersistenceService: ObservableObject { } } - saveFavorites() + if assignment.requiresVerifiedCommit { + guard persistFavorites(updatedFavorites) else { + return + } + favorites = updatedFavorites + finishPendingNostrIdentityRebind() + } else { + favorites = updatedFavorites + saveFavorites() + } // Notify observers NotificationCenter.default.post( @@ -193,6 +352,281 @@ final class FavoritesPersistenceService: ObservableObject { } return nil } + + func peerNostrPublicKeys( + excludingNoisePublicKey excludedNoisePublicKey: Data + ) -> [String] { + favorites.compactMap { noisePublicKey, relationship in + guard noisePublicKey != excludedNoisePublicKey else { + return nil + } + return relationship.peerNostrPublicKey + } + } + + /// Permanently requires pairwise NDR for this Noise identity. The pin is + /// intentionally independent of the Nostr identity so an identity rebind + /// can never reopen legacy kind-1059 fallback. Panic reset is the only + /// normal path that clears it. + @discardableResult + func markNdrRequired(for peerNoisePublicKey: Data) -> Bool { + guard !ndrBindingStorageUnreadable else { return false } + guard !ndrRequiredNoiseKeys.contains(peerNoisePublicKey) else { + return true + } + var updated = ndrRequiredNoiseKeys + updated.insert(peerNoisePublicKey) + guard persistNdrRequiredNoiseKeys(updated) else { + ndrBindingStorageUnreadable = true + SecureLogger.error( + "Could not durably pin favorite to double-ratchet transport", + category: .security + ) + NotificationCenter.default.post( + name: .favoriteStatusChanged, + object: nil + ) + return false + } + ndrRequiredNoiseKeys = updated + NotificationCenter.default.post( + name: .favoriteStatusChanged, + object: nil + ) + return true + } + + func isNdrRequired(for peerNoisePublicKey: Data) -> Bool { + ndrBindingStorageUnreadable + || ndrRequiredNoiseKeys.contains(peerNoisePublicKey) + } + + func isNdrRequired(for peerID: PeerID) -> Bool { + if ndrBindingStorageUnreadable { + return true + } + return ndrRequiredNoiseKeys.contains { + Self.peerID(peerID, matchesNoisePublicKey: $0) + } + } + + /// A journal always suppresses legacy fallback, including after a build + /// turns the rollout gate back off. A permanent pin does the same after + /// the journal has been completed. + func isNdrFallbackBlocked(for peerID: PeerID) -> Bool { + if isNdrRequired(for: peerID) { + return true + } + guard let pendingNostrIdentityRebind else { return false } + return Self.peerID( + peerID, + matchesNoisePublicKey: + pendingNostrIdentityRebind.peerNoisePublicKey + ) + } + + /// Binding-dependent work is allowed only for an unambiguous durable + /// binding. If the target favorite was committed and only journal cleanup + /// failed, target OOB remains usable while legacy fallback stays blocked. + func canUseNdrBinding( + peerNoisePublicKey: Data, + peerNostrPublicKey: String + ) -> Bool { + guard !ndrBindingStorageUnreadable else { return false } + guard let pendingNostrIdentityRebind else { return true } + guard pendingNostrIdentityRebind.peerNoisePublicKey + == peerNoisePublicKey + else { + return true + } + return pendingNostrIdentityRebind + .targetRelationship.peerNostrPublicKey + == peerNostrPublicKey + && favorites[peerNoisePublicKey]?.peerNostrPublicKey + == peerNostrPublicKey + } + + func canUseNdrBinding(for peerID: PeerID) -> Bool { + guard !ndrBindingStorageUnreadable else { return false } + guard let pendingNostrIdentityRebind, + Self.peerID( + peerID, + matchesNoisePublicKey: + pendingNostrIdentityRebind.peerNoisePublicKey + ) + else { + return true + } + let targetNostrPublicKey = pendingNostrIdentityRebind + .targetRelationship.peerNostrPublicKey + return targetNostrPublicKey != nil + && favorites[pendingNostrIdentityRebind.peerNoisePublicKey]? + .peerNostrPublicKey == targetNostrPublicKey + } + + /// Account-mailbox kind-1059 is a legacy transport. Once a favorite has + /// durable pairwise state, or while its identity is being rebound, an + /// inbound legacy envelope from either identity is a downgrade and must + /// not be delivered under a virtual Nostr peer. + func canAcceptLegacyNostrDM(from peerNostrPublicKey: String) -> Bool { + guard !ndrBindingStorageUnreadable, + let normalizedPeer = + Self.normalizedNostrPublicKey(peerNostrPublicKey) + else { + return false + } + + if let journal = pendingNostrIdentityRebind { + if Self.normalizedNostrPublicKey( + journal.oldNostrPublicKey + ) == normalizedPeer + || journal.targetRelationship.peerNostrPublicKey.flatMap( + Self.normalizedNostrPublicKey + ) == normalizedPeer + { + return false + } + } + + for (noisePublicKey, relationship) in favorites { + guard ndrRequiredNoiseKeys.contains(noisePublicKey), + relationship.peerNostrPublicKey.flatMap( + Self.normalizedNostrPublicKey + ) == normalizedPeer + else { + continue + } + return false + } + return true + } + + var canActivateDoubleRatchetRelay: Bool { + guard !ndrBindingStorageUnreadable else { return false } + guard let pendingNostrIdentityRebind else { return true } + return favorites[pendingNostrIdentityRebind.peerNoisePublicKey]? + .peerNostrPublicKey + == pendingNostrIdentityRebind + .targetRelationship.peerNostrPublicKey + } + + private func beginNostrIdentityAssignment( + peerNoisePublicKey: Data, + oldNostrPublicKey: String?, + newNostrPublicKey: String, + targetRelationship: FavoriteRelationship + ) -> NostrIdentityAssignment? { + // A pending transaction reserves its target identity globally. Letting + // another favorite claim it can make crash recovery collision-fail + // forever. + guard pendingNostrIdentityRebind == nil else { + return nil + } + if let authorizeNostrIdentityRebind { + guard authorizeNostrIdentityRebind( + peerNoisePublicKey, + oldNostrPublicKey, + newNostrPublicKey + ) else { + return nil + } + } else if nostrIdentityRebindAuthorizationRequired { + return nil + } + + guard let oldNostrPublicKey, + ndrRequiredNoiseKeys.contains(peerNoisePublicKey) + else { + // Ordinary pre-NDR favorite identity changes remain legacy-capable. + // Only explicit durable session evidence creates the permanent pin + // and therefore requires destructive-retirement journaling. + return NostrIdentityAssignment( + requiresVerifiedCommit: false + ) + } + // Durable pins outlive rollout switches. A pinned binding therefore + // always needs the same journaled retirement transaction, even in a + // build where new NDR sessions are dark. + guard !ndrBindingStorageUnreadable, + let commitNostrIdentityRebind + else { + return nil + } + + let journal = PendingNostrIdentityRebind( + peerNoisePublicKey: peerNoisePublicKey, + oldNostrPublicKey: oldNostrPublicKey, + targetRelationship: targetRelationship + ) + guard persistPendingNostrIdentityRebind(journal) else { + ndrBindingStorageUnreadable = true + return nil + } + pendingNostrIdentityRebind = journal + NotificationCenter.default.post( + name: .favoriteStatusChanged, + object: nil + ) + + guard commitNostrIdentityRebind( + peerNoisePublicKey, + oldNostrPublicKey, + newNostrPublicKey + ) else { + // The journal is deliberately retained. Native retirement may + // have partially succeeded, so clearing it could reopen legacy + // fallback against an ambiguous binding. + return nil + } + return NostrIdentityAssignment(requiresVerifiedCommit: true) + } + + private static func peerID( + _ peerID: PeerID, + matchesNoisePublicKey noisePublicKey: Data + ) -> Bool { + if let fullKey = Data(hexString: peerID.id), + fullKey == noisePublicKey + { + return true + } + return peerID.toShort() + == PeerID(publicKey: noisePublicKey).toShort() + } + + private static func preservingEquivalentNostrKey( + existing: String?, + requested: String? + ) -> String? { + guard let requested else { return nil } + guard let existing, + normalizedNostrPublicKey(existing) + == normalizedNostrPublicKey(requested), + normalizedNostrPublicKey(existing) != nil + else { + return requested + } + return existing + } + + private static func normalizedNostrPublicKey(_ value: String) -> Data? { + let lowered = value.lowercased() + if lowered.hasPrefix("npub") { + guard let (hrp, data) = try? Bech32.decode(lowered), + hrp == "npub", + data.count == 32 + else { + return nil + } + return data + } + guard lowered.count == 64, + lowered.allSatisfy(\.isHexDigit) + else { + return nil + } + return Data(hexString: lowered) + } /// Clear all favorites - used for panic mode func clearAllFavorites() { @@ -206,6 +640,17 @@ final class FavoritesPersistenceService: ObservableObject { key: Self.storageKey, service: Self.keychainService ) + keychain.delete( + key: Self.pendingNostrIdentityRebindKey, + service: Self.keychainService + ) + keychain.delete( + key: Self.ndrRequiredNoiseKeysKey, + service: Self.keychainService + ) + pendingNostrIdentityRebind = nil + ndrRequiredNoiseKeys.removeAll() + ndrBindingStorageUnreadable = false // Post notification for UI update NotificationCenter.default.post(name: .favoriteStatusChanged, object: nil) @@ -213,36 +658,250 @@ final class FavoritesPersistenceService: ObservableObject { // MARK: - Persistence - private func saveFavorites() { - let relationships = Array(favorites.values) - // Saving favorite relationships to keychain - + @discardableResult + private func saveFavorites() -> Bool { + persistFavorites(favorites) + } + + private func persistFavorites( + _ relationshipsByNoiseKey: [Data: FavoriteRelationship] + ) -> Bool { do { - let encoder = JSONEncoder() - let data = try encoder.encode(relationships) - - // Store in keychain for security - keychain.save( + let relationships = relationshipsByNoiseKey.values.sorted { + $0.peerNoisePublicKey.hexEncodedString() + < $1.peerNoisePublicKey.hexEncodedString() + } + let data = try JSONEncoder().encode(relationships) + guard persistVerified( key: Self.storageKey, data: data, - service: Self.keychainService, - accessible: nil - ) - - // Successfully saved favorites + service: Self.keychainService + ) else { + SecureLogger.error( + "Failed to verify persisted favorites", + category: .security + ) + return false + } + return true } catch { SecureLogger.error("Failed to save favorites: \(error)", category: .session) + return false } } - + + private func persistNdrRequiredNoiseKeys( + _ noiseKeys: Set + ) -> Bool { + do { + let sorted = noiseKeys.sorted { + $0.hexEncodedString() < $1.hexEncodedString() + } + let data = try JSONEncoder().encode(sorted) + return persistVerified( + key: Self.ndrRequiredNoiseKeysKey, + data: data, + service: Self.keychainService + ) + } catch { + return false + } + } + + private func persistPendingNostrIdentityRebind( + _ journal: PendingNostrIdentityRebind + ) -> Bool { + do { + let data = try JSONEncoder().encode(journal) + return persistVerified( + key: Self.pendingNostrIdentityRebindKey, + data: data, + service: Self.keychainService + ) + } catch { + SecureLogger.error( + "Failed to encode favorite NDR rebind journal", + category: .security + ) + return false + } + } + + private func persistVerified( + key: String, + data: Data, + service: String + ) -> Bool { + keychain.save( + key: key, + data: data, + service: service, + accessible: nil + ) + guard case .success(let stored) = keychain.loadWithResult( + key: key, + service: service + ) else { + return false + } + return stored == data + } + + private func finishPendingNostrIdentityRebind() { + guard pendingNostrIdentityRebind != nil else { return } + keychain.delete( + key: Self.pendingNostrIdentityRebindKey, + service: Self.keychainService + ) + switch keychain.loadWithResult( + key: Self.pendingNostrIdentityRebindKey, + service: Self.keychainService + ) { + case .itemNotFound: + pendingNostrIdentityRebind = nil + case .success: + SecureLogger.error( + "Favorite NDR rebind journal could not be cleared", + category: .security + ) + case .accessDenied, .deviceLocked, .authenticationFailed, + .otherError: + ndrBindingStorageUnreadable = true + SecureLogger.error( + "Favorite NDR rebind journal clear could not be verified", + category: .security + ) + } + NotificationCenter.default.post( + name: .favoriteStatusChanged, + object: nil + ) + } + + private func loadNdrRequiredNoiseKeys() { + switch keychain.loadWithResult( + key: Self.ndrRequiredNoiseKeysKey, + service: Self.keychainService + ) { + case .itemNotFound: + return + case .success(let data): + guard let values = try? JSONDecoder().decode( + [Data].self, + from: data + ), + values.allSatisfy({ $0.count == 32 }) + else { + ndrBindingStorageUnreadable = true + return + } + ndrRequiredNoiseKeys = Set(values) + case .accessDenied, .deviceLocked, .authenticationFailed, + .otherError: + ndrBindingStorageUnreadable = true + } + } + + private func loadPendingNostrIdentityRebind() { + switch keychain.loadWithResult( + key: Self.pendingNostrIdentityRebindKey, + service: Self.keychainService + ) { + case .itemNotFound: + return + case .success(let data): + guard let journal = try? JSONDecoder().decode( + PendingNostrIdentityRebind.self, + from: data + ), + journal.peerNoisePublicKey.count == 32, + journal.targetRelationship.peerNoisePublicKey + == journal.peerNoisePublicKey, + journal.targetRelationship.peerNostrPublicKey != nil, + journal.targetRelationship.peerNostrPublicKey + != journal.oldNostrPublicKey + else { + ndrBindingStorageUnreadable = true + return + } + pendingNostrIdentityRebind = journal + case .accessDenied, .deviceLocked, .authenticationFailed, + .otherError: + ndrBindingStorageUnreadable = true + } + } + + private func recoverPendingNostrIdentityRebindIfPossible() { + guard !ndrBindingStorageUnreadable, + let journal = pendingNostrIdentityRebind, + let targetNostrPublicKey = + journal.targetRelationship.peerNostrPublicKey, + let commitNostrIdentityRebind + else { + return + } + + let currentNostrPublicKey = + favorites[journal.peerNoisePublicKey]?.peerNostrPublicKey + let normalizedCurrentNostrPublicKey = + currentNostrPublicKey.flatMap(Self.normalizedNostrPublicKey) + guard currentNostrPublicKey == nil + || normalizedCurrentNostrPublicKey + == Self.normalizedNostrPublicKey( + journal.oldNostrPublicKey + ) + || normalizedCurrentNostrPublicKey + == Self.normalizedNostrPublicKey( + targetNostrPublicKey + ), + authorizeNostrIdentityRebind?( + journal.peerNoisePublicKey, + journal.oldNostrPublicKey, + targetNostrPublicKey + ) == true, + markNdrRequired(for: journal.peerNoisePublicKey), + commitNostrIdentityRebind( + journal.peerNoisePublicKey, + journal.oldNostrPublicKey, + targetNostrPublicKey + ) + else { + return + } + + var recovered = favorites + recovered[journal.peerNoisePublicKey] = + journal.targetRelationship + guard persistFavorites(recovered) else { + return + } + favorites = recovered + finishPendingNostrIdentityRebind() + NotificationCenter.default.post( + name: .favoriteStatusChanged, + object: nil, + userInfo: [ + "peerPublicKey": journal.peerNoisePublicKey + ] + ) + } + private func loadFavorites() { // Loading favorites from keychain - - guard let data = keychain.load( + + let data: Data + switch keychain.loadWithResult( key: Self.storageKey, service: Self.keychainService - ) else { - return + ) { + case .itemNotFound: + return + case .success(let stored): + data = stored + case .accessDenied, .deviceLocked, .authenticationFailed, + .otherError: + ndrBindingStorageUnreadable = true + return } do { @@ -307,6 +966,7 @@ final class FavoritesPersistenceService: ObservableObject { // Loaded relationships successfully } catch { SecureLogger.error("Failed to load favorites: \(error)", category: .session) + ndrBindingStorageUnreadable = true } } } diff --git a/bitchat/Services/KeychainManager.swift b/bitchat/Services/KeychainManager.swift index abc86b10..e52df81e 100644 --- a/bitchat/Services/KeychainManager.swift +++ b/bitchat/Services/KeychainManager.swift @@ -97,6 +97,7 @@ final class KeychainManager: KeychainManagerProtocol { /// migrations and panic deletion cannot silently miss them. private static let additionalApplicationOwnedServices = [ "chat.bitchat.nostr", + "chat.bitchat.ndr.session-markers", "chat.bitchat.favorites", "chat.bitchat.outbox", "com.bitchat.passwords", @@ -874,14 +875,25 @@ final class KeychainManager: KeychainManagerProtocol { kSecAttrSynchronizable as String: false ]) { _, new in new } - // Delete by the item's primary key only. Value/accessibility fields - // are add attributes, not valid selectors for replacing an existing - // item; including them can leave the old item in place and make the - // subsequent add fail as a duplicate. - let deleteStatus = SecItemDelete(primaryKeyQuery as CFDictionary) - guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else { + // Update in place so a failed replacement never destroys the last + // durable value. Rebind journals rely on the old favorites snapshot + // remaining readable when a write is rejected (locked device, + // entitlement failure, storage pressure, and similar errors). + let updateAttributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: + accessible ?? Self.itemAccessibility + ] + let updateStatus = SecItemUpdate( + primaryKeyQuery as CFDictionary, + updateAttributes as CFDictionary + ) + if updateStatus == errSecSuccess { + return + } + guard updateStatus == errSecItemNotFound else { SecureLogger.error( - NSError(domain: "Keychain", code: Int(deleteStatus)), + NSError(domain: "Keychain", code: Int(updateStatus)), context: "Unable to replace custom-service keychain item", category: .keychain ) diff --git a/bitchat/Services/NdrNostrService.swift b/bitchat/Services/NdrNostrService.swift index b593c6f9..41451081 100644 --- a/bitchat/Services/NdrNostrService.swift +++ b/bitchat/Services/NdrNostrService.swift @@ -1,409 +1,1115 @@ +import BitFoundation import BitLogger import Foundation import NdrFfi @MainActor protocol NostrRelayManaging: AnyObject { + @discardableResult func subscribe( filter: NostrFilter, id: String, relayUrls: [String]?, handler: @escaping (NostrEvent) -> Void, onEOSE: (() -> Void)? - ) + ) -> Bool func unsubscribe(id: String) - func sendEvent(_ event: NostrEvent, to relayUrls: [String]?) + func sendEventImmediately( + _ event: NostrEvent, + to relayUrls: [String]?, + completion: @escaping (Bool) -> Void + ) } extension NostrRelayManager: NostrRelayManaging {} -struct NdrDecryptedMessage { - let event: NostrEvent - let senderPubkeyHex: String - let senderDevicePubkeyHex: String? - let conversationOwnerPubkeyHex: String? +enum NdrDeliveryDisposition: Equatable { + case consumed + case retry +} - /// Iris sets `conversationOwnerPubkeyHex` on a local-sibling copy so the - /// receiving device can route its own authored event to the remote peer's - /// thread. Authentication continues to use `senderPubkeyHex`. - var conversationPubkeyHex: String { - conversationOwnerPubkeyHex ?? senderPubkeyHex +enum NdrSendDisposition: Equatable { + case sent(innerEventID: String, outerEventID: String) + case noSession + case failed +} + +enum NdrStorageDirectoryError: Error, Equatable { + case applicationSupportUnavailable +} + +enum NdrSessionStateError: Error, Equatable { + case missingEstablishedState + case invalidEstablishedSessionMarker + case establishedSessionMarkerWriteFailed + case establishedSessionMarkerClearFailed +} + +@MainActor +protocol NdrSessionMarkerStoring: AnyObject { + func contains(identityPubkeyHex: String) throws -> Bool + func mark(identityPubkeyHex: String) throws + func clear() throws +} + +@MainActor +final class InMemoryNdrSessionMarkerStore: NdrSessionMarkerStoring { + private var identities = Set() + + func contains(identityPubkeyHex: String) throws -> Bool { + identities.contains(identityPubkeyHex) } - var isLocalSiblingCopy: Bool { - conversationOwnerPubkeyHex != nil + func mark(identityPubkeyHex: String) throws { + identities.insert(identityPubkeyHex) + } + + func clear() throws { + identities.removeAll() } } -/// Bridges the protocol-backed `NdrFfi` `SessionManagerHandle` with `NostrRelayManager`. +@MainActor +private final class KeychainNdrSessionMarkerStore: + NdrSessionMarkerStoring +{ + private static let service = "chat.bitchat.ndr.session-markers" + private static let key = "established-identities" + private let keychain: KeychainManagerProtocol + + init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) { + self.keychain = keychain + } + + func contains(identityPubkeyHex: String) throws -> Bool { + try identities().contains(identityPubkeyHex) + } + + func mark(identityPubkeyHex: String) throws { + var updated = try identities() + updated.insert(identityPubkeyHex) + let data = try JSONEncoder().encode(updated.sorted()) + keychain.save( + key: Self.key, + data: data, + service: Self.service, + accessible: nil + ) + guard try identities().contains(identityPubkeyHex) else { + throw NdrSessionStateError + .establishedSessionMarkerWriteFailed + } + } + + func clear() throws { + keychain.delete(key: Self.key, service: Self.service) + guard keychain.load(key: Self.key, service: Self.service) == nil else { + throw NdrSessionStateError + .establishedSessionMarkerClearFailed + } + } + + private func identities() throws -> Set { + guard let data = keychain.load( + key: Self.key, + service: Self.service + ) else { + return [] + } + guard let values = try? JSONDecoder().decode( + [String].self, + from: data + ), + values.allSatisfy({ + $0.count == 64 + && $0.allSatisfy { character in + character.isHexDigit + } + }) + else { + throw NdrSessionStateError.invalidEstablishedSessionMarker + } + return Set(values.map { $0.lowercased() }) + } +} + +struct NdrDecryptedMessage { + let event: NostrEvent + let senderPubkeyHex: String + let outerEventID: String + let expiresAtSeconds: UInt64? +} + +struct NdrOutOfBandAction { + let eventJson: String + let peerPubkeyHex: String + + let actionID: String + fileprivate let manager: PairwiseManager + fileprivate let managerEpoch: UInt64 +} + +struct NdrInviteAction { + let eventJson: String + let eventID: String + + fileprivate let manager: PairwiseManager + fileprivate let managerEpoch: UInt64 +} + +typealias NdrDeliveryCompletion = @MainActor (NdrDeliveryDisposition) -> Void +typealias NdrDecryptedMessageHandler = + @MainActor (NdrDecryptedMessage, @escaping NdrDeliveryCompletion) -> Void + +/// Bridges BitChat's authenticated BLE bootstrap and relay transport to the +/// durable single-device pairwise NDR runtime. /// -/// The ndr session manager emits a stream of pub/sub actions we must execute externally: -/// - `subscribe` / `unsubscribe`: Nostr filter subscriptions (for invite responses, sessions, etc) -/// - `publish_signed`: signed Nostr events to publish -/// - `decrypted_message`: decrypted inner event JSON (kind 14) to surface to the app -/// -/// BitChat policy: do NOT publish double-ratchet invite/response handshake events to Nostr. -/// Those are exchanged out-of-band over the BLE Noise channel (see `Transport.sendNdrEvent`). +/// Only kind 1060 reaches relays. Invite/response payloads remain bound to the +/// authenticated BLE Noise generation, and every durable runtime action is +/// acknowledged only after its host-side effect succeeds. @MainActor final class NdrNostrService { static let shared = NdrNostrService() - private static let compactInviteURLRoot = "https://b" - private static let maximumPendingOutOfBandInvites = 64 - private static let maximumBufferedDecryptedMessages = 128 - /// Called when an ndr message is decrypted into an inner Nostr event (kind 14). - var onDecryptedMessage: ((NdrDecryptedMessage) -> Void)? { + private static let storageVersionDirectory = "pairwise-v1" + private static let maximumActionsPerRetry = 128 + private static let transientRetryDelays: [TimeInterval] = [ + 0.25, 0.5, 1, 2 + ] + + var onDecryptedMessage: NdrDecryptedMessageHandler? { didSet { - flushBufferedDecryptedMessages() + if oldValue == nil, onDecryptedMessage != nil { + retryPendingDeliveries() + } } } private let relayManager: NostrRelayManaging private let storageDirectoryProvider: @MainActor () throws -> URL + private let sessionMarkerStore: NdrSessionMarkerStoring + private let retryScheduler: + @MainActor ( + TimeInterval, + @escaping @MainActor () -> Void + ) -> Void + private let nativeOutOfBandMutationObserver: + (@MainActor () -> Void)? private let rolloutEnabled: Bool - private var sessionManager: SessionManagerHandle? - private var activeSubIDs = Set() - private var appKeysSubscriptionIDByOwner: [String: String] = [:] - private var appKeysOwnerBySubscriptionID: [String: String] = [:] - /// Owners whose live AppKeys feed must outlive any one bootstrap attempt. - /// Device authorization and revocation are replaceable kind-37368 state; - /// retaining only the snapshot that admitted an invite would keep removed - /// devices eligible for future outbound fanout. - private var durableAppKeysOwners = Set() - private var cachedInviteEventJson: String? - private var bufferedDecryptedMessages: [NdrDecryptedMessage] = [] - - private struct PendingOutOfBandInvite { - let key: PendingOutOfBandInviteKey - let payload: String - let sequence: UInt64 - let authorization: () -> Bool - let deferredResponseHandler: (String) -> Void - } - - private struct PendingOutOfBandInviteKey: Hashable { - let ownerPubkeyHex: String - let inviterPubkeyHex: String - } - - private var pendingOutOfBandInvites: - [PendingOutOfBandInviteKey: PendingOutOfBandInvite] = [:] - private var nextPendingOutOfBandInviteSequence: UInt64 = 0 - + private var manager: PairwiseManager? + private var managerEpoch: UInt64 = 0 private var configuredForPubkeyHex: String? - private var deviceId: String? - private let deviceIdProvider: @MainActor () -> String - private let clearPersistedDeviceId: @MainActor () -> Void + private var failedConfigurationPubkeyHex: String? + private var activeSubIDs = Set() + private var inFlightActionIDs = Set() + private var deferredActionIDs = Set() + private var transientRetryAttempts: [String: Int] = [:] + private var scheduledTransientRetryTokens: [String: UUID] = [:] + private var continuationScheduled = false private init() { - self.relayManager = NostrRelayManager.shared - self.rolloutEnabled = DoubleRatchetFeature.isEnabled - self.deviceId = rolloutEnabled ? Self.loadOrCreateDeviceId() : nil - self.deviceIdProvider = Self.loadOrCreateDeviceId - self.clearPersistedDeviceId = Self.clearPersistedDeviceId - self.storageDirectoryProvider = Self.ndrStorageDirectory + relayManager = NostrRelayManager.shared + rolloutEnabled = DoubleRatchetFeature.isEnabled + sessionMarkerStore = KeychainNdrSessionMarkerStore() + storageDirectoryProvider = { + try Self.ndrStorageDirectory() + } + retryScheduler = Self.scheduleLiveRetry + nativeOutOfBandMutationObserver = nil } - /// Dependency-injected initializer (primarily for tests). + /// Dependency-injected initializer used by the app-target integration tests. init( relayManager: NostrRelayManaging, - deviceId: String, rolloutEnabled: Bool, storageDirectoryProvider: @escaping @MainActor () throws -> URL, - clearPersistedDeviceId: @escaping @MainActor () -> Void = {} + sessionMarkerStore: NdrSessionMarkerStoring? = nil, + retryScheduler: @escaping @MainActor ( + TimeInterval, + @escaping @MainActor () -> Void + ) -> Void = NdrNostrService.scheduleLiveRetry, + nativeOutOfBandMutationObserver: + (@MainActor () -> Void)? = nil ) { self.relayManager = relayManager self.rolloutEnabled = rolloutEnabled - self.deviceId = deviceId - self.deviceIdProvider = { deviceId } self.storageDirectoryProvider = storageDirectoryProvider - self.clearPersistedDeviceId = clearPersistedDeviceId + self.sessionMarkerStore = + sessionMarkerStore ?? InMemoryNdrSessionMarkerStore() + self.retryScheduler = retryScheduler + self.nativeOutOfBandMutationObserver = + nativeOutOfBandMutationObserver } - var isConfigured: Bool { rolloutEnabled && sessionManager != nil } + var isConfigured: Bool { rolloutEnabled && manager != nil } var isRolloutEnabled: Bool { rolloutEnabled } var configuredPubkeyHex: String? { configuredForPubkeyHex } - /// Returns our current device invite event JSON (kind 30078), if available. - /// - /// This is exchanged out-of-band with mutual favorites over BLE and is never published to Nostr. func currentInviteEventJson() -> String? { - rolloutEnabled ? cachedInviteEventJson : nil + currentInviteAction()?.eventJson } - func configureIfNeeded(identity: NostrIdentity) { - guard rolloutEnabled else { return } - let pubkey = identity.publicKeyHex.lowercased() - if configuredForPubkeyHex == pubkey, sessionManager != nil { return } - - // Identity changed: tear down subscriptions we created (best-effort). - for id in activeSubIDs { - relayManager.unsubscribe(id: id) + func currentInviteAction() -> NdrInviteAction? { + guard rolloutEnabled, let manager else { return nil } + guard let eventJson = try? manager.currentInviteEventJson(), + let event = try? JSONDecoder().decode( + NostrEvent.self, + from: Data(eventJson.utf8) + ) + else { + return nil } - activeSubIDs.removeAll() - appKeysSubscriptionIDByOwner.removeAll() - appKeysOwnerBySubscriptionID.removeAll() - durableAppKeysOwners.removeAll() - pendingOutOfBandInvites.removeAll() - bufferedDecryptedMessages.removeAll() - sessionManager = nil - cachedInviteEventJson = nil + return NdrInviteAction( + eventJson: eventJson, + eventID: event.id, + manager: manager, + managerEpoch: managerEpoch + ) + } + + func isCurrentInviteAction(_ action: NdrInviteAction) -> Bool { + guard isCurrent(action.manager, epoch: action.managerEpoch) else { + return false + } + guard let eventJson = try? action.manager.currentInviteEventJson(), + let event = try? JSONDecoder().decode( + NostrEvent.self, + from: Data(eventJson.utf8) + ) + else { + return false + } + return event.id == action.eventID + } + + @discardableResult + func configureIfNeeded( + identity: NostrIdentity, + processPendingActions shouldProcessPendingActions: Bool = true, + allowDisabledMaintenance: Bool = false + ) -> Bool { + guard rolloutEnabled || allowDisabledMaintenance else { + return false + } + let pubkey = identity.publicKeyHex.lowercased() + if failedConfigurationPubkeyHex == pubkey { + // A corrupt/unopenable runtime must not be reclassified as + // "no session" by a later send. Recovery requires an identity + // change or the explicit panic/storage wipe transaction. + return false + } + if configuredForPubkeyHex == pubkey, manager != nil { + if shouldProcessPendingActions && rolloutEnabled { + processAvailableActions() + } + return true + } + + replaceManager(with: nil, configuredPubkeyHex: nil) + failedConfigurationPubkeyHex = nil configuredForPubkeyHex = pubkey do { - // The FFI's file adapter uses fixed filenames within its base - // directory. Namespace that directory by owner so switching the - // account identity can never load another owner's ratchet state. - let storagePath = try storageDirectoryProvider() + let storageURL = try storageDirectoryProvider() + .appendingPathComponent( + Self.storageVersionDirectory, + isDirectory: true + ) .appendingPathComponent(pubkey, isDirectory: true) - .path - let deviceId = deviceId ?? deviceIdProvider() - self.deviceId = deviceId - let mgr = try SessionManagerHandle.newWithStoragePath( + if try sessionMarkerStore.contains( + identityPubkeyHex: pubkey + ), + !Self.hasDurablePairwiseState(at: storageURL) + { + throw NdrSessionStateError.missingEstablishedState + } + let newManager = try PairwiseManager.newWithStoragePath( ourPubkeyHex: pubkey, - ourIdentityPrivkeyHex: identity.privateKey.hexEncodedString(), - deviceId: deviceId, - storagePath: storagePath, - ownerPubkeyHex: nil + ourIdentityPrivateKeyHex: + identity.privateKey.hexEncodedString(), + storagePath: storageURL.path ) - try mgr.`init`() - sessionManager = mgr - restoreDurableAppKeysSubscriptions(using: mgr) - _ = drainAndApplyPubSubEvents() - SecureLogger.info("NdrNostrService configured pub=\(pubkey.prefix(8))… device=\(deviceId)", category: .session) + manager = newManager + try markEstablishedSessionIfNeeded( + manager: newManager, + identityPubkeyHex: pubkey + ) + failedConfigurationPubkeyHex = nil + if shouldProcessPendingActions && rolloutEnabled { + processAvailableActions() + } + SecureLogger.info( + "NdrNostrService configured pairwise pub=\(pubkey.prefix(8))…", + category: .session + ) + return true } catch { - SecureLogger.error("NdrNostrService: failed to configure: \(error)", category: .session) - sessionManager = nil + SecureLogger.error( + "NdrNostrService: failed to configure: \(error)", + category: .session + ) + replaceManager(with: nil, configuredPubkeyHex: pubkey) + failedConfigurationPubkeyHex = pubkey + return false } } func hasActiveSession(with peerPubkeyHex: String) -> Bool { - guard rolloutEnabled, let mgr = sessionManager else { return false } - do { - return try mgr.getActiveSessionState(peerPubkeyHex: peerPubkeyHex.lowercased()) != nil - } catch { + guard rolloutEnabled, + let manager, + let peer = Self.normalizedPubkeyHex(peerPubkeyHex), + let info = try? manager.sessionInfo(peerPubkeyHex: peer) + else { return false } + return info.sendReady && info.receiveReady } - func activeSessionStateJson(with peerPubkeyHex: String) -> String? { - guard rolloutEnabled, let mgr = sessionManager else { return nil } - return try? mgr.getActiveSessionState(peerPubkeyHex: peerPubkeyHex.lowercased()) + /// Any native session record suppresses another invite. A valid response + /// can create a half-ready session while its relay bootstrap is still in + /// flight; sending a second invite in that window creates avoidable glare. + func hasPairwiseSession(with peerPubkeyHex: String) -> Bool { + guard rolloutEnabled, + let manager, + let peer = Self.normalizedPubkeyHex(peerPubkeyHex) + else { + return false + } + return (try? manager.sessionInfo(peerPubkeyHex: peer)) != nil } - /// Attempt to send via ndr when a verified, send-ready session exists. - /// A newly established session can remain unavailable until the peer's - /// owner-signed AppKeys roster arrives; callers may use their legacy path - /// until the runtime can prepare an NDR relay event. - func sendIfPossible(_ text: String, to peerPubkeyHex: String) -> Bool { - guard rolloutEnabled, let mgr = sessionManager else { return false } - guard hasActiveSession(with: peerPubkeyHex) else { return false } + /// Removes only the selected pairwise peer. Other peer sessions and the + /// local invite remain intact. + @discardableResult + func retirePeer( + _ peerPubkeyHex: String, + processPendingActions shouldProcessPendingActions: Bool = true, + allowDisabledMaintenance: Bool = false + ) -> Bool { + guard rolloutEnabled || allowDisabledMaintenance, + let manager, + let peer = Self.normalizedPubkeyHex(peerPubkeyHex) + else { + return false + } + + let retiredActionIDs = Set( + (try? manager.pendingActions())? + .filter { $0.peerPubkeyHex == peer } + .map(\.actionId) + ?? [] + ) do { - let outboundEventIDs = try mgr.sendText( - recipientPubkeyHex: peerPubkeyHex.lowercased(), - text: text, - expiresAtSeconds: nil - ) - _ = drainAndApplyPubSubEvents() - if outboundEventIDs.isEmpty { - SecureLogger.debug( - "NdrNostrService: send queued no relay publish for \(peerPubkeyHex.prefix(8))…", - category: .session - ) + guard try manager.retirePeer(peerPubkeyHex: peer) else { + return true + } + for actionID in retiredActionIDs { + inFlightActionIDs.remove(actionID) + deferredActionIDs.remove(actionID) + transientRetryAttempts.removeValue(forKey: actionID) + scheduledTransientRetryTokens.removeValue(forKey: actionID) + } + if shouldProcessPendingActions && rolloutEnabled { + processAvailableActions() } return true } catch { - SecureLogger.debug("NdrNostrService: send failed (no session yet?): \(error)", category: .session) - // Still drain in case the error queued any pubsub actions. - _ = drainAndApplyPubSubEvents() + SecureLogger.error( + "NdrNostrService: failed to retire pairwise peer: \(error)", + category: .session + ) return false } } - /// Process a received invite/response payload (transferred out-of-band over BLE). - /// - /// Returns any outbound handshake payloads (e.g. giftwrap response JSON or compact invite URL) - /// that should be returned to the sender over BLE. + /// A legacy envelope is permitted only when no pairwise session exists. + /// Once any session exists, inability to ratchet is a fail-closed error. + func send( + _ text: String, + to peerPubkeyHex: String, + expiresAtSeconds: UInt64? = nil + ) -> NdrSendDisposition { + guard rolloutEnabled else { + return .noSession + } + guard let manager else { + return failedConfigurationPubkeyHex == nil + ? .noSession + : .failed + } + guard + let peer = Self.normalizedPubkeyHex(peerPubkeyHex) + else { + return .noSession + } + + do { + guard let info = try manager.sessionInfo(peerPubkeyHex: peer) else { + return .noSession + } + guard info.sendReady else { + SecureLogger.warning( + "NdrNostrService: pairwise session exists but is not send-ready", + category: .security + ) + return .failed + } + + let result = try manager.sendText( + peerPubkeyHex: peer, + text: text, + expiresAtSeconds: expiresAtSeconds + ) + processAvailableActions() + return .sent( + innerEventID: result.innerEventId, + outerEventID: result.outerEventId + ) + } catch { + SecureLogger.error( + "NdrNostrService: active pairwise send failed: \(error)", + category: .session + ) + processAvailableActions() + return .failed + } + } + + /// Processes an invite or response delivered over an authenticated BLE + /// Noise session and returns only OOB actions for that exact peer. func processOutOfBandEventJson( _ eventJson: String, expectedPeerPubkeyHex: String, authorization: (() -> Bool)? = nil, - deferredResponseHandler: ((String) -> Void)? = nil - ) -> [String] { + persistEstablishedBinding: () -> Bool + ) -> [NdrOutOfBandAction] { guard rolloutEnabled, - let mgr = sessionManager, - authorization?() != false + let manager, + authorization?() != false, + let expectedPeer = + Self.normalizedPubkeyHex(expectedPeerPubkeyHex) else { return [] } - let payload = eventJson.trimmingCharacters(in: .whitespacesAndNewlines) - guard payload.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes, - let expectedPeer = Self.normalizedPubkeyHex(expectedPeerPubkeyHex) + let epoch = managerEpoch + let payload = + eventJson.trimmingCharacters(in: .whitespacesAndNewlines) + guard !payload.isEmpty, + payload.utf8.count + <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { - SecureLogger.warning("NdrNostrService: rejected invalid or oversized OOB payload", category: .security) - return [] - } - - let inboundInvite = parseOutOfBandInvite(payload) - let responseEvent = inboundInvite == nil - ? try? JSONDecoder().decode(NostrEvent.self, from: Data(payload.utf8)) - : nil - if let inboundInvite, inboundInvite.ownerPubkeyHex != expectedPeer { SecureLogger.warning( - "NdrNostrService: rejected OOB invite whose owner does not match the authenticated peer", + "NdrNostrService: rejected invalid or oversized OOB payload", category: .security ) return [] } - if inboundInvite == nil { - guard let responseEvent, - responseEvent.kind == 1059, - responseEvent.isValidSignature(), - NostrEvent.isWithinInboundTagLimits(responseEvent.tags) - else { - SecureLogger.warning("NdrNostrService: rejected non-invite OOB payload", category: .security) + let mutation: ValidatedOutOfBandMutation + if let invite = parseOutOfBandInvite(payload) { + guard invite.peerPubkeyHex == expectedPeer else { + SecureLogger.warning( + "NdrNostrService: rejected OOB invite for another authenticated peer", + category: .security + ) return [] } + mutation = .invite(invite.transport) + } else { + guard let response = try? JSONDecoder().decode( + NostrEvent.self, + from: Data(payload.utf8) + ), + response.kind == 1059, + response.isValidSignature(), + NostrEvent.isWithinInboundTagLimits(response.tags) + else { + SecureLogger.warning( + "NdrNostrService: rejected non-invite OOB payload", + category: .security + ) + return [] + } + mutation = .response } - var blockedOnOwnerRoster = false - var processingFailed = false + // A valid authenticated OOB payload can durably create native + // pairwise state. Persist both host-side downgrade barriers first so + // a crash at any later instruction cannot reopen kind-1059 fallback. + guard persistEstablishedBinding() else { + SecureLogger.error( + "NdrNostrService: OOB binding pin was not durable", + category: .security + ) + return [] + } do { - switch inboundInvite?.transport { - case .eventJSON: - _ = try mgr.acceptInviteFromEventJson( - eventJson: payload, - ownerPubkeyHintHex: expectedPeer - ) - case .url: - _ = try mgr.acceptInviteFromUrl( - inviteUrl: payload, - ownerPubkeyHintHex: expectedPeer - ) - case .none: - try mgr.processOutOfBandResponse( - eventJson: payload, - expectedOwnerPubkeyHex: expectedPeer - ) + guard let identityPubkeyHex = configuredForPubkeyHex else { + throw NdrSessionStateError + .establishedSessionMarkerWriteFailed } - } catch let error as NdrError { - if case .SessionNotReady = error { - blockedOnOwnerRoster = true - } else { - processingFailed = true - SecureLogger.debug( - "NdrNostrService: OOB payload ignored/rejected: \(error)", - category: .session + try sessionMarkerStore.mark( + identityPubkeyHex: identityPubkeyHex.lowercased() + ) + } catch { + SecureLogger.error( + "NdrNostrService: failed to precommit established-session marker: \(error)", + category: .security + ) + return [] + } + + do { + nativeOutOfBandMutationObserver?() + switch mutation { + case .invite(.eventJSON): + _ = try manager.acceptInviteFromEventJson( + eventJson: payload, + authenticatedPeerPubkeyHex: expectedPeer + ) + case .invite(.url): + _ = try manager.acceptInviteFromUrl( + inviteUrl: payload, + authenticatedPeerPubkeyHex: expectedPeer + ) + case .response: + try manager.processOutOfBandResponse( + eventJson: payload, + authenticatedPeerPubkeyHex: expectedPeer ) } } catch { - processingFailed = true SecureLogger.debug( "NdrNostrService: OOB payload ignored/rejected: \(error)", category: .session ) - } - - if !blockedOnOwnerRoster, - !processingFailed, - hasActiveSession(with: expectedPeer) { - ensureDurableAppKeysSubscription( - ownerPubkeyHex: expectedPeer, - using: mgr - ) - } - let outOfBandPublishes = drainAndApplyPubSubEvents(collectOutOfBandPublishes: true) - if blockedOnOwnerRoster, let inboundInvite { - if let deferredResponseHandler { - retainPendingOutOfBandInvite( - payload: payload, - ownerPubkeyHex: inboundInvite.ownerPubkeyHex, - inviterPubkeyHex: inboundInvite.inviterPubkeyHex, - authorization: authorization ?? { true }, - deferredResponseHandler: deferredResponseHandler - ) - } else { - finishPendingOutOfBandInvite( - ownerPubkeyHex: inboundInvite.ownerPubkeyHex, - inviterPubkeyHex: inboundInvite.inviterPubkeyHex - ) - } - return [] - } - if processingFailed { - if let inboundInvite { - finishPendingOutOfBandInvite( - ownerPubkeyHex: inboundInvite.ownerPubkeyHex, - inviterPubkeyHex: inboundInvite.inviterPubkeyHex - ) - } + processAvailableActions() return [] } - if let inboundInvite, - outOfBandPublishes.isEmpty, - hasActiveSession(with: inboundInvite.ownerPubkeyHex), - let currentInvite = preferredInviteOobPayload() { - finishPendingOutOfBandInvite( - ownerPubkeyHex: inboundInvite.ownerPubkeyHex, - inviterPubkeyHex: inboundInvite.inviterPubkeyHex - ) - return outOfBandPublishes + [currentInvite] - } - if let inboundInvite { - finishPendingOutOfBandInvite( - ownerPubkeyHex: inboundInvite.ownerPubkeyHex, - inviterPubkeyHex: inboundInvite.inviterPubkeyHex - ) - } - return outOfBandPublishes + guard isCurrent(manager, epoch: epoch) else { return [] } + return processPendingActions(collectOutOfBandFor: expectedPeer) + } + + /// Completes the durable OOB action only after BLE accepted the encrypted + /// packet for the exact authenticated Noise generation. + func completeOutOfBandAction( + _ action: NdrOutOfBandAction, + succeeded: Bool + ) { + guard isCurrent(action.manager, epoch: action.managerEpoch) else { + return + } + inFlightActionIDs.remove(action.actionID) + guard succeeded else { + deferredActionIDs.insert(action.actionID) + return + } + deferredActionIDs.remove(action.actionID) + if !acknowledge( + [action.actionID], + manager: action.manager, + epoch: action.managerEpoch + ) { + deferredActionIDs.insert(action.actionID) + } else { + // A bootstrap publish for this session is deliberately held until + // BLE has accepted the authenticated OOB response. + processAvailableActions() + } + } + + /// Reclaims one failed BLE action for a bounded host retry without + /// releasing unrelated peer-routed actions from the durable queue. + func prepareOutOfBandActionForRetry( + _ action: NdrOutOfBandAction + ) -> Bool { + guard isCurrent(action.manager, epoch: action.managerEpoch), + !inFlightActionIDs.contains(action.actionID), + let pending = try? action.manager.pendingActions(), + pending.contains(where: { + $0.actionId == action.actionID + && $0.kind == "out_of_band" + }) + else { + return false + } + deferredActionIDs.remove(action.actionID) + inFlightActionIDs.insert(action.actionID) + return true + } + + @discardableResult + func scheduleHostTransientRetry( + after retryAttempt: Int, + operation: @escaping @MainActor () -> Void + ) -> Bool { + guard retryAttempt < Self.transientRetryDelays.count else { + return false + } + retryScheduler( + Self.transientRetryDelays[retryAttempt], + operation + ) + return true } - /// Process a Nostr event received from relays (kind 1060 messages, app-keys maintenance, etc). func processInboundRelayEvent(_ event: NostrEvent) { - guard rolloutEnabled else { return } - processInboundNostrEvent(event) + guard rolloutEnabled, let manager else { return } + processInboundNostrEvent( + event, + manager: manager, + epoch: managerEpoch + ) } - /// Invalidates every in-memory callback/session and removes the persisted - /// ratchet database and device identifier as part of the synchronous panic - /// transaction. A failed directory deletion is surfaced so startup keeps - /// the durable recovery marker and retries before transports restart. - func resetForPanic() throws { - for id in activeSubIDs { - relayManager.unsubscribe(id: id) + func pendingOutOfBandActions( + forAuthenticatedPeerPubkeyHex peerPubkeyHex: String, + releaseDeferred: Bool = false + ) -> [NdrOutOfBandAction] { + guard rolloutEnabled, + let peer = Self.normalizedPubkeyHex(peerPubkeyHex) + else { + return [] } - activeSubIDs.removeAll() - appKeysSubscriptionIDByOwner.removeAll() - appKeysOwnerBySubscriptionID.removeAll() - durableAppKeysOwners.removeAll() - pendingOutOfBandInvites.removeAll() - nextPendingOutOfBandInviteSequence = 0 - bufferedDecryptedMessages.removeAll() - sessionManager = nil - cachedInviteEventJson = nil - configuredForPubkeyHex = nil + if releaseDeferred { + releaseDeferredOutOfBandActions(for: peer) + } + return processPendingActions(collectOutOfBandFor: peer) + } + + /// A real disconnected→connected edge starts a fresh bounded relay retry + /// epoch without releasing consumer or BLE work. + func retryRelayActions() { + releaseDeferredActions( + where: { action in + action.kind == "publish" + || action.kind == "subscribe" + || action.kind == "unsubscribe" + } + ) + processAvailableActions() + } + + /// Consumer installation or an explicit delivery retry releases only + /// application delivery work. + func retryPendingDeliveries() { + releaseDeferredActions(where: { $0.kind == "delivery" }) + processAvailableActions() + } + + /// Invalidates callbacks first, then deletes every pairwise database as + /// part of the synchronous panic transaction. + func resetForPanic() throws { + replaceManager(with: nil, configuredPubkeyHex: nil) + failedConfigurationPubkeyHex = nil onDecryptedMessage = nil - deviceId = nil - clearPersistedDeviceId() let storageDirectory = try storageDirectoryProvider() if FileManager.default.fileExists(atPath: storageDirectory.path) { try FileManager.default.removeItem(at: storageDirectory) } + try sessionMarkerStore.clear() } - // MARK: - Internals + // MARK: - Durable action processing - private func processInboundNostrEvent(_ event: NostrEvent) { - guard let mgr = sessionManager else { return } - guard event.kind == 1060 || event.kind == 37368, + @discardableResult + private func processPendingActions( + collectOutOfBandFor expectedPeer: String? + ) -> [NdrOutOfBandAction] { + guard let manager else { return [] } + let epoch = managerEpoch + let actions: [PairwiseAction] + do { + // Inspect the bounded native queue in full so already in-flight, + // deferred, or peer-routed OOB actions at its head cannot starve + // actionable work behind them. Host-side effects remain capped + // below by `maximumActionsPerRetry`. + actions = try manager.pendingActions() + } catch { + SecureLogger.error( + "NdrNostrService: pendingActions failed: \(error)", + category: .session + ) + return [] + } + + let pendingOutOfBandSessionIDs = Set( + actions.compactMap { action in + action.kind == "out_of_band" ? action.sessionId : nil + } + ) + let hasUnscopedOutOfBandAction = actions.contains { action in + action.kind == "out_of_band" && action.sessionId == nil + } + + var synchronousAcks: [String] = [] + var outOfBand: [NdrOutOfBandAction] = [] + var processedActionCount = 0 + var hitBatchLimit = false + + for action in actions { + guard isCurrent(manager, epoch: epoch), + !inFlightActionIDs.contains(action.actionId), + !deferredActionIDs.contains(action.actionId) + else { + continue + } + + switch action.kind { + case "publish": + let sharesPendingOutOfBandSession = + action.sessionId.map( + pendingOutOfBandSessionIDs.contains + ) + ?? !pendingOutOfBandSessionIDs.isEmpty + guard !hasUnscopedOutOfBandAction, + !sharesPendingOutOfBandSession + else { + // The OOB response and relay bootstrap are one ordered + // handshake. Unrelated established sessions continue. + continue + } + guard processedActionCount + < Self.maximumActionsPerRetry + else { + hitBatchLimit = true + continue + } + processedActionCount += 1 + guard let event = Self.validatedPublishAction(action) else { + synchronousAcks.append(action.actionId) + continue + } + inFlightActionIDs.insert(action.actionId) + relayManager.sendEventImmediately( + event, + to: nil, + completion: { [weak self, manager] accepted in + guard let self, + self.isCurrent(manager, epoch: epoch) + else { + return + } + self.inFlightActionIDs.remove(action.actionId) + if accepted { + self.deferredActionIDs.remove(action.actionId) + if !self.acknowledge( + [action.actionId], + manager: manager, + epoch: epoch + ) { + self.deferForTransientRetry( + action.actionId, + manager: manager, + epoch: epoch + ) + } + } else { + self.deferForTransientRetry( + action.actionId, + manager: manager, + epoch: epoch + ) + } + } + ) + + case "out_of_band": + guard let eventJson = action.eventJson, + let peer = + action.peerPubkeyHex.flatMap( + Self.normalizedPubkeyHex + ), + Self.isValidOutOfBandResponse(eventJson) + else { + guard processedActionCount + < Self.maximumActionsPerRetry + else { + hitBatchLimit = true + continue + } + processedActionCount += 1 + synchronousAcks.append(action.actionId) + continue + } + guard let expectedPeer, peer == expectedPeer else { + // An OOB action for another peer needs that peer's current + // authenticated BLE route, so it remains durable. + continue + } + guard processedActionCount + < Self.maximumActionsPerRetry + else { + hitBatchLimit = true + continue + } + processedActionCount += 1 + inFlightActionIDs.insert(action.actionId) + outOfBand.append( + NdrOutOfBandAction( + eventJson: eventJson, + peerPubkeyHex: peer, + actionID: action.actionId, + manager: manager, + managerEpoch: epoch + ) + ) + + case "subscribe": + guard processedActionCount + < Self.maximumActionsPerRetry + else { + hitBatchLimit = true + continue + } + processedActionCount += 1 + guard let subscriptionID = action.subscriptionId, + let filterJson = action.filterJson, + let filter = try? JSONDecoder().decode( + NostrFilter.self, + from: Data(filterJson.utf8) + ), + Self.isAllowedNdrSubscription(filter) + else { + synchronousAcks.append(action.actionId) + continue + } + if !activeSubIDs.contains(subscriptionID) { + let registered = relayManager.subscribe( + filter: filter, + id: subscriptionID, + relayUrls: nil, + handler: { [weak self, manager] event in + self?.processInboundNostrEvent( + event, + manager: manager, + epoch: epoch + ) + }, + onEOSE: nil + ) + guard registered else { + deferForTransientRetry( + action.actionId, + manager: manager, + epoch: epoch + ) + continue + } + activeSubIDs.insert(subscriptionID) + } + synchronousAcks.append(action.actionId) + + case "unsubscribe": + guard processedActionCount + < Self.maximumActionsPerRetry + else { + hitBatchLimit = true + continue + } + processedActionCount += 1 + guard let subscriptionID = action.subscriptionId else { + synchronousAcks.append(action.actionId) + continue + } + if activeSubIDs.remove(subscriptionID) != nil { + relayManager.unsubscribe(id: subscriptionID) + } + synchronousAcks.append(action.actionId) + + case "delivery": + guard processedActionCount + < Self.maximumActionsPerRetry + else { + hitBatchLimit = true + continue + } + guard let message = + Self.validatedDecryptedMessage(from: action) + else { + processedActionCount += 1 + // Malformed or policy-disallowed plaintext is a definitive + // rejection, not a transient retry. + synchronousAcks.append(action.actionId) + continue + } + if Self.isExpiredDelivery(action) { + processedActionCount += 1 + // Expiration is a definitive policy drop. Recheck here, + // after decrypt/validation and immediately before handing + // plaintext to the app. + synchronousAcks.append(action.actionId) + continue + } + guard let handler = onDecryptedMessage else { + // The FFI remains the durable buffer. + continue + } + guard !Self.isExpiredDelivery(action) else { + processedActionCount += 1 + synchronousAcks.append(action.actionId) + continue + } + processedActionCount += 1 + inFlightActionIDs.insert(action.actionId) + handler(message) { [weak self, manager] disposition in + guard let self, + self.isCurrent(manager, epoch: epoch) + else { + return + } + self.inFlightActionIDs.remove(action.actionId) + if disposition == .consumed { + self.deferredActionIDs.remove(action.actionId) + if !self.acknowledge( + [action.actionId], + manager: manager, + epoch: epoch + ) { + self.deferredActionIDs.insert(action.actionId) + } + } else { + self.deferredActionIDs.insert(action.actionId) + } + } + + default: + guard processedActionCount + < Self.maximumActionsPerRetry + else { + hitBatchLimit = true + continue + } + processedActionCount += 1 + // Unknown actions cannot become valid after a retry. + synchronousAcks.append(action.actionId) + } + } + + let synchronousAckSucceeded = acknowledge( + synchronousAcks, + manager: manager, + epoch: epoch + ) + if !synchronousAcks.isEmpty, !synchronousAckSucceeded { + for actionID in synchronousAcks { + deferForTransientRetry( + actionID, + manager: manager, + epoch: epoch + ) + } + } + if hitBatchLimit, + synchronousAckSucceeded + || processedActionCount > synchronousAcks.count + { + schedulePendingActionContinuation( + manager: manager, + epoch: epoch + ) + } + return outOfBand + } + + @discardableResult + private func acknowledge( + _ actionIDs: [String], + manager: PairwiseManager, + epoch: UInt64 + ) -> Bool { + guard !actionIDs.isEmpty, isCurrent(manager, epoch: epoch) else { + return false + } + do { + try manager.ackActions(actionIds: actionIDs) + for actionID in actionIDs { + transientRetryAttempts.removeValue(forKey: actionID) + scheduledTransientRetryTokens.removeValue(forKey: actionID) + } + schedulePendingActionContinuation( + manager: manager, + epoch: epoch + ) + return true + } catch { + SecureLogger.error( + "NdrNostrService: durable action ack failed: \(error)", + category: .session + ) + return false + } + } + + private func deferForTransientRetry( + _ actionID: String, + manager: PairwiseManager, + epoch: UInt64 + ) { + guard isCurrent(manager, epoch: epoch) else { return } + deferredActionIDs.insert(actionID) + guard scheduledTransientRetryTokens[actionID] == nil else { + return + } + let attempt = transientRetryAttempts[actionID, default: 0] + guard attempt < Self.transientRetryDelays.count else { return } + + transientRetryAttempts[actionID] = attempt + 1 + let retryToken = UUID() + scheduledTransientRetryTokens[actionID] = retryToken + retryScheduler(Self.transientRetryDelays[attempt]) { + [weak self, manager] in + guard let self, + self.isCurrent(manager, epoch: epoch), + self.scheduledTransientRetryTokens[actionID] == retryToken, + self.deferredActionIDs.remove(actionID) != nil + else { + return + } + self.scheduledTransientRetryTokens.removeValue( + forKey: actionID + ) + _ = self.processPendingActions(collectOutOfBandFor: nil) + } + } + + private func schedulePendingActionContinuation( + manager: PairwiseManager, + epoch: UInt64 + ) { + guard isCurrent(manager, epoch: epoch), + !continuationScheduled + else { + return + } + continuationScheduled = true + Task { @MainActor [weak self, manager] in + guard let self, + self.isCurrent(manager, epoch: epoch) + else { + return + } + self.continuationScheduled = false + _ = self.processPendingActions(collectOutOfBandFor: nil) + } + } + + private func processInboundNostrEvent( + _ event: NostrEvent, + manager: PairwiseManager, + epoch: UInt64 + ) { + guard isCurrent(manager, epoch: epoch), + event.kind == 1060, event.isValidSignature(), NostrEvent.isWithinInboundTagLimits(event.tags), + Self.isRecipientFreeNdrEnvelope(event), let json = try? event.jsonString(), - json.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes + json.utf8.count + <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { SecureLogger.warning( "NdrNostrService: rejected disallowed or malformed relay-origin event", @@ -413,120 +1119,122 @@ final class NdrNostrService { } do { - try mgr.processEvent(eventJson: json) + try manager.processEvent(eventJson: json) } catch { - // ndr will reject most unrelated events; keep log noise low. - SecureLogger.debug("NdrNostrService: processEvent ignored/rejected: \(error)", category: .session) + SecureLogger.debug( + "NdrNostrService: relay event ignored/rejected: \(error)", + category: .session + ) } + guard isCurrent(manager, epoch: epoch) else { return } + processAvailableActions() + } - _ = drainAndApplyPubSubEvents() - if event.kind == 37368, - let owner = Self.normalizedPubkeyHex(event.pubkey) { - retryPendingOutOfBandInvite(ownerPubkeyHex: owner) + private func processAvailableActions() { + _ = processPendingActions(collectOutOfBandFor: nil) + } + + private func releaseDeferredOutOfBandActions(for peerPubkeyHex: String) { + releaseDeferredActions { action in + action.kind == "out_of_band" + && action.peerPubkeyHex.flatMap(Self.normalizedPubkeyHex) + == peerPubkeyHex } } - @discardableResult - private func drainAndApplyPubSubEvents(collectOutOfBandPublishes: Bool = false) -> [String] { - guard let mgr = sessionManager else { return [] } - var outOfBandPublishes: [String] = [] - do { - let events = try mgr.drainEvents() - for e in events { - apply( - pubsub: e, - collectOutOfBandPublish: collectOutOfBandPublishes ? { outOfBandPublishes.append($0) } : nil - ) - } - } catch { - SecureLogger.error("NdrNostrService: drainEvents failed: \(error)", category: .session) + private func releaseDeferredActions( + where shouldRelease: (PairwiseAction) -> Bool + ) { + guard let manager, + let actions = try? manager.pendingActions() + else { + return } - return outOfBandPublishes - } - - private func apply(pubsub e: PubSubEvent, collectOutOfBandPublish: ((String) -> Void)?) { - switch e.kind { - case "subscribe": - guard let subid = e.subid, let filterJson = e.filterJson else { return } - - do { - let filter = try JSONDecoder().decode(NostrFilter.self, from: Data(filterJson.utf8)) - // BitChat policy: don't do Nostr-based DR invite discovery or invite-response listening. - if shouldIgnoreNdrSubscription(filter) { - return - } - if let owner = appKeysSubscriptionOwner(filter) { - guard appKeysSubscriptionIDByOwner[owner] == nil else { - return - } - appKeysSubscriptionIDByOwner[owner] = subid - appKeysOwnerBySubscriptionID[subid] = owner - } - guard activeSubIDs.insert(subid).inserted else { return } // already subscribed - relayManager.subscribe( - filter: filter, - id: subid, - relayUrls: nil, - handler: { [weak self] event in - self?.processInboundNostrEvent(event) - }, - onEOSE: nil - ) - } catch { - SecureLogger.error("NdrNostrService: failed to decode subscribe filter: \(error)", category: .session) - } - - case "unsubscribe": - guard let subid = e.subid else { return } - if let owner = appKeysOwnerBySubscriptionID.removeValue(forKey: subid) { - appKeysSubscriptionIDByOwner.removeValue(forKey: owner) - durableAppKeysOwners.remove(owner) - } - guard activeSubIDs.remove(subid) != nil else { return } - relayManager.unsubscribe(id: subid) - - case "publish_signed": - guard let eventJson = e.eventJson else { return } - do { - let event = try JSONDecoder().decode(NostrEvent.self, from: Data(eventJson.utf8)) - - if isDoubleRatchetInviteEvent(event) { - // Cache the current device invite for out-of-band sharing; never publish to Nostr. - cachedInviteEventJson = eventJson - collectOutOfBandPublish?(eventJson) - return - } - if event.kind == 1059 { - // Giftwrap responses are part of the DR handshake; exchange OOB over BLE. - collectOutOfBandPublish?(eventJson) - return - } - - relayManager.sendEvent(event, to: nil) - } catch { - SecureLogger.error("NdrNostrService: failed to decode outbound event: \(error)", category: .session) - } - - case "decrypted_message": - consumeDecryptedPubSubEvent(e) - - default: - // Other events currently ignored (e.g. app-keys maintenance). - break + for action in actions where shouldRelease(action) { + deferredActionIDs.remove(action.actionId) + transientRetryAttempts.removeValue(forKey: action.actionId) + scheduledTransientRetryTokens.removeValue( + forKey: action.actionId + ) } } - private func isDoubleRatchetInviteEvent(_ event: NostrEvent) -> Bool { - guard event.kind == 30078 else { return false } - for tag in event.tags where tag.count >= 2 { - if tag[0] == "l", tag[1] == "double-ratchet/invites" { - return true - } - if tag[0] == "d", tag[1].hasPrefix("double-ratchet/invites/") { - return true - } + // MARK: - Validation + + static func validatedPublishAction( + _ action: PairwiseAction + ) -> NostrEvent? { + guard action.kind == "publish", + let eventJson = action.eventJson, + eventJson.utf8.count + <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes, + let event = try? JSONDecoder().decode( + NostrEvent.self, + from: Data(eventJson.utf8) + ), + event.kind == 1060, + event.isValidSignature(), + NostrEvent.isWithinInboundTagLimits(event.tags), + isRecipientFreeNdrEnvelope(event), + action.outerEventId == event.id + else { + SecureLogger.warning( + "NdrNostrService: rejected malformed pairwise publish action", + category: .security + ) + return nil } - return false + return event + } + + static func isRecipientFreeNdrEnvelope(_ event: NostrEvent) -> Bool { + !event.tags.contains { $0.first == "p" } + } + + static func validatedDecryptedMessage( + from action: PairwiseAction + ) -> NdrDecryptedMessage? { + guard action.kind == "delivery", + let sender = + action.peerPubkeyHex.flatMap(normalizedPubkeyHex), + let innerJson = action.innerEventJson, + !innerJson.isEmpty, + innerJson.utf8.count + <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes, + let innerID = action.innerEventId, + innerID.count == 64, + let outerID = action.outerEventId, + outerID.count == 64, + let inner = try? JSONDecoder().decode( + NostrEvent.self, + from: Data(innerJson.utf8) + ), + inner.kind == NostrProtocol.EventKind.dm.rawValue, + normalizedPubkeyHex(inner.pubkey) == sender, + inner.id == innerID, + inner.hasValidEventID(), + NostrEvent.isWithinInboundTagLimits(inner.tags) + else { + return nil + } + return NdrDecryptedMessage( + event: inner, + senderPubkeyHex: sender, + outerEventID: outerID, + expiresAtSeconds: action.expiresAtSeconds + ) + } + + static func isExpiredDelivery( + _ action: PairwiseAction, + now: Date = Date() + ) -> Bool { + guard action.kind == "delivery", + let expiresAtSeconds = action.expiresAtSeconds + else { + return false + } + return now.timeIntervalSince1970 >= TimeInterval(expiresAtSeconds) } private enum OutOfBandInviteTransport { @@ -534,250 +1242,80 @@ final class NdrNostrService { case url } + private enum ValidatedOutOfBandMutation { + case invite(OutOfBandInviteTransport) + case response + } + private struct ParsedOutOfBandInvite { - let ownerPubkeyHex: String - let inviterPubkeyHex: String + let peerPubkeyHex: String let transport: OutOfBandInviteTransport } - private func parseOutOfBandInvite(_ payload: String) -> ParsedOutOfBandInvite? { - guard !payload.isEmpty else { return nil } + private func parseOutOfBandInvite( + _ payload: String + ) -> ParsedOutOfBandInvite? { if payload.first == "{" { - guard let event = try? JSONDecoder().decode(NostrEvent.self, from: Data(payload.utf8)), - isDoubleRatchetInviteEvent(event), - event.isValidSignature(), - NostrEvent.isWithinInboundTagLimits(event.tags), - let invite = try? InviteHandle.fromEventJson(eventJson: payload), - let ownerPubkeyHex = try? invite.getOwnerPubkeyHex(), - let inviterPubkeyHex = Self.normalizedPubkeyHex( - invite.getInviterPubkeyHex() - ) else { + guard let event = try? JSONDecoder().decode( + NostrEvent.self, + from: Data(payload.utf8) + ), + event.kind == 30078, + event.isValidSignature(), + NostrEvent.isWithinInboundTagLimits(event.tags), + let invite = try? PairwiseInvite.fromEventJson( + eventJson: payload + ), + let peer = + Self.normalizedPubkeyHex(invite.getPeerPubkeyHex()) + else { return nil } return ParsedOutOfBandInvite( - ownerPubkeyHex: ownerPubkeyHex.lowercased(), - inviterPubkeyHex: inviterPubkeyHex, + peerPubkeyHex: peer, transport: .eventJSON ) } - guard let invite = try? InviteHandle.fromUrl(url: payload), - let ownerPubkeyHex = try? invite.getOwnerPubkeyHex(), - let inviterPubkeyHex = Self.normalizedPubkeyHex( - invite.getInviterPubkeyHex() - ) else { + guard let invite = try? PairwiseInvite.fromUrl(url: payload), + let peer = + Self.normalizedPubkeyHex(invite.getPeerPubkeyHex()) + else { return nil } return ParsedOutOfBandInvite( - ownerPubkeyHex: ownerPubkeyHex.lowercased(), - inviterPubkeyHex: inviterPubkeyHex, + peerPubkeyHex: peer, transport: .url ) } - private func preferredInviteOobPayload() -> String? { - guard let eventJson = cachedInviteEventJson else { return nil } - return compactInviteURL(from: eventJson) ?? eventJson - } - - private func compactInviteURL(from eventJson: String) -> String? { - guard let invite = try? InviteHandle.fromEventJson(eventJson: eventJson) else { - return nil - } - return try? invite.toUrl(root: Self.compactInviteURLRoot) - } - - private func shouldIgnoreNdrSubscription(_ filter: NostrFilter) -> Bool { - // Never use Nostr for invite/response exchange in BitChat. - if filter.kinds?.contains(1059) == true { - return true - } - // `setupUser` emits an author-scoped invite-discovery filter without - // the label selector. AppKeys setup is needed for live device - // revocation, but every kind-30078 discovery shape remains BLE-only. - if filter.kinds?.contains(30078) == true { - return true - } - return false - } - - private func restoreDurableAppKeysSubscriptions( - using mgr: SessionManagerHandle - ) { - for owner in mgr.knownPeerOwnerPubkeys() { - ensureDurableAppKeysSubscription( - ownerPubkeyHex: owner, - using: mgr - ) - } - } - - private func ensureDurableAppKeysSubscription( - ownerPubkeyHex: String, - using mgr: SessionManagerHandle - ) { - guard let owner = Self.normalizedPubkeyHex(ownerPubkeyHex), - !durableAppKeysOwners.contains(owner) + private static func isAllowedNdrSubscription( + _ filter: NostrFilter + ) -> Bool { + guard filter.kinds == [1060], + let authors = filter.authors, + !authors.isEmpty else { - return - } - do { - // The FFI emits both AppKeys and invite-discovery filters. `apply` - // keeps the former and drops the latter under BitChat's BLE-only - // bootstrap policy. - try mgr.setupUser(userPubkeyHex: owner) - durableAppKeysOwners.insert(owner) - } catch { - SecureLogger.error( - "NdrNostrService: failed to retain AppKeys updates for \(owner.prefix(8))…: \(error)", - category: .session - ) + return false } + return authors.allSatisfy { normalizedPubkeyHex($0) != nil } } - private func appKeysSubscriptionOwner(_ filter: NostrFilter) -> String? { - guard filter.kinds == [37368], - filter.authors?.count == 1, - let author = filter.authors?.first + private static func isValidOutOfBandResponse( + _ eventJson: String + ) -> Bool { + guard eventJson.utf8.count + <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes, + let event = try? JSONDecoder().decode( + NostrEvent.self, + from: Data(eventJson.utf8) + ) else { - return nil - } - return Self.normalizedPubkeyHex(author) - } - - private func retainPendingOutOfBandInvite( - payload: String, - ownerPubkeyHex: String, - inviterPubkeyHex: String, - authorization: @escaping () -> Bool, - deferredResponseHandler: @escaping (String) -> Void - ) { - let key = PendingOutOfBandInviteKey( - ownerPubkeyHex: ownerPubkeyHex, - inviterPubkeyHex: inviterPubkeyHex - ) - nextPendingOutOfBandInviteSequence &+= 1 - pendingOutOfBandInvites[key] = PendingOutOfBandInvite( - key: key, - payload: payload, - sequence: nextPendingOutOfBandInviteSequence, - authorization: authorization, - deferredResponseHandler: deferredResponseHandler - ) - // Insert first so evicting an older device under this same owner - // cannot tear down the owner's sole AppKeys subscription. - if pendingOutOfBandInvites.count > Self.maximumPendingOutOfBandInvites, - let oldest = pendingOutOfBandInvites.min(by: { $0.value.sequence < $1.value.sequence })?.key { - finishPendingOutOfBandInvite(key: oldest) - } - } - - private func retryPendingOutOfBandInvite(ownerPubkeyHex: String) { - let pendingForOwner = pendingOutOfBandInvites.values - .filter { $0.key.ownerPubkeyHex == ownerPubkeyHex } - .sorted { $0.sequence < $1.sequence } - for pending in pendingForOwner { - guard pending.authorization() else { - finishPendingOutOfBandInvite(key: pending.key) - continue - } - let responses = processOutOfBandEventJson( - pending.payload, - expectedPeerPubkeyHex: ownerPubkeyHex, - authorization: pending.authorization, - deferredResponseHandler: pending.deferredResponseHandler - ) - for response in responses { - pending.deferredResponseHandler(response) - } - } - } - - private func finishPendingOutOfBandInvite( - ownerPubkeyHex: String, - inviterPubkeyHex: String - ) { - finishPendingOutOfBandInvite( - key: PendingOutOfBandInviteKey( - ownerPubkeyHex: ownerPubkeyHex, - inviterPubkeyHex: inviterPubkeyHex - ) - ) - } - - private func finishPendingOutOfBandInvite(key: PendingOutOfBandInviteKey) { - pendingOutOfBandInvites.removeValue(forKey: key) - let ownerPubkeyHex = key.ownerPubkeyHex - guard !pendingOutOfBandInvites.keys.contains(where: { - $0.ownerPubkeyHex == ownerPubkeyHex - }) else { - return - } - guard !durableAppKeysOwners.contains(ownerPubkeyHex) else { - return - } - guard let subid = appKeysSubscriptionIDByOwner.removeValue(forKey: ownerPubkeyHex) else { - return - } - appKeysOwnerBySubscriptionID.removeValue(forKey: subid) - if activeSubIDs.remove(subid) != nil { - relayManager.unsubscribe(id: subid) - } - } - - func consumeDecryptedPubSubEvent(_ event: PubSubEvent) { - guard rolloutEnabled, - let inner = Self.validatedDecryptedMessage(from: event) - else { - SecureLogger.warning( - "NdrNostrService: rejected a malformed or misattributed decrypted rumor", - category: .security - ) - return - } - guard let onDecryptedMessage else { - if bufferedDecryptedMessages.count >= Self.maximumBufferedDecryptedMessages { - bufferedDecryptedMessages.removeFirst() - } - bufferedDecryptedMessages.append(inner) - return - } - onDecryptedMessage(inner) - } - - static func validatedDecryptedMessage(from event: PubSubEvent) -> NdrDecryptedMessage? { - guard event.kind == "decrypted_message", - let sender = event.senderPubkeyHex.flatMap(normalizedPubkeyHex), - event.senderDevicePubkeyHex == nil - || event.senderDevicePubkeyHex.flatMap(normalizedPubkeyHex) != nil, - event.conversationOwnerPubkeyHex == nil - || event.conversationOwnerPubkeyHex.flatMap(normalizedPubkeyHex) != nil, - event.eventId == nil || event.eventId?.count == 64, - let innerJson = event.content, - !innerJson.isEmpty, - innerJson.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes, - let inner = try? JSONDecoder().decode(NostrEvent.self, from: Data(innerJson.utf8)), - inner.kind == NostrProtocol.EventKind.dm.rawValue, - let innerAuthor = normalizedPubkeyHex(inner.pubkey), - innerAuthor == sender, - NostrEvent.isWithinInboundTagLimits(inner.tags), - inner.hasValidEventID() - else { - return nil - } - return NdrDecryptedMessage( - event: inner, - senderPubkeyHex: sender, - senderDevicePubkeyHex: event.senderDevicePubkeyHex.flatMap(normalizedPubkeyHex), - conversationOwnerPubkeyHex: - event.conversationOwnerPubkeyHex.flatMap(normalizedPubkeyHex) - ) - } - - private func flushBufferedDecryptedMessages() { - while let handler = onDecryptedMessage, !bufferedDecryptedMessages.isEmpty { - handler(bufferedDecryptedMessages.removeFirst()) + return false } + return event.kind == 1059 + && event.isValidSignature() + && NostrEvent.isWithinInboundTagLimits(event.tags) } private static func normalizedPubkeyHex(_ value: String) -> String? { @@ -791,37 +1329,103 @@ final class NdrNostrService { return lowered } - private static func loadOrCreateDeviceId() -> String { - let defaults = UserDefaults.standard - if let existing = defaults.string(forKey: deviceIdKey), !existing.isEmpty { - return existing + // MARK: - Manager lifecycle + + private func isCurrent( + _ candidate: PairwiseManager, + epoch: UInt64 + ) -> Bool { + managerEpoch == epoch && manager === candidate + } + + private func replaceManager( + with replacement: PairwiseManager?, + configuredPubkeyHex: String? + ) { + managerEpoch &+= 1 + for id in activeSubIDs { + relayManager.unsubscribe(id: id) } - let id = UUID().uuidString - defaults.set(id, forKey: deviceIdKey) - return id + activeSubIDs.removeAll() + inFlightActionIDs.removeAll() + deferredActionIDs.removeAll() + transientRetryAttempts.removeAll() + scheduledTransientRetryTokens.removeAll() + continuationScheduled = false + manager = replacement + configuredForPubkeyHex = configuredPubkeyHex } - private static let deviceIdKey = "ndr.device_id" - - private static func clearPersistedDeviceId() { - UserDefaults.standard.removeObject(forKey: deviceIdKey) + private func markEstablishedSessionIfNeeded( + manager: PairwiseManager, + identityPubkeyHex: String + ) throws { + guard !(try manager.knownPeerPubkeys()).isEmpty else { return } + try sessionMarkerStore.mark( + identityPubkeyHex: identityPubkeyHex.lowercased() + ) } - private static func ndrStorageDirectory() throws -> URL { - let root = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? FileManager.default.temporaryDirectory - let dir = root.appendingPathComponent("ndr", isDirectory: true) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) + static func hasDurablePairwiseState(at directory: URL) -> Bool { + guard let names = try? FileManager.default.contentsOfDirectory( + atPath: directory.path + ) else { + return false + } + return names.contains { name in + name.hasPrefix("ndr-pairwise-state-v1-") + && name.hasSuffix(".json") + } + } + + static func ndrStorageDirectory( + applicationSupportDirectory: URL? = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first, + fileManager: FileManager = .default + ) throws -> URL { + guard let root = applicationSupportDirectory else { + throw NdrStorageDirectoryError + .applicationSupportUnavailable + } + let directory = root.appendingPathComponent( + "ndr", + isDirectory: true + ) + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil + ) var resourceValues = URLResourceValues() resourceValues.isExcludedFromBackup = true - var protectedDirectory = dir + var protectedDirectory = directory try protectedDirectory.setResourceValues(resourceValues) #if os(iOS) - try FileManager.default.setAttributes( - [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], - ofItemAtPath: dir.path + try fileManager.setAttributes( + [ + .protectionKey: + FileProtectionType + .completeUntilFirstUserAuthentication + ], + ofItemAtPath: directory.path ) #endif - return dir + return directory + } + + static func scheduleLiveRetry( + after delay: TimeInterval, + operation: @escaping @MainActor () -> Void + ) { + Task { @MainActor in + let nanoseconds = UInt64( + max(0, delay) * 1_000_000_000 + ) + try? await Task.sleep(nanoseconds: nanoseconds) + guard !Task.isCancelled else { return } + operation() + } } } diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index 131021a2..b2b8614b 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -16,6 +16,8 @@ final class NostrTransport: Transport, @unchecked Sendable { case missingSenderIdentity case failedToEncodePacket case failedToBuildFallbackEvent + case ndrSessionFailure + case expiringMessageRequiresNdrSession var errorDescription: String? { switch self { @@ -29,15 +31,28 @@ final class NostrTransport: Transport, @unchecked Sendable { return "Failed to encode embedded private-message packet" case .failedToBuildFallbackEvent: return "Failed to build fallback private-message event" + case .ndrSessionFailure: + return "The active double-ratchet session could not send" + case .expiringMessageRequiresNdrSession: + return "Disappearing messages require an active double-ratchet session" } } } + private enum WrappedMessageOutcome { + case sent(OutboundPrivateMessageTransport) + case ndrFailed + case ndrRequired + case fallbackBuildFailed + } + struct Dependencies { let notificationCenter: NotificationCenter let loadFavorites: @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship] let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship? let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship? + let canUseNdrBindingForPeerID: @MainActor (PeerID) -> Bool + let isNdrFallbackBlockedForPeerID: @MainActor (PeerID) -> Bool let currentIdentity: @MainActor () throws -> NostrIdentity? let registerPendingGiftWrap: @MainActor (String) -> Void let sendEvent: @MainActor (NostrEvent) -> Void @@ -55,6 +70,8 @@ final class NostrTransport: Transport, @unchecked Sendable { loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship], favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?, favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?, + canUseNdrBindingForPeerID: @escaping @MainActor (PeerID) -> Bool = { _ in true }, + isNdrFallbackBlockedForPeerID: @escaping @MainActor (PeerID) -> Bool = { _ in false }, currentIdentity: @escaping @MainActor () throws -> NostrIdentity?, registerPendingGiftWrap: @escaping @MainActor (String) -> Void, sendEvent: @escaping @MainActor (NostrEvent) -> Void, @@ -66,6 +83,10 @@ final class NostrTransport: Transport, @unchecked Sendable { self.loadFavorites = loadFavorites self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey self.favoriteStatusForPeerID = favoriteStatusForPeerID + self.canUseNdrBindingForPeerID = + canUseNdrBindingForPeerID + self.isNdrFallbackBlockedForPeerID = + isNdrFallbackBlockedForPeerID self.currentIdentity = currentIdentity self.registerPendingGiftWrap = registerPendingGiftWrap self.sendEvent = sendEvent @@ -83,6 +104,14 @@ final class NostrTransport: Transport, @unchecked Sendable { loadFavorites: { FavoritesPersistenceService.shared.favorites }, favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) }, favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) }, + canUseNdrBindingForPeerID: { + FavoritesPersistenceService.shared + .canUseNdrBinding(for: $0) + }, + isNdrFallbackBlockedForPeerID: { + FavoritesPersistenceService.shared + .isNdrFallbackBlocked(for: $0) + }, currentIdentity: { try idBridge.getCurrentNostrIdentity() }, registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) }, sendEvent: { NostrRelayManager.shared.sendEvent($0) }, @@ -183,7 +212,12 @@ final class NostrTransport: Transport, @unchecked Sendable { // Synchronously warm the cache to avoid startup race let favorites = self.dependencies.loadFavorites() let reachable = favorites.values - .filter { $0.peerNostrPublicKey != nil } + .filter { + $0.peerNostrPublicKey != nil + && self.dependencies.canUseNdrBindingForPeerID( + PeerID(publicKey: $0.peerNoisePublicKey) + ) + } .map { PeerID(publicKey: $0.peerNoisePublicKey) } queue.sync(flags: .barrier) { @@ -217,7 +251,12 @@ final class NostrTransport: Transport, @unchecked Sendable { Task { @MainActor in let favorites = dependencies.loadFavorites() let reachable = favorites.values - .filter { $0.peerNostrPublicKey != nil } + .filter { + $0.peerNostrPublicKey != nil + && dependencies.canUseNdrBindingForPeerID( + PeerID(publicKey: $0.peerNoisePublicKey) + ) + } .map { PeerID(publicKey: $0.peerNoisePublicKey) } self.queue.async(flags: .barrier) { [weak self] in @@ -297,13 +336,44 @@ final class NostrTransport: Transport, @unchecked Sendable { } } + func sendPrivateMessage( + _ content: String, + to peerID: PeerID, + recipientNickname: String, + messageID: String, + expiresAtSeconds: UInt64 + ) { + Task { @MainActor in + do { + _ = try sendPrivateMessageAndReturnTransport( + content, + to: peerID, + recipientNickname: recipientNickname, + messageID: messageID, + expiresAtSeconds: expiresAtSeconds + ) + } catch { + SecureLogger.error( + "NostrTransport: failed to send disappearing PM: \(error)", + category: .session + ) + } + } + } + @MainActor func sendPrivateMessageAndReturnTransport( _ content: String, to peerID: PeerID, recipientNickname _: String, - messageID: String + messageID: String, + expiresAtSeconds: UInt64? = nil ) throws -> OutboundPrivateMessageTransport { + guard dependencies.canUseNdrBindingForPeerID(peerID) else { + throw OutboundPrivateMessageError.ndrSessionFailure + } + let requiresNdr = + dependencies.isNdrFallbackBlockedForPeerID(peerID) guard let recipientNpub = resolveRecipientNpub(for: peerID) else { throw OutboundPrivateMessageError.missingRecipientNpub(peerID.id) } @@ -325,14 +395,26 @@ final class NostrTransport: Transport, @unchecked Sendable { ) else { throw OutboundPrivateMessageError.failedToEncodePacket } - guard let transport = sendWrappedMessage( + switch sendWrappedMessage( content: embedded, recipientHex: recipientHex, - senderIdentity: senderIdentity - ) else { + senderIdentity: senderIdentity, + requiresNdr: requiresNdr, + expiresAtSeconds: expiresAtSeconds + ) { + case .sent(let transport): + return transport + case .ndrFailed: + throw OutboundPrivateMessageError.ndrSessionFailure + case .ndrRequired: + if expiresAtSeconds != nil { + throw OutboundPrivateMessageError + .expiringMessageRequiresNdrSession + } + throw OutboundPrivateMessageError.ndrSessionFailure + case .fallbackBuildFailed: throw OutboundPrivateMessageError.failedToBuildFallbackEvent } - return transport } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { @@ -357,7 +439,13 @@ final class NostrTransport: Transport, @unchecked Sendable { SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session) return } - sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity) + sendWrappedMessage( + content: embedded, + recipientHex: recipientHex, + senderIdentity: senderIdentity, + requiresNdr: + dependencies.isNdrFallbackBlockedForPeerID(peerID) + ) } } @@ -424,27 +512,51 @@ extension NostrTransport { recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false, - allowNdr: Bool = true - ) -> OutboundPrivateMessageTransport? { + allowNdr: Bool = true, + requiresNdr: Bool = false, + expiresAtSeconds: UInt64? = nil + ) -> WrappedMessageOutcome { if allowNdr { // Invites/responses travel only over an authenticated BLE Noise // session; relay transport is used only after both clients have // established the same ratchet session out of band. ndrService.configureIfNeeded(identity: senderIdentity) - if ndrService.sendIfPossible(content, to: recipientHex) { - return .ndr + switch ndrService.send( + content, + to: recipientHex, + expiresAtSeconds: expiresAtSeconds + ) { + case .sent: + return .sent(.ndr) + case .noSession: + if expiresAtSeconds != nil || requiresNdr { + SecureLogger.warning( + "NostrTransport: refusing legacy downgrade where NDR is required", + category: .security + ) + return .ndrRequired + } + break + case .failed: + SecureLogger.error( + "NostrTransport: active NDR session failed; refusing legacy downgrade", + category: .security + ) + return .ndrFailed } + } else if expiresAtSeconds != nil || requiresNdr { + return .ndrRequired } guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session) - return nil + return .fallbackBuildFailed } if registerPending { dependencies.registerPendingGiftWrap(event.id) } dependencies.sendEvent(event) - return .legacy1059 + return .sent(.legacy1059) } @@ -461,7 +573,13 @@ extension NostrTransport { SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session) return } - sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity) + sendWrappedMessage( + content: ack, + recipientHex: recipientHex, + senderIdentity: senderIdentity, + requiresNdr: + dependencies.isNdrFallbackBlockedForPeerID(peerID) + ) case .deliveredDirect(let messageID, let peerID): guard let recipientNpub = resolveRecipientNpub(for: peerID), @@ -472,7 +590,13 @@ extension NostrTransport { SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session) return } - sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity) + sendWrappedMessage( + content: ack, + recipientHex: recipientHex, + senderIdentity: senderIdentity, + requiresNdr: + dependencies.isNdrFallbackBlockedForPeerID(peerID) + ) case .deliveredGeohash(let messageID, let recipientHex, let identity): SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session) @@ -501,6 +625,9 @@ extension NostrTransport { @MainActor private func resolveRecipientNpub(for peerID: PeerID) -> String? { + guard dependencies.canUseNdrBindingForPeerID(peerID) else { + return nil + } if let noiseKey = Data(hexString: peerID.id), let fav = dependencies.favoriteStatusForNoiseKey(noiseKey), let npub = fav.peerNostrPublicKey { diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index b883fa13..7b354a0d 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -276,7 +276,8 @@ protocol Transport: AnyObject { func sendNdrEvent( to peerID: PeerID, eventJson: String, - expectedTransportState: AuthenticatedPeerTransportState + expectedTransportState: AuthenticatedPeerTransportState, + completion: @escaping @MainActor (Bool) -> Void ) // Vouching / transitive verification (optional for transports) @@ -370,8 +371,13 @@ extension Transport { func sendNdrEvent( to peerID: PeerID, eventJson: String, - expectedTransportState: AuthenticatedPeerTransportState - ) {} + expectedTransportState: AuthenticatedPeerTransportState, + completion: @escaping @MainActor (Bool) -> Void + ) { + Task { @MainActor in + completion(false) + } + } func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {} func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {} func broadcastGroupMessage(_ envelope: Data) {} diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index 7a3a3051..e011c763 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -565,49 +565,6 @@ final class ChatPrivateConversationCoordinator { context.notifyUIChanged() } - /// Ingests a message authored on another device belonging to our account. - /// It is a sent message in the remote peer's thread: do not acknowledge it, - /// mark it unread, or notify as though it came from the remote peer. - func handleLocalSiblingPrivateMessage( - _ payload: NoisePayload, - conversationPubkey: String, - convKey: PeerID, - messageTimestamp: Date - ) { - guard let pm = PrivateMessagePacket.decode(from: payload.data), - !context.isNostrBlocked(pubkeyHexLowercased: conversationPubkey), - !context.privateChatsContainMessage(withID: pm.messageID) - else { - return - } - - let conversationPeerID = consolidateAccountConversationAliases(for: convKey) - let recipientName: String = { - if let noiseKey = conversationPeerID.noiseKey, - let favoriteNickname = - context.favoriteRelationship(forNoiseKey: noiseKey)?.peerNickname, - !favoriteNickname.isEmpty { - return favoriteNickname - } - return context.displayNameForNostrPubkey(conversationPubkey) - }() - let message = BitchatMessage( - id: pm.messageID, - sender: context.nickname, - content: pm.content, - timestamp: messageTimestamp, - isRelay: false, - isPrivate: true, - recipientNickname: recipientName, - senderPeerID: context.myPeerID, - deliveryStatus: .sent - ) - guard context.appendPrivateMessage(message, to: conversationPeerID) else { - return - } - context.notifyUIChanged() - } - func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { guard let messageID = String(data: payload.data, encoding: .utf8) else { return } diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index 299f6970..07fa21e1 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -181,32 +181,80 @@ extension ChatViewModel: ChatTransportEventContext { let authenticated = meshService.authenticatedPeerTransportState(peerID), authenticated.capabilities.contains(.doubleRatchet), - let relationship = FavoritesPersistenceService.shared.getFavoriteStatus( + let relationship = favoritesService.getFavoriteStatus( for: authenticated.noisePublicKey ), relationship.isMutual, let peerNostrKey = relationship.peerNostrPublicKey, let peerPubkeyHex = Self.ndrNostrPubkeyHex(from: peerNostrKey), + favoritesService.canUseNdrBinding( + peerNoisePublicKey: authenticated.noisePublicKey, + peerNostrPublicKey: peerNostrKey + ), let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return } - ndrService.configureIfNeeded(identity: currentIdentity) - guard !ndrService.hasActiveSession(with: peerPubkeyHex), - let invite = ndrService.currentInviteEventJson() - else { + ndrService.configureIfNeeded( + identity: currentIdentity, + processPendingActions: false + ) + guard prepareDoubleRatchetPeerBinding( + peerID: peerID, + noisePublicKey: authenticated.noisePublicKey, + peerPubkeyHex: peerPubkeyHex, + currentIdentityPubkeyHex: currentIdentity.publicKeyHex + ) else { return } + if ndrService.hasPairwiseSession(with: peerPubkeyHex) { + guard favoritesService.markNdrRequired( + for: authenticated.noisePublicKey + ) else { + return + } + ndrService.configureIfNeeded(identity: currentIdentity) + } + let shouldReleaseDeferredOutOfBand = + ndrOutOfBandGenerationByPeer[peerID] + != authenticated.sessionGeneration + ndrOutOfBandGenerationByPeer[peerID] = + authenticated.sessionGeneration + sendNdrOutOfBandActions( + ndrService.pendingOutOfBandActions( + forAuthenticatedPeerPubkeyHex: peerPubkeyHex, + releaseDeferred: shouldReleaseDeferredOutOfBand + ), + to: peerID, + peerPubkeyHex: peerPubkeyHex, + expectedTransportState: authenticated + ) + if ndrService.hasPairwiseSession(with: peerPubkeyHex) { + ndrInviteAttemptTokenByPeer.removeValue(forKey: peerID) + return + } + guard let invite = ndrService.currentInviteAction() else { return } + let inviteAttemptToken = [ + authenticated.sessionGeneration.uuidString, + peerPubkeyHex, + invite.eventID + ].joined(separator: "|") + guard ndrInviteAttemptTokenByPeer[peerID] != inviteAttemptToken else { + return + } + ndrInviteAttemptTokenByPeer[peerID] = inviteAttemptToken SecureLogger.debug( "NDR: OOB invite -> \(peerID.id.prefix(8))… peer=\(peerPubkeyHex.prefix(8))…", category: .session ) - meshService.sendNdrEvent( + sendNdrInvite( + invite, to: peerID, - eventJson: invite, - expectedTransportState: authenticated + peerPubkeyHex: peerPubkeyHex, + expectedTransportState: authenticated, + inviteAttemptToken: inviteAttemptToken ) } @@ -217,12 +265,16 @@ extension ChatViewModel: ChatTransportEventContext { let authenticated = meshService.authenticatedPeerTransportState(peerID), authenticated.capabilities.contains(.doubleRatchet), - let relationship = FavoritesPersistenceService.shared.getFavoriteStatus( + let relationship = favoritesService.getFavoriteStatus( for: authenticated.noisePublicKey ), relationship.isMutual, let peerNostrKey = relationship.peerNostrPublicKey, let peerPubkeyHex = Self.ndrNostrPubkeyHex(from: peerNostrKey), + favoritesService.canUseNdrBinding( + peerNoisePublicKey: authenticated.noisePublicKey, + peerNostrPublicKey: peerNostrKey + ), let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return @@ -235,30 +287,172 @@ extension ChatViewModel: ChatTransportEventContext { expectedPeerPubkeyHex: peerPubkeyHex ) == true } - let sendIfExpectedBindingCurrent: (String) -> Void = { [weak self] response in - guard let self, - isExpectedBindingCurrent() - else { - return - } - self.meshService.sendNdrEvent( - to: peerID, - eventJson: response, - expectedTransportState: authenticated - ) + ndrService.configureIfNeeded( + identity: currentIdentity, + processPendingActions: false + ) + guard prepareDoubleRatchetPeerBinding( + peerID: peerID, + noisePublicKey: authenticated.noisePublicKey, + peerPubkeyHex: peerPubkeyHex, + currentIdentityPubkeyHex: currentIdentity.publicKeyHex + ) else { + return } - - ndrService.configureIfNeeded(identity: currentIdentity) - for response in ndrService.processOutOfBandEventJson( + let actions = ndrService.processOutOfBandEventJson( eventJson, expectedPeerPubkeyHex: peerPubkeyHex, authorization: isExpectedBindingCurrent, - deferredResponseHandler: sendIfExpectedBindingCurrent - ) { - sendIfExpectedBindingCurrent(response) + persistEstablishedBinding: { [weak self] in + self?.favoritesService.markNdrRequired( + for: authenticated.noisePublicKey + ) == true + } + ) + sendNdrOutOfBandActions( + actions, + to: peerID, + peerPubkeyHex: peerPubkeyHex, + expectedTransportState: authenticated + ) + } + + private func sendNdrOutOfBandActions( + _ actions: [NdrOutOfBandAction], + to peerID: PeerID, + peerPubkeyHex: String, + expectedTransportState: AuthenticatedPeerTransportState + ) { + for action in actions { + sendNdrOutOfBandAction( + action, + to: peerID, + peerPubkeyHex: peerPubkeyHex, + expectedTransportState: expectedTransportState, + retryAttempt: 0 + ) } } + private func sendNdrInvite( + _ invite: NdrInviteAction, + to peerID: PeerID, + peerPubkeyHex: String, + expectedTransportState: AuthenticatedPeerTransportState, + inviteAttemptToken: String, + retryAttempt: Int = 0 + ) { + guard ndrInviteAttemptTokenByPeer[peerID] == inviteAttemptToken, + !ndrService.hasPairwiseSession(with: peerPubkeyHex), + ndrService.isCurrentInviteAction(invite), + isCurrentDoubleRatchetBinding( + peerID: peerID, + expectedTransportState: expectedTransportState, + expectedPeerPubkeyHex: peerPubkeyHex + ) + else { + if ndrInviteAttemptTokenByPeer[peerID] == inviteAttemptToken { + ndrInviteAttemptTokenByPeer.removeValue(forKey: peerID) + } + return + } + + meshService.sendNdrEvent( + to: peerID, + eventJson: invite.eventJson, + expectedTransportState: expectedTransportState, + completion: { [weak self] succeeded in + guard !succeeded, let self else { return } + guard self.ndrInviteAttemptTokenByPeer[peerID] + == inviteAttemptToken + else { + return + } + self.ndrService.scheduleHostTransientRetry( + after: retryAttempt + ) { + [weak self] in + self?.sendNdrInvite( + invite, + to: peerID, + peerPubkeyHex: peerPubkeyHex, + expectedTransportState: expectedTransportState, + inviteAttemptToken: inviteAttemptToken, + retryAttempt: retryAttempt + 1 + ) + } + } + ) + } + + private func sendNdrOutOfBandAction( + _ action: NdrOutOfBandAction, + to peerID: PeerID, + peerPubkeyHex: String, + expectedTransportState: AuthenticatedPeerTransportState, + retryAttempt: Int + ) { + guard action.peerPubkeyHex == peerPubkeyHex, + isCurrentDoubleRatchetBinding( + peerID: peerID, + expectedTransportState: expectedTransportState, + expectedPeerPubkeyHex: peerPubkeyHex + ) + else { + ndrService.completeOutOfBandAction( + action, + succeeded: false + ) + return + } + + let service = ndrService + meshService.sendNdrEvent( + to: peerID, + eventJson: action.eventJson, + expectedTransportState: expectedTransportState, + completion: { [weak self] succeeded in + service.completeOutOfBandAction( + action, + succeeded: succeeded + ) + guard !succeeded, let self else { return } + self.ndrService.scheduleHostTransientRetry( + after: retryAttempt + ) { + [weak self] in + guard let self else { return } + guard self.isCurrentDoubleRatchetBinding( + peerID: peerID, + expectedTransportState: expectedTransportState, + expectedPeerPubkeyHex: peerPubkeyHex + ) + else { + if self.ndrOutOfBandGenerationByPeer[peerID] + == expectedTransportState.sessionGeneration + { + self.ndrOutOfBandGenerationByPeer + .removeValue(forKey: peerID) + } + return + } + guard + service.prepareOutOfBandActionForRetry(action) + else { + return + } + self.sendNdrOutOfBandAction( + action, + to: peerID, + peerPubkeyHex: peerPubkeyHex, + expectedTransportState: expectedTransportState, + retryAttempt: retryAttempt + 1 + ) + } + } + ) + } + private func isCurrentDoubleRatchetBinding( peerID: PeerID, expectedTransportState: AuthenticatedPeerTransportState, @@ -266,21 +460,184 @@ extension ChatViewModel: ChatTransportEventContext { ) -> Bool { guard meshService.authenticatedPeerTransportState(peerID) == expectedTransportState, expectedTransportState.capabilities.contains(.doubleRatchet), - let relationship = FavoritesPersistenceService.shared.getFavoriteStatus( + let relationship = favoritesService.getFavoriteStatus( for: expectedTransportState.noisePublicKey ), relationship.isMutual, let peerNostrKey = relationship.peerNostrPublicKey, - Self.ndrNostrPubkeyHex(from: peerNostrKey) == expectedPeerPubkeyHex + Self.ndrNostrPubkeyHex(from: peerNostrKey) + == expectedPeerPubkeyHex, + favoritesService.canUseNdrBinding( + peerNoisePublicKey: + expectedTransportState.noisePublicKey, + peerNostrPublicKey: peerNostrKey + ) else { return false } return true } - private static func ndrNostrPubkeyHex(from npubOrHex: String) -> String? { - if npubOrHex.hasPrefix("npub") { - guard let (hrp, data) = try? Bech32.decode(npubOrHex), + private func prepareDoubleRatchetPeerBinding( + peerID: PeerID, + noisePublicKey: Data, + peerPubkeyHex: String, + currentIdentityPubkeyHex: String + ) -> Bool { + let identityPubkeyHex = currentIdentityPubkeyHex.lowercased() + if ndrBindingIdentityPubkeyHex != identityPubkeyHex { + ndrInviteAttemptTokenByPeer.removeAll() + ndrOutOfBandGenerationByPeer.removeAll() + ndrPeerPubkeyByNoiseKey.removeAll() + ndrBindingIdentityPubkeyHex = identityPubkeyHex + } + + if let previousPeerPubkeyHex = + ndrPeerPubkeyByNoiseKey[noisePublicKey], + previousPeerPubkeyHex != peerPubkeyHex + { + guard ndrService.retirePeer(previousPeerPubkeyHex) else { + return false + } + ndrInviteAttemptTokenByPeer.removeValue(forKey: peerID) + ndrOutOfBandGenerationByPeer.removeValue(forKey: peerID) + } + ndrPeerPubkeyByNoiseKey[noisePublicKey] = peerPubkeyHex + return true + } + + func authorizeDoubleRatchetFavoriteRebind( + noisePublicKey: Data, + oldNostrPublicKey: String?, + newNostrPublicKey: String + ) -> Bool { + guard let newPeerPubkeyHex = + Self.ndrNostrPubkeyHex(from: newNostrPublicKey) + else { + // A malformed destination cannot be collision-checked. + return false + } + let oldPeerPubkeyHex: String? + if let oldNostrPublicKey { + guard let normalized = + Self.ndrNostrPubkeyHex(from: oldNostrPublicKey) + else { + // A malformed existing binding cannot be safely retired. + return false + } + oldPeerPubkeyHex = normalized + } else { + oldPeerPubkeyHex = nil + } + guard oldPeerPubkeyHex != newPeerPubkeyHex else { return true } + let otherFavoritePubkeys = + favoritesService.peerNostrPublicKeys( + excludingNoisePublicKey: noisePublicKey + ) + .compactMap { Self.ndrNostrPubkeyHex(from: $0) } + guard !otherFavoritePubkeys.contains(newPeerPubkeyHex) else { + // A Nostr identity may have only one stable Noise binding. Without + // this, two radio identities could both authorize the same ratchet. + return false + } + guard let oldPeerPubkeyHex else { + // Initial and nil-to-value assignments have nothing to retire. + return true + } + guard ndrService.isRolloutEnabled else { + // FavoritesPersistenceService still journals and commits a + // previously pinned binding while rollout is dark. There is no + // new session to discover or pin on this path. + return true + } + guard let currentIdentity = + try? idBridge.getCurrentNostrIdentity() + else { + return false + } + + ndrService.configureIfNeeded( + identity: currentIdentity, + processPendingActions: false + ) + guard ndrService.isConfigured else { + return false + } + if ndrService.hasPairwiseSession(with: oldPeerPubkeyHex) { + return favoritesService.markNdrRequired( + for: noisePublicKey + ) + } + return true + } + + func commitDoubleRatchetFavoriteRebind( + noisePublicKey: Data, + oldNostrPublicKey: String, + newNostrPublicKey: String + ) -> Bool { + guard let oldPeerPubkeyHex = + Self.ndrNostrPubkeyHex(from: oldNostrPublicKey), + let newPeerPubkeyHex = + Self.ndrNostrPubkeyHex(from: newNostrPublicKey), + let currentIdentity = + try? idBridge.getCurrentNostrIdentity() + else { + return false + } + // Representation-only changes (hex ↔ npub or case) carry no + // retirement intent. Returning success also recovers journals written + // by an older build before equivalent keys were normalized. + guard oldPeerPubkeyHex != newPeerPubkeyHex else { + return true + } + let otherFavoritePubkeys = + favoritesService.peerNostrPublicKeys( + excludingNoisePublicKey: noisePublicKey + ) + .compactMap { Self.ndrNostrPubkeyHex(from: $0) } + guard !otherFavoritePubkeys.contains(newPeerPubkeyHex) else { + return false + } + + // Configuration and retirement are intentionally action-silent here: + // the durable rebind journal exists, but the target favorite has not + // been committed yet. Relay work resumes through the normal setup/send + // path only after FavoritesPersistenceService verifies that commit. + guard ndrService.configureIfNeeded( + identity: currentIdentity, + processPendingActions: false, + allowDisabledMaintenance: true + ) else { + return false + } + if !otherFavoritePubkeys.contains(oldPeerPubkeyHex), + !ndrService.retirePeer( + oldPeerPubkeyHex, + processPendingActions: false, + allowDisabledMaintenance: true + ) + { + return false + } + let reboundPeerIDs = ndrOutOfBandGenerationByPeer.keys.filter { + meshService.authenticatedPeerTransportState($0)? + .noisePublicKey == noisePublicKey + } + for peerID in reboundPeerIDs { + ndrInviteAttemptTokenByPeer.removeValue(forKey: peerID) + ndrOutOfBandGenerationByPeer.removeValue(forKey: peerID) + } + ndrPeerPubkeyByNoiseKey[noisePublicKey] = newPeerPubkeyHex + ndrBindingIdentityPubkeyHex = + currentIdentity.publicKeyHex.lowercased() + return true + } + + static func ndrNostrPubkeyHex(from npubOrHex: String) -> String? { + let lowered = npubOrHex.lowercased() + if lowered.hasPrefix("npub") { + guard let (hrp, data) = try? Bech32.decode(lowered), hrp == "npub", data.count == 32 else { @@ -289,7 +646,6 @@ extension ChatViewModel: ChatTransportEventContext { return data.hexEncodedString() } - let lowered = npubOrHex.lowercased() guard lowered.count == 64, lowered.allSatisfy(\.isHexDigit) else { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index c392ca4c..c33e14ee 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -317,6 +317,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage let idBridge: NostrIdentityBridge let identityManager: SecureIdentityStateManagerProtocol let ndrService: NdrNostrService + let favoritesService: FavoritesPersistenceService + /// Bounds NDR bootstrap retries to one chain for an exact authenticated + /// Noise generation. A changed generation or invite replaces the token. + var ndrInviteAttemptTokenByPeer: [PeerID: String] = [:] + /// Tracks which Noise generation last claimed durable OOB responses so + /// repeated bootstrap triggers cannot reset their retry budget. + var ndrOutOfBandGenerationByPeer: [PeerID: UUID] = [:] + /// A favorite's authenticated Noise key is the stable binding. If its + /// associated Nostr identity changes, retire only that old pairwise peer. + var ndrPeerPubkeyByNoiseKey: [Data: String] = [:] + var ndrBindingIdentityPubkeyHex: String? + private let ndrFavoriteRebindAuthorizationOwner = UUID() /// Single source of truth for conversation message state and selection /// (docs/CONVERSATION-STORE-DESIGN.md). Owned by `AppRuntime` and passed /// through. @@ -1104,7 +1116,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage peerIdentityStore: PeerIdentityStore? = nil, locationPresenceStore: LocationPresenceStore? = nil, locationManager: LocationChannelManager = .shared, - ndrService: NdrNostrService? = nil + ndrService: NdrNostrService? = nil, + favoritesService: FavoritesPersistenceService? = nil ) { let livePanicRecoveryOperations = PanicRecoveryOperations.live() let startSuspendedForRecovery: Bool @@ -1141,6 +1154,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage identityManager: identityManager, transport: meshService, ndrService: ndrService, + favoritesService: favoritesService, conversations: conversations, peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(), locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(), @@ -1161,6 +1175,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage identityManager: SecureIdentityStateManagerProtocol, transport: Transport, ndrService: NdrNostrService? = nil, + favoritesService: FavoritesPersistenceService? = nil, conversations: ConversationStore? = nil, peerIdentityStore: PeerIdentityStore? = nil, locationPresenceStore: LocationPresenceStore? = nil, @@ -1176,6 +1191,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore() let locationPresenceStore = locationPresenceStore ?? LocationPresenceStore() let resolvedNdrService = ndrService ?? .shared + let resolvedFavoritesService = + favoritesService ?? FavoritesPersistenceService.shared let services = ChatViewModelServiceBundle( keychain: keychain, idBridge: idBridge, @@ -1194,6 +1211,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage self.idBridge = idBridge self.identityManager = identityManager self.ndrService = resolvedNdrService + self.favoritesService = resolvedFavoritesService self.conversations = conversations self.peerIdentityStore = peerIdentityStore self.locationPresenceStore = locationPresenceStore @@ -1248,6 +1266,32 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage _ = panicClearAllData(restartServices: false) } + resolvedFavoritesService + .installNostrIdentityRebindAuthorization( + owner: ndrFavoriteRebindAuthorizationOwner, + required: resolvedNdrService.isRolloutEnabled, + authorize: { [weak self] + noisePublicKey, + oldNostrPublicKey, + newNostrPublicKey in + self?.authorizeDoubleRatchetFavoriteRebind( + noisePublicKey: noisePublicKey, + oldNostrPublicKey: oldNostrPublicKey, + newNostrPublicKey: newNostrPublicKey + ) ?? false + }, + commit: { [weak self] + noisePublicKey, + oldNostrPublicKey, + newNostrPublicKey in + self?.commitDoubleRatchetFavoriteRebind( + noisePublicKey: noisePublicKey, + oldNostrPublicKey: oldNostrPublicKey, + newNostrPublicKey: newNostrPublicKey + ) ?? false + } + ) + if networkActivationAllowed { ChatViewModelBootstrapper(viewModel: self).configure() } @@ -1256,6 +1300,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage // MARK: - Deinitialization deinit { + let owner = ndrFavoriteRebindAuthorizationOwner + let favoritesService = favoritesService + Task { @MainActor in + favoritesService + .removeNostrIdentityRebindAuthorization(owner: owner) + } // No need to force UserDefaults synchronization } @@ -1596,6 +1646,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage queuedPrivateChatClears.removeAll(keepingCapacity: false) privateChatClearInFlight = false + ndrInviteAttemptTokenByPeer.removeAll() + ndrOutOfBandGenerationByPeer.removeAll() + ndrPeerPubkeyByNoiseKey.removeAll() + ndrBindingIdentityPubkeyHex = nil let ndrWipeCompleted: Bool do { try ndrService.resetForPanic() @@ -1648,7 +1702,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage publicRateLimiter.reset() // Clear persistent favorites from keychain - FavoritesPersistenceService.shared.clearAllFavorites() + favoritesService.clearAllFavorites() // Drop courier mail carried for third parties (memory and disk), // our own queued outbox, the carried public history, and the diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 5a8487ae..ff9c0628 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -84,6 +84,7 @@ final class ChatViewModelBootstrapper { configureGateway() configureBridge() configureBridgeCourier() + bindNdrRelayRetry() bindTeleportState() requestNotifications() registerObservers() @@ -639,6 +640,17 @@ private extension ChatViewModelBootstrapper { courier.refresh() } + func bindNdrRelayRetry() { + NostrRelayManager.shared.$isDMRelayConnected + .removeDuplicates() + .filter { $0 } + .receive(on: DispatchQueue.main) + .sink { [weak viewModel] _ in + viewModel?.ndrService.retryRelayActions() + } + .store(in: &viewModel.cancellables) + } + private static let bridgeSubscriptionID = "bridge-rendezvous" private static let courierDropSubscriptionID = "bridge-courier-drops" diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift index 64fe72df..2aa2eb62 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift @@ -62,11 +62,55 @@ extension ChatViewModel { @MainActor func setupNostrMessageHandling() { - if let currentIdentity = try? idBridge.getCurrentNostrIdentity() { - ndrService.onDecryptedMessage = { [weak self] message in - self?.nostrCoordinator.inbound.handleNdrDecryptedMessage(message) + if favoritesService.canActivateDoubleRatchetRelay, + let currentIdentity = try? idBridge.getCurrentNostrIdentity() + { + ndrService.configureIfNeeded( + identity: currentIdentity, + processPendingActions: false + ) + if ndrService.isRolloutEnabled { + for relationship in favoritesService.favorites.values { + guard let peerNostrPublicKey = + relationship.peerNostrPublicKey, + let peerPubkeyHex = + Self.ndrNostrPubkeyHex( + from: peerNostrPublicKey + ), + ndrService.hasPairwiseSession( + with: peerPubkeyHex + ) + else { + continue + } + guard favoritesService.markNdrRequired( + for: relationship.peerNoisePublicKey + ) else { + ndrService.onDecryptedMessage = nil + nostrCoordinator.subscriptions + .setupNostrMessageHandling() + return + } + } + } + guard favoritesService.canActivateDoubleRatchetRelay else { + ndrService.onDecryptedMessage = nil + nostrCoordinator.subscriptions.setupNostrMessageHandling() + return + } + ndrService.onDecryptedMessage = { [weak self] message, completion in + guard let self else { + completion(.retry) + return + } + self.nostrCoordinator.inbound.handleNdrDecryptedMessage( + message, + completion: completion + ) } ndrService.configureIfNeeded(identity: currentIdentity) + } else { + ndrService.onDecryptedMessage = nil } nostrCoordinator.subscriptions.setupNostrMessageHandling() } diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift index 9e2c8535..d77a4221 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift @@ -45,21 +45,6 @@ extension ChatViewModel { ) } - @MainActor - func handleLocalSiblingPrivateMessage( - _ payload: NoisePayload, - conversationPubkey: String, - convKey: PeerID, - messageTimestamp: Date - ) { - privateConversationCoordinator.handleLocalSiblingPrivateMessage( - payload, - conversationPubkey: conversationPubkey, - convKey: convKey, - messageTimestamp: messageTimestamp - ) - } - @MainActor func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { privateConversationCoordinator.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index 43063819..192506f6 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -24,6 +24,11 @@ protocol NostrInboundPipelineContext: AnyObject { /// All favorite relationships, used to bridge a Nostr pubkey back to a /// Noise key on the inbound DM path. func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] + func canUseNdrBinding( + peerNoisePublicKey: Data, + peerNostrPublicKey: String + ) -> Bool + func canAcceptLegacyNostrDM(from peerNostrPublicKey: String) -> Bool // MARK: Presence & key mapping func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) @@ -48,25 +53,21 @@ protocol NostrInboundPipelineContext: AnyObject { id: NostrIdentity, messageTimestamp: Date ) - func handleLocalSiblingPrivateMessage( - _ payload: NoisePayload, - conversationPubkey: String, - convKey: PeerID, - messageTimestamp: Date - ) func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) } extension NostrInboundPipelineContext { - /// Contexts that do not persist private conversations (for example - /// performance harnesses) safely drop local-sibling synchronization. - func handleLocalSiblingPrivateMessage( - _ payload: NoisePayload, - conversationPubkey: String, - convKey: PeerID, - messageTimestamp: Date - ) {} + func canUseNdrBinding( + peerNoisePublicKey _: Data, + peerNostrPublicKey _: String + ) -> Bool { + true + } + + func canAcceptLegacyNostrDM(from _: String) -> Bool { + true + } } extension ChatViewModel: NostrInboundPipelineContext { @@ -79,7 +80,23 @@ extension ChatViewModel: NostrInboundPipelineContext { } func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] { - Array(FavoritesPersistenceService.shared.favorites.values) + Array(favoritesService.favorites.values) + } + + func canUseNdrBinding( + peerNoisePublicKey: Data, + peerNostrPublicKey: String + ) -> Bool { + favoritesService.canUseNdrBinding( + peerNoisePublicKey: peerNoisePublicKey, + peerNostrPublicKey: peerNostrPublicKey + ) + } + + func canAcceptLegacyNostrDM(from peerNostrPublicKey: String) -> Bool { + favoritesService.canAcceptLegacyNostrDM( + from: peerNostrPublicKey + ) } func recordProcessedNostrEvent(_ eventID: String) { @@ -109,6 +126,7 @@ extension ChatViewModel: NostrInboundPipelineContext { final class NostrInboundPipeline { private weak var context: (any NostrInboundPipelineContext)? private let presence: GeoPresenceTracker + private let now: @MainActor () -> Date private var geoEventLogCount = 0 /// Monotonic panic-wipe generation for this pipeline. A panic wipe clears @@ -127,9 +145,14 @@ final class NostrInboundPipeline { wipeGeneration &+= 1 } - init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) { + init( + context: any NostrInboundPipelineContext, + presence: GeoPresenceTracker, + now: @escaping @MainActor () -> Date = Date.init + ) { self.context = context self.presence = presence + self.now = now } @MainActor @@ -439,24 +462,47 @@ final class NostrInboundPipeline { } @MainActor - func handleNdrDecryptedMessage(_ message: NdrDecryptedMessage) { - guard let context else { return } + func handleNdrDecryptedMessage( + _ message: NdrDecryptedMessage, + completion: @escaping NdrDeliveryCompletion + ) { + guard let context else { + completion(.retry) + return + } let innerEvent = message.event - guard !context.hasProcessedNostrEvent(innerEvent.id) else { return } - context.recordProcessedNostrEvent(innerEvent.id) + guard !context.hasProcessedNostrEvent(innerEvent.id) else { + completion(.consumed) + return + } let wipeGeneration = self.wipeGeneration - guard let currentIdentity = context.currentNostrIdentity() else { return } + guard let currentIdentity = context.currentNostrIdentity() else { + completion(.retry) + return + } Task { [weak self] in - await self?.processDecryptedNostrDMContent( + guard let self else { + completion(.retry) + return + } + let disposition = await self.processDecryptedNostrDMContent( innerEvent.content, senderPubkey: message.senderPubkeyHex, - conversationPubkey: message.conversationPubkeyHex, - isLocalSiblingCopy: message.isLocalSiblingCopy, rumorTimestamp: innerEvent.created_at, currentIdentity: currentIdentity, - wipeGeneration: wipeGeneration + wipeGeneration: wipeGeneration, + expiresAtSeconds: message.expiresAtSeconds, + requiresFavoriteBinding: true ) + guard self.wipeGeneration == wipeGeneration else { + completion(.retry) + return + } + if disposition == .consumed { + context.recordProcessedNostrEvent(innerEvent.id) + } + completion(disposition) } } @@ -485,6 +531,18 @@ final class NostrInboundPipeline { giftWrap: giftWrap, recipientIdentity: currentIdentity ) + let acceptsLegacyDM = await MainActor.run { + context.canAcceptLegacyNostrDM( + from: senderPubkey + ) + } + guard acceptsLegacyDM else { + SecureLogger.warning( + "Rejected legacy account DM for pairwise-only binding", + category: .security + ) + return + } await processDecryptedNostrDMContent( content, senderPubkey: senderPubkey, @@ -497,23 +555,24 @@ final class NostrInboundPipeline { } } + @discardableResult private func processDecryptedNostrDMContent( _ content: String, senderPubkey: String, - conversationPubkey: String? = nil, - isLocalSiblingCopy: Bool = false, rumorTimestamp: Int, currentIdentity: NostrIdentity, - wipeGeneration: UInt64 - ) async { - guard let context else { return } + wipeGeneration: UInt64, + expiresAtSeconds: UInt64? = nil, + requiresFavoriteBinding: Bool = false + ) async -> NdrDeliveryDisposition { + guard let context else { return .retry } if content.hasPrefix("verify:") { - return + return .consumed } guard content.hasPrefix("bitchat1:") else { SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session) - return + return .consumed } let packet: BitchatPacket? = await MainActor.run { @@ -521,61 +580,66 @@ final class NostrInboundPipeline { } guard let packet else { SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session) - return + return .consumed } - let routingPubkey = conversationPubkey ?? senderPubkey + let routingPubkey = senderPubkey let actualSenderNoiseKey: Data? = await MainActor.run { self.findNoiseKey(for: routingPubkey) } + if requiresFavoriteBinding, actualSenderNoiseKey == nil { + // Keep the native delivery durable until the favorite binding + // journal is recovered. Falling through to a virtual Nostr peer + // would bypass the fail-closed pairwise identity binding. + return .retry + } let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: routingPubkey) guard packet.type == MessageType.noiseEncrypted.rawValue, let payload = NoisePayload.decode(packet.payload) else { - return + return .consumed } let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) - await MainActor.run { - guard self.wipeGeneration == wipeGeneration else { return } + return await MainActor.run { + guard self.wipeGeneration == wipeGeneration else { + return .retry + } + if let expiresAtSeconds, + self.now().timeIntervalSince1970 + >= TimeInterval(expiresAtSeconds) + { + // The native delivery remains durable while decoding hops + // actors. Recheck immediately before every app mutation so a + // message that expires during that work drains without ever + // being mapped, persisted, or notified. + return .consumed + } context.registerNostrKeyMapping(routingPubkey, for: targetPeerID) switch payload.type { case .privateMessage: - if isLocalSiblingCopy { - context.handleLocalSiblingPrivateMessage( - payload, - conversationPubkey: routingPubkey, - convKey: targetPeerID, - messageTimestamp: messageTimestamp - ) - } else { - context.handlePrivateMessage( - payload, - senderPubkey: senderPubkey, - convKey: targetPeerID, - id: currentIdentity, - messageTimestamp: messageTimestamp - ) - } + context.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: targetPeerID, + id: currentIdentity, + messageTimestamp: messageTimestamp + ) case .delivered: - if !isLocalSiblingCopy { - context.handleDelivered( - payload, - senderPubkey: senderPubkey, - convKey: targetPeerID - ) - } + context.handleDelivered( + payload, + senderPubkey: senderPubkey, + convKey: targetPeerID + ) case .readReceipt: - if !isLocalSiblingCopy { - context.handleReadReceipt( - payload, - senderPubkey: senderPubkey, - convKey: targetPeerID - ) - } + context.handleReadReceipt( + payload, + senderPubkey: senderPubkey, + convKey: targetPeerID + ) // These payloads are mesh-only and must never be tunneled through // either private-relay envelope. case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, @@ -583,6 +647,7 @@ final class NostrInboundPipeline { .authenticatedPeerState: break } + return .consumed } } @@ -608,6 +673,13 @@ final class NostrInboundPipeline { for relationship in favorites { if let storedNostrKey = relationship.peerNostrPublicKey { + guard context.canUseNdrBinding( + peerNoisePublicKey: + relationship.peerNoisePublicKey, + peerNostrPublicKey: storedNostrKey + ) else { + continue + } if storedNostrKey == npubToMatch { return relationship.peerNoisePublicKey } diff --git a/bitchatTests/ChatNostrCoordinatorContextTests.swift b/bitchatTests/ChatNostrCoordinatorContextTests.swift index 80d249c4..cff5032d 100644 --- a/bitchatTests/ChatNostrCoordinatorContextTests.swift +++ b/bitchatTests/ChatNostrCoordinatorContextTests.swift @@ -213,6 +213,7 @@ private final class MockChatNostrContext: ChatNostrContext { // Favorites & notifications var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:] + var acceptsLegacyNostrDM = true private(set) var geohashActivityNotifications: [(geohash: String, bodyPreview: String)] = [] func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { @@ -223,6 +224,10 @@ private final class MockChatNostrContext: ChatNostrContext { Array(favoriteRelationshipsByNoiseKey.values) } + func canAcceptLegacyNostrDM(from _: String) -> Bool { + acceptsLegacyNostrDM + } + func notifyGeohashActivity(geohash: String, bodyPreview: String) { geohashActivityNotifications.append((geohash, bodyPreview)) } @@ -446,6 +451,45 @@ struct ChatNostrCoordinatorContextTests { #expect(context.recordedNostrEventIDs == [giftWrap.id]) } + @Test @MainActor + func processNostrMessage_rejectsLegacyDowngradeBeforeDelivery() + async throws + { + let context = MockChatNostrContext() + let coordinator = ChatNostrCoordinator(context: context) + let recipient = try NostrIdentity.generate() + let sender = try NostrIdentity.generate() + context.nostrIdentity = recipient + context.acceptsLegacyNostrDM = false + let embedded = try #require( + NostrEmbeddedBitChat.encodePMForNostrNoRecipient( + content: "must not downgrade", + messageID: "legacy-blocked", + senderPeerID: PeerID(str: "aabbccddeeff0011") + ) + ) + let blockedGiftWrap = try NostrProtocol.createPrivateMessage( + content: embedded, + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + + await coordinator.inbound.processNostrMessage(blockedGiftWrap) + + #expect(context.handledPrivateMessages.isEmpty) + #expect(context.recordedNostrEventIDs == [blockedGiftWrap.id]) + + context.acceptsLegacyNostrDM = true + let acceptedGiftWrap = try NostrProtocol.createPrivateMessage( + content: embedded, + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + await coordinator.inbound.processNostrMessage(acceptedGiftWrap) + + #expect(context.handledPrivateMessages.count == 1) + } + @Test @MainActor func switchLocationChannel_toMesh_tearsDownGeohashState() async { let context = MockChatNostrContext() @@ -681,4 +725,60 @@ struct GeoPresenceTrackerTests { ) } + @Test @MainActor + func ndrDelivery_expiredAtFinalMutationDrainsWithoutSideEffects() async throws { + let context = MockChatNostrContext() + let recipient = try NostrIdentity.generate() + let sender = try NostrIdentity.generate() + context.nostrIdentity = recipient + let senderPeerID = PeerID(str: "0011223344556677") + let embedded = try #require( + NostrEmbeddedBitChat.encodeAckForNostrNoRecipient( + type: .delivered, + messageID: "expired-ndr", + senderPeerID: senderPeerID + ) + ) + let unsigned = NostrEvent( + pubkey: sender.publicKeyHex, + createdAt: Date(timeIntervalSince1970: 99), + kind: .dm, + tags: [], + content: embedded + ) + var rumor = try unsigned.sign( + with: sender.schnorrSigningKey() + ) + rumor.sig = nil + + let presence = GeoPresenceTracker(context: context) + let pipeline = NostrInboundPipeline( + context: context, + presence: presence, + now: { Date(timeIntervalSince1970: 100) } + ) + var disposition: NdrDeliveryDisposition? + pipeline.handleNdrDecryptedMessage( + NdrDecryptedMessage( + event: rumor, + senderPubkeyHex: sender.publicKeyHex, + outerEventID: String(repeating: "a", count: 64), + expiresAtSeconds: 100 + ), + completion: { disposition = $0 } + ) + let completed = await TestHelpers.waitUntil( + { disposition != nil }, + timeout: TestConstants.settleTimeout + ) + + #expect(completed) + #expect(disposition == .consumed) + #expect(context.nostrKeyMapping.isEmpty) + #expect(context.handledDelivered.isEmpty) + #expect(context.handledPrivateMessages.isEmpty) + #expect(context.handledReadReceipts.isEmpty) + #expect(context.recordedNostrEventIDs == [rumor.id]) + } + } diff --git a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift index 92e511f5..54cb341e 100644 --- a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift @@ -491,48 +491,6 @@ struct ChatPrivateConversationCoordinatorContextTests { #expect(context.privateChats[convKey]?.count == 1) } - @Test @MainActor - func localSiblingCopy_isStoredAsSentInRemoteConversation() async throws { - let context = MockChatPrivateConversationContext() - let coordinator = ChatPrivateConversationCoordinator(context: context) - let noiseKey = Data(repeating: 0xD1, count: 32) - let peerID = PeerID(hexData: noiseKey) - let remotePubkey = String(repeating: "b", count: 64) - context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( - noiseKey: noiseKey, - nostrPublicKey: remotePubkey, - nickname: "bob", - isFavorite: true, - theyFavoritedUs: true - ) - let payload = NoisePayload( - type: .privateMessage, - data: try #require( - PrivateMessagePacket( - messageID: "local-sibling-1", - content: "sent on my other device" - ).encode() - ) - ) - - coordinator.handleLocalSiblingPrivateMessage( - payload, - conversationPubkey: remotePubkey, - convKey: peerID, - messageTimestamp: Date(timeIntervalSince1970: 123) - ) - - let message = try #require(context.privateChats[peerID]?.first) - #expect(message.id == "local-sibling-1") - #expect(message.sender == context.nickname) - #expect(message.senderPeerID == context.myPeerID) - #expect(message.recipientNickname == "bob") - #expect(message.deliveryStatus == .sent) - #expect(context.sentGeoDeliveryAcks.isEmpty) - #expect(context.unreadPrivateMessages.isEmpty) - #expect(context.notifyUIChangedCount == 1) - } - @Test @MainActor func accountDM_handsOpenShortIDConversationToStableWhenOffline() async { let context = MockChatPrivateConversationContext() diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 7e4e46a1..ad8f580f 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -17,6 +17,7 @@ import BitFoundation @MainActor private func makeTestableViewModel( keychain injectedKeychain: MockKeychain? = nil, + ndrService: NdrNostrService? = nil, panicMediaWipe: (() throws -> Void)? = nil, panicRecoveryOperations: PanicRecoveryOperations? = nil, panicNetworkLifecycle: PanicNetworkLifecycle = .noop @@ -32,6 +33,7 @@ private func makeTestableViewModel( idBridge: idBridge, identityManager: identityManager, transport: transport, + ndrService: ndrService, panicMediaWipe: panicMediaWipe, panicRecoveryOperations: panicRecoveryOperations, panicNetworkLifecycle: panicNetworkLifecycle @@ -2228,6 +2230,121 @@ struct ChatViewModelPanicTests { #expect(!viewModel.networkActivationAllowed) } + @Test @MainActor + func failedNdrPanicWipeStaysLatchedAcrossViewModelRestart() throws { + enum StorageFailure: Error { + case unavailable + } + + let storage = FileManager.default.temporaryDirectory + .appendingPathComponent( + "bitchat-tests-panic-ndr-\(UUID().uuidString)", + isDirectory: true + ) + var storageAvailable = true + var recoveryPending = false + var beginCount = 0 + var completeCount = 0 + let recoveryOperations = PanicRecoveryOperations( + isPending: { recoveryPending }, + begin: { + recoveryPending = true + beginCount += 1 + return PanicRecoveryIntent( + fileMarkerEstablished: true, + externalMarkerEstablished: true + ) + }, + wipeMedia: { _ in }, + complete: { + recoveryPending = false + completeCount += 1 + } + ) + let markerStore = InMemoryNdrSessionMarkerStore() + let firstNdrService = NdrNostrService( + relayManager: FakeRelayManager(), + rolloutEnabled: true, + storageDirectoryProvider: { + guard storageAvailable else { + throw StorageFailure.unavailable + } + return storage + }, + sessionMarkerStore: markerStore + ) + firstNdrService.configureIfNeeded( + identity: try NostrIdentity.generate() + ) + #expect( + FileManager.default.fileExists(atPath: storage.path) + ) + + let (firstViewModel, firstTransport) = + makeTestableViewModel( + ndrService: firstNdrService, + panicRecoveryOperations: recoveryOperations + ) + let startsBeforePanic = firstTransport.startServicesCallCount + storageAvailable = false + + #expect(!firstViewModel.panicClearAllData()) + #expect(recoveryPending) + #expect(completeCount == 0) + #expect( + firstTransport.startServicesCallCount == startsBeforePanic + ) + #expect(!firstViewModel.networkActivationAllowed) + #expect( + FileManager.default.fileExists(atPath: storage.path) + ) + + let secondNdrService = NdrNostrService( + relayManager: FakeRelayManager(), + rolloutEnabled: true, + storageDirectoryProvider: { + guard storageAvailable else { + throw StorageFailure.unavailable + } + return storage + }, + sessionMarkerStore: markerStore + ) + let (secondViewModel, secondTransport) = + makeTestableViewModel( + ndrService: secondNdrService, + panicRecoveryOperations: recoveryOperations + ) + + #expect(recoveryPending) + #expect(beginCount == 2) + #expect(completeCount == 0) + #expect(secondTransport.startServicesCallCount == 0) + #expect(!secondViewModel.networkActivationAllowed) + + storageAvailable = true + let thirdNdrService = NdrNostrService( + relayManager: FakeRelayManager(), + rolloutEnabled: true, + storageDirectoryProvider: { storage }, + sessionMarkerStore: markerStore + ) + let (thirdViewModel, thirdTransport) = + makeTestableViewModel( + ndrService: thirdNdrService, + panicRecoveryOperations: recoveryOperations + ) + + #expect(!recoveryPending) + #expect(beginCount == 3) + #expect(completeCount == 1) + #expect(thirdTransport.startServicesCallCount == 1) + #expect(thirdViewModel.networkActivationAllowed) + #expect( + !FileManager.default.fileExists(atPath: storage.path) + ) + } + @Test @MainActor func panicClearAllData_delegatesToTransport() async { let (viewModel, transport) = makeTestableViewModel() diff --git a/bitchatTests/DoubleRatchet/NdrOutOfBandTransportTests.swift b/bitchatTests/DoubleRatchet/NdrOutOfBandTransportTests.swift index c5694a20..091869dc 100644 --- a/bitchatTests/DoubleRatchet/NdrOutOfBandTransportTests.swift +++ b/bitchatTests/DoubleRatchet/NdrOutOfBandTransportTests.swift @@ -17,10 +17,19 @@ final class FakeRelayManager: NostrRelayManaging { let handler: (NostrEvent) -> Void } + struct PendingPublish { + let eventID: String + let completion: (Bool) -> Void + } + private(set) var subscriptions: [Subscription] = [] private(set) var unsubscribedIDs: [String] = [] private(set) var sentEvents: [NostrEvent] = [] + private(set) var pendingPublishes: [PendingPublish] = [] private var activeSubscriptionIDs = Set() + var automaticallyCompletePublishes = true + var publishAccepted = true + var subscriptionRegistrationSucceeds = true var activeSubscriptions: [Subscription] { subscriptions.filter { activeSubscriptionIDs.contains($0.id) } @@ -36,11 +45,13 @@ final class FakeRelayManager: NostrRelayManaging { relayUrls: [String]?, handler: @escaping (NostrEvent) -> Void, onEOSE: (() -> Void)? - ) { + ) -> Bool { + guard subscriptionRegistrationSucceeds else { return false } subscriptions.append( Subscription(id: id, filter: filter, handler: handler) ) activeSubscriptionIDs.insert(id) + return true } func unsubscribe(id: String) { @@ -48,14 +59,30 @@ final class FakeRelayManager: NostrRelayManaging { activeSubscriptionIDs.remove(id) } - func sendEvent(_ event: NostrEvent, to relayUrls: [String]?) { + func sendEventImmediately( + _ event: NostrEvent, + to relayUrls: [String]?, + completion: @escaping (Bool) -> Void + ) { sentEvents.append(event) + if automaticallyCompletePublishes { + completion(publishAccepted) + } else { + pendingPublishes.append( + PendingPublish(eventID: event.id, completion: completion) + ) + } + } + + func completeNextPublish(accepted: Bool) { + guard !pendingPublishes.isEmpty else { return } + pendingPublishes.removeFirst().completion(accepted) } func deliver(_ event: NostrEvent, to subscriptionID: String) { guard activeSubscriptionIDs.contains(subscriptionID), let subscription = subscriptions.last(where: { - $0.id == subscriptionID + $0.id == subscriptionID }) else { return @@ -64,25 +91,78 @@ final class FakeRelayManager: NostrRelayManaging { } } -struct NdrOutOfBandTransportTests { +@MainActor +private final class FakeNdrRetryScheduler { + struct Scheduled { + let delay: TimeInterval + let operation: @MainActor () -> Void + } - @Test("Double-ratchet bootstrap uses the coordinated cross-platform wire value") - func wireValue_isCoordinated() { + private(set) var scheduled: [Scheduled] = [] + private(set) var requestedDelays: [TimeInterval] = [] + + func schedule( + after delay: TimeInterval, + operation: @escaping @MainActor () -> Void + ) { + requestedDelays.append(delay) + scheduled.append( + Scheduled(delay: delay, operation: operation) + ) + } + + func runNext() { + guard !scheduled.isEmpty else { return } + scheduled.removeFirst().operation() + } +} + +@MainActor +private final class ControllableNdrSessionMarkerStore: + NdrSessionMarkerStoring +{ + enum Failure: Error { + case mark + case clear + } + + var failMark = false + var failClear = false + private var identities = Set() + + func contains(identityPubkeyHex: String) throws -> Bool { + identities.contains(identityPubkeyHex.lowercased()) + } + + func mark(identityPubkeyHex: String) throws { + guard !failMark else { throw Failure.mark } + identities.insert(identityPubkeyHex.lowercased()) + } + + func clear() throws { + guard !failClear else { throw Failure.clear } + identities.removeAll() + } +} + +@Suite(.serialized) +struct NdrOutOfBandTransportTests { + @Test("Double-ratchet bootstrap uses the coordinated wire value") + func wireValueIsCoordinated() { #expect(NoisePayloadType.ndrEvent.rawValue == 0x22) #expect(NoisePayloadType.decoded(rawValue: 0x22) == .ndrEvent) #expect(NoisePayloadType.decoded(rawValue: 0x12) == .vouch) } - @Test("Double-ratchet rollout remains dark until kind-1402 coordination completes") + @Test("Double-ratchet rollout remains dark until coordination completes") @MainActor - func rolloutGate_isDisabledByDefaultAndFailsClosed() throws { + func rolloutGateIsDisabledByDefaultAndFailsClosed() throws { #expect(!DoubleRatchetFeature.isEnabled) #expect(!PeerCapabilities.localSupported.contains(.doubleRatchet)) let identity = try NostrIdentity.generate() var requestedStorage = false let service = NdrNostrService( relayManager: FakeRelayManager(), - deviceId: "disabled-rollout", rolloutEnabled: false, storageDirectoryProvider: { requestedStorage = true @@ -97,122 +177,121 @@ struct NdrOutOfBandTransportTests { #expect(!requestedStorage) } - @Test("Out-of-band invites are bound to the authenticated favorite identity") + @Test("A failed runtime open stays fail-closed until identity change") @MainActor - func oobInvite_rejectsUnexpectedOwner() throws { + func configurationFailureRequiresExplicitRecoveryBoundary() throws { + enum StorageFailure: Error { case unavailable } + + let first = try NostrIdentity.generate() + let second = try NostrIdentity.generate() + let storage = try makeTempDir(label: "configuration-failure") + var storageAvailable = false + let service = NdrNostrService( + relayManager: FakeRelayManager(), + rolloutEnabled: true, + storageDirectoryProvider: { + guard storageAvailable else { + throw StorageFailure.unavailable + } + return storage + } + ) + + service.configureIfNeeded(identity: first) + storageAvailable = true + service.configureIfNeeded(identity: first) + #expect( + service.send("blocked", to: second.publicKeyHex) == .failed + ) + + service.configureIfNeeded(identity: second) + #expect(service.isConfigured) + #expect(service.configuredPubkeyHex == second.publicKeyHex) + #expect(service.send("no session", to: first.publicKeyHex) == .noSession) + } + + @Test("Missing Application Support storage fails closed") + @MainActor + func missingApplicationSupportNeverFallsBackToTemporaryStorage() { + do { + _ = try NdrNostrService.ndrStorageDirectory( + applicationSupportDirectory: nil + ) + Issue.record("Expected missing Application Support to fail") + } catch { + #expect( + error as? NdrStorageDirectoryError + == .applicationSupportUnavailable + ) + } + } + + @Test("An invite is bound to the authenticated favorite identity") + @MainActor + func inviteRejectsUnexpectedPeer() throws { let alice = try NostrIdentity.generate() let bob = try NostrIdentity.generate() - let unexpectedPeer = try NostrIdentity.generate() - let aliceService = NdrNostrService( - relayManager: FakeRelayManager(), - deviceId: "alice-device", - rolloutEnabled: true, - storageDirectoryProvider: { try makeTempDir(label: "ndr-owner-alice") } - ) - let bobService = NdrNostrService( - relayManager: FakeRelayManager(), - deviceId: "bob-device", - rolloutEnabled: true, - storageDirectoryProvider: { try makeTempDir(label: "ndr-owner-bob") } - ) - + let unexpected = try NostrIdentity.generate() + let aliceService = try makeService(label: "invite-alice") + let bobService = try makeService(label: "invite-bob") aliceService.configureIfNeeded(identity: alice) bobService.configureIfNeeded(identity: bob) - let aliceInvite = try #require(aliceService.currentInviteEventJson()) + let invite = try #require(aliceService.currentInviteEventJson()) let responses = bobService.processOutOfBandEventJson( - aliceInvite, - expectedPeerPubkeyHex: unexpectedPeer.publicKeyHex + invite, + expectedPeerPubkeyHex: unexpected.publicKeyHex, + persistEstablishedBinding: { true } ) #expect(responses.isEmpty) #expect(!bobService.hasActiveSession(with: alice.publicKeyHex)) } - @Test("Out-of-band responses cannot establish a session for another authenticated peer") + @Test("A response is accepted only on its authenticated Noise route") @MainActor - func oobResponse_rejectsUnexpectedOwner() throws { + func responseRejectsUnexpectedPeerAndRelayInjection() throws { let alice = try NostrIdentity.generate() let bob = try NostrIdentity.generate() - let unexpectedPeer = try NostrIdentity.generate() - let aliceService = NdrNostrService( - relayManager: FakeRelayManager(), - deviceId: "response-owner-alice", - rolloutEnabled: true, - storageDirectoryProvider: { try makeTempDir(label: "ndr-response-owner-alice") } - ) - let bobService = NdrNostrService( - relayManager: FakeRelayManager(), - deviceId: "response-owner-bob", - rolloutEnabled: true, - storageDirectoryProvider: { try makeTempDir(label: "ndr-response-owner-bob") } - ) + let unexpected = try NostrIdentity.generate() + let aliceService = try makeService(label: "response-alice") + let bobService = try makeService(label: "response-bob") aliceService.configureIfNeeded(identity: alice) bobService.configureIfNeeded(identity: bob) - let aliceInvite = try #require(aliceService.currentInviteEventJson()) - let bobResponses = bobService.processOutOfBandEventJson( - aliceInvite, - expectedPeerPubkeyHex: alice.publicKeyHex - ) - let bobResponse = try #require( - bobResponses.first(where: { (try? extractNostrKind(json: $0)) == 1059 }) + let invite = try #require(aliceService.currentInviteEventJson()) + let response = try #require( + bobService.processOutOfBandEventJson( + invite, + expectedPeerPubkeyHex: alice.publicKeyHex, + persistEstablishedBinding: { true } + ).first(where: { + (try? extractNostrKind(json: $0.eventJson)) == 1059 + }) ) let rejected = aliceService.processOutOfBandEventJson( - bobResponse, - expectedPeerPubkeyHex: unexpectedPeer.publicKeyHex + response.eventJson, + expectedPeerPubkeyHex: unexpected.publicKeyHex, + persistEstablishedBinding: { true } ) - #expect(rejected.isEmpty) #expect(!aliceService.hasActiveSession(with: bob.publicKeyHex)) - #expect(!aliceService.hasActiveSession(with: unexpectedPeer.publicKeyHex)) - } - @Test("Relay-injected invite responses cannot bypass the authenticated Noise route") - @MainActor - func relayInjectedResponse_isRejected() throws { - let alice = try NostrIdentity.generate() - let bob = try NostrIdentity.generate() - let aliceService = NdrNostrService( - relayManager: FakeRelayManager(), - deviceId: "relay-response-alice", - rolloutEnabled: true, - storageDirectoryProvider: { try makeTempDir(label: "ndr-relay-response-alice") } - ) - let bobService = NdrNostrService( - relayManager: FakeRelayManager(), - deviceId: "relay-response-bob", - rolloutEnabled: true, - storageDirectoryProvider: { try makeTempDir(label: "ndr-relay-response-bob") } - ) - aliceService.configureIfNeeded(identity: alice) - bobService.configureIfNeeded(identity: bob) - - let aliceInvite = try #require(aliceService.currentInviteEventJson()) - let bobResponse = try #require( - bobService.processOutOfBandEventJson( - aliceInvite, - expectedPeerPubkeyHex: alice.publicKeyHex - ).first(where: { (try? extractNostrKind(json: $0)) == 1059 }) - ) let relayInjected = try JSONDecoder().decode( NostrEvent.self, - from: Data(bobResponse.utf8) + from: Data(response.eventJson.utf8) ) - aliceService.processInboundRelayEvent(relayInjected) - #expect(!aliceService.hasActiveSession(with: bob.publicKeyHex)) } - @Test("Decrypted rumors are bound to the ratchet-authenticated sending device") + @Test("Decrypted events require exact pairwise sender attribution") @MainActor - func decryptedRumor_rejectsClaimedSenderMismatch() throws { + func decryptedEventRejectsClaimedSenderMismatch() throws { let authenticated = try NostrIdentity.generate() let claimed = try NostrIdentity.generate() - let conversationOwner = try NostrIdentity.generate() - let matching = try makeInnerMessageEvent( + let inner = try makeInnerMessageEvent( identity: authenticated, content: "bitchat1:matching" ) @@ -221,636 +300,2417 @@ struct NdrOutOfBandTransportTests { content: "bitchat1:forged" ) + let matching = makeDeliveryAction( + inner, + authenticatedSender: authenticated.publicKeyHex + ) + #expect(inner.sig == nil) + #expect( + NdrNostrService.validatedDecryptedMessage(from: matching)? + .event.pubkey == authenticated.publicKeyHex + ) #expect( NdrNostrService.validatedDecryptedMessage( - from: makeDecryptedPubSubEvent( - matching, - authenticatedSender: authenticated.publicKeyHex, - conversationOwner: conversationOwner.publicKeyHex + from: makeDeliveryAction( + forged, + authenticatedSender: authenticated.publicKeyHex ) - )?.event.pubkey == authenticated.publicKeyHex + ) == nil ) - let sibling = try #require( + var invalidID = inner + invalidID.id = String(repeating: "c", count: 64) + #expect( NdrNostrService.validatedDecryptedMessage( - from: makeDecryptedPubSubEvent( - matching, - authenticatedSender: authenticated.publicKeyHex, - conversationOwner: conversationOwner.publicKeyHex + from: makeDeliveryAction( + invalidID, + authenticatedSender: authenticated.publicKeyHex ) + ) == nil + ) + #expect( + !NdrNostrService.isExpiredDelivery( + makeDeliveryAction( + inner, + authenticatedSender: authenticated.publicKeyHex + ), + now: Date(timeIntervalSince1970: 100) + ) + ) + #expect( + NdrNostrService.isExpiredDelivery( + makeDeliveryAction( + inner, + authenticatedSender: authenticated.publicKeyHex, + expiresAtSeconds: 100 + ), + now: Date(timeIntervalSince1970: 100) ) ) - #expect(sibling.senderPubkeyHex == authenticated.publicKeyHex) - #expect(sibling.conversationPubkeyHex == conversationOwner.publicKeyHex) - #expect(sibling.isLocalSiblingCopy) #expect( NdrNostrService.validatedDecryptedMessage( - from: makeDecryptedPubSubEvent( - forged, + from: makeDeliveryAction( + inner, authenticatedSender: authenticated.publicKeyHex, - conversationOwner: claimed.publicKeyHex + innerEventID: String(repeating: "b", count: 64) ) ) == nil ) } - @Test("A delayed owner roster retries a multi-device invite and returns its response over Noise") + @Test("A failed BLE handoff leaves its response durable for retry") @MainActor - func multiDeviceInvite_retriesAfterOwnerRoster() throws { - let owner = try NostrIdentity.generate() - let device = try NostrIdentity.generate() - let recipient = try NostrIdentity.generate() - let deviceManager = try SessionManagerHandle( - ourPubkeyHex: device.publicKeyHex, - ourIdentityPrivkeyHex: device.privateKey.hexEncodedString(), - deviceId: "multi-device-child", - ownerPubkeyHex: owner.publicKeyHex + func failedOutOfBandHandoffRetriesUntilAccepted() throws { + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() + let aliceService = try makeService(label: "oob-retry-alice") + let bobService = try makeService(label: "oob-retry-bob") + aliceService.configureIfNeeded(identity: alice) + bobService.configureIfNeeded(identity: bob) + + let invite = try #require(aliceService.currentInviteEventJson()) + let first = try #require( + bobService.processOutOfBandEventJson( + invite, + expectedPeerPubkeyHex: alice.publicKeyHex, + persistEstablishedBinding: { true } + ).first ) - try deviceManager.`init`() - let deviceInvite = try #require( - try deviceManager.drainEvents() - .first(where: { isInvitePublish($0) })? - .eventJson + bobService.completeOutOfBandAction(first, succeeded: false) + + let retried = try #require( + bobService.pendingOutOfBandActions( + forAuthenticatedPeerPubkeyHex: alice.publicKeyHex, + releaseDeferred: true + ).first + ) + #expect(retried.eventJson == first.eventJson) + #expect(retried.peerPubkeyHex == alice.publicKeyHex) + + bobService.completeOutOfBandAction(retried, succeeded: true) + #expect( + bobService.pendingOutOfBandActions( + forAuthenticatedPeerPubkeyHex: alice.publicKeyHex + ).isEmpty + ) + } + + @Test("Relay bootstrap waits for authenticated OOB handoff") + @MainActor + func bootstrapPublishWaitsForOutOfBandAck() throws { + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() + let bobRelay = FakeRelayManager() + let aliceService = try makeService(label: "oob-order-alice") + let bobService = try makeService( + label: "oob-order-bob", + relay: bobRelay + ) + aliceService.configureIfNeeded(identity: alice) + bobService.configureIfNeeded(identity: bob) + + let invite = try #require(aliceService.currentInviteEventJson()) + let first = try #require( + bobService.processOutOfBandEventJson( + invite, + expectedPeerPubkeyHex: alice.publicKeyHex, + persistEstablishedBinding: { true } + ).first + ) + #expect(bobRelay.sentEvents.filter { $0.kind == 1060 }.isEmpty) + + bobService.completeOutOfBandAction(first, succeeded: false) + #expect(bobRelay.sentEvents.filter { $0.kind == 1060 }.isEmpty) + + let retried = try #require( + bobService.pendingOutOfBandActions( + forAuthenticatedPeerPubkeyHex: alice.publicKeyHex, + releaseDeferred: true + ).first + ) + bobService.completeOutOfBandAction(retried, succeeded: true) + + #expect(bobRelay.sentEvents.filter { $0.kind == 1060 }.count == 1) + } + + @Test("A half-ready session suppresses a second invite") + @MainActor + func acceptedInviteCreatesPairwiseRecordBeforeRelayBootstrap() throws { + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() + let aliceService = try makeService(label: "half-ready-alice") + let bobService = try makeService(label: "half-ready-bob") + aliceService.configureIfNeeded(identity: alice) + bobService.configureIfNeeded(identity: bob) + + let invite = try #require(aliceService.currentInviteEventJson()) + let responses = bobService.processOutOfBandEventJson( + invite, + expectedPeerPubkeyHex: alice.publicKeyHex, + persistEstablishedBinding: { true } ) - let recipientRelay = FakeRelayManager() - let recipientService = NdrNostrService( - relayManager: recipientRelay, - deviceId: "multi-device-recipient", + let response = try #require(responses.first) + bobService.completeOutOfBandAction(response, succeeded: true) + _ = aliceService.processOutOfBandEventJson( + response.eventJson, + expectedPeerPubkeyHex: bob.publicKeyHex, + persistEstablishedBinding: { true } + ) + + #expect(aliceService.hasPairwiseSession(with: bob.publicKeyHex)) + #expect(!aliceService.hasActiveSession(with: bob.publicKeyHex)) + } + + @Test("An OOB response blocks only its own session") + @MainActor + func pendingOutOfBandDoesNotBlockAnotherPeerPublish() throws { + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() + let carol = try NostrIdentity.generate() + let aliceRelay = FakeRelayManager() + let bobRelay = FakeRelayManager() + let aliceService = try makeService( + label: "oob-isolation-alice", + relay: aliceRelay + ) + let bobService = try makeService( + label: "oob-isolation-bob", + relay: bobRelay + ) + let carolService = try makeService(label: "oob-isolation-carol") + aliceService.configureIfNeeded(identity: alice) + bobService.configureIfNeeded(identity: bob) + carolService.configureIfNeeded(identity: carol) + try establishPairwiseSessions( + aliceService, + bobService, + firstIdentity: alice, + secondIdentity: bob, + firstRelay: aliceRelay, + secondRelay: bobRelay + ) + + aliceRelay.resetSentEvents() + let carolInvite = try #require( + carolService.currentInviteEventJson() + ) + let pendingCarolResponse = + aliceService.processOutOfBandEventJson( + carolInvite, + expectedPeerPubkeyHex: carol.publicKeyHex, + persistEstablishedBinding: { true } + ) + #expect(!pendingCarolResponse.isEmpty) + #expect(aliceRelay.sentEvents.isEmpty) + + guard case .sent = aliceService.send( + "bitchat1:unrelated-session", + to: bob.publicKeyHex + ) else { + Issue.record("Expected established Bob session to send") + return + } + #expect(aliceRelay.sentEvents.filter { $0.kind == 1060 }.count == 1) + } + + @Test("Retiring one peer preserves unrelated pairwise sessions") + @MainActor + func peerRetirementIsTargetedAndIdempotent() throws { + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() + let carol = try NostrIdentity.generate() + let aliceRelay = FakeRelayManager() + let bobRelay = FakeRelayManager() + let carolRelay = FakeRelayManager() + let aliceService = try makeService( + label: "retire-alice", + relay: aliceRelay + ) + let bobService = try makeService( + label: "retire-bob", + relay: bobRelay + ) + let carolService = try makeService( + label: "retire-carol", + relay: carolRelay + ) + aliceService.configureIfNeeded(identity: alice) + bobService.configureIfNeeded(identity: bob) + carolService.configureIfNeeded(identity: carol) + try establishPairwiseSessions( + aliceService, + bobService, + firstIdentity: alice, + secondIdentity: bob, + firstRelay: aliceRelay, + secondRelay: bobRelay + ) + try establishPairwiseSessions( + aliceService, + carolService, + firstIdentity: alice, + secondIdentity: carol, + firstRelay: aliceRelay, + secondRelay: carolRelay + ) + + #expect(aliceService.hasActiveSession(with: bob.publicKeyHex)) + #expect(aliceService.hasActiveSession(with: carol.publicKeyHex)) + #expect(aliceService.retirePeer(bob.publicKeyHex)) + #expect(!aliceService.hasPairwiseSession(with: bob.publicKeyHex)) + #expect(aliceService.hasActiveSession(with: carol.publicKeyHex)) + #expect(aliceService.retirePeer(bob.publicKeyHex)) + #expect(aliceService.currentInviteEventJson() != nil) + } + + @Test("A favorite Nostr rebind retires only the old pairwise peer") + @MainActor + func favoriteIdentityRebindRetiresOldPeerBeforeNewInvite() throws { + let relay = FakeRelayManager() + let storage = try makeTempDir(label: "favorite-rebind-local") + let service = NdrNostrService( + relayManager: relay, rolloutEnabled: true, - storageDirectoryProvider: { try makeTempDir(label: "ndr-multi-device-recipient") } + storageDirectoryProvider: { storage } ) - recipientService.configureIfNeeded(identity: recipient) - var deferredResponses: [String] = [] - - let immediate = recipientService.processOutOfBandEventJson( - deviceInvite, - expectedPeerPubkeyHex: owner.publicKeyHex, - deferredResponseHandler: { deferredResponses.append($0) } + let (viewModel, transport, favoritesService) = + makeViewModel(ndrService: service) + let local = try #require( + try viewModel.idBridge.getCurrentNostrIdentity() + ) + let oldPeer = try NostrIdentity.generate() + let newPeer = try NostrIdentity.generate() + let oldPeerRelay = FakeRelayManager() + let oldPeerService = try makeService( + label: "favorite-rebind-old-peer", + relay: oldPeerRelay + ) + service.configureIfNeeded(identity: local) + oldPeerService.configureIfNeeded(identity: oldPeer) + try establishPairwiseSessions( + service, + oldPeerService, + firstIdentity: local, + secondIdentity: oldPeer, + firstRelay: relay, + secondRelay: oldPeerRelay ) - #expect(immediate.isEmpty) - #expect(!recipientService.hasActiveSession(with: owner.publicKeyHex)) - _ = recipientService.processOutOfBandEventJson( - deviceInvite, - expectedPeerPubkeyHex: owner.publicKeyHex, - deferredResponseHandler: { deferredResponses.append($0) } + let noiseKey = try #require( + Data(hexString: oldPeer.publicKeyHex) + ) + let peerID = PeerID(str: "32435465768798a9") + installMutualFavorite( + in: favoritesService, + noiseKey: noiseKey, + nostrIdentity: oldPeer + ) + defer { + removeFavorite(in: favoritesService, noiseKey: noiseKey) + } + transport.authenticatedPeerTransportStates[peerID] = + AuthenticatedPeerTransportState( + capabilities: [.doubleRatchet], + sessionGeneration: UUID(), + noisePublicKey: noiseKey + ) + + viewModel.bootstrapDoubleRatchetIfNeeded(for: peerID) + #expect( + viewModel.ndrPeerPubkeyByNoiseKey[noiseKey] + == oldPeer.publicKeyHex + ) + #expect(service.hasActiveSession(with: oldPeer.publicKeyHex)) + + favoritesService.updatePeerFavoritedUs( + peerNoisePublicKey: noiseKey, + favorited: true, + peerNostrPublicKey: newPeer.npub + ) + viewModel.bootstrapDoubleRatchetIfNeeded(for: peerID) + + #expect( + viewModel.ndrPeerPubkeyByNoiseKey[noiseKey] + == newPeer.publicKeyHex + ) + #expect(!service.hasPairwiseSession(with: oldPeer.publicKeyHex)) + #expect(favoritesService.isNdrRequired(for: noiseKey)) + #expect(transport.sentNdrEvents.count == 1) + } + + @Test("A failed durable favorite commit stays journaled after native retirement") + @MainActor + func favoriteRebindCommitFailureStaysFailClosed() throws { + let relay = FakeRelayManager() + let service = try makeService( + label: "favorite-rebind-commit-failure", + relay: relay + ) + let favoritesKeychain = MockKeychain() + let favoritesService = FavoritesPersistenceService( + keychain: favoritesKeychain + ) + let (viewModel, transport, _) = makeViewModel( + ndrService: service, + favoritesService: favoritesService + ) + let local = try #require( + try viewModel.idBridge.getCurrentNostrIdentity() + ) + let oldPeer = try NostrIdentity.generate() + let newPeer = try NostrIdentity.generate() + let oldPeerRelay = FakeRelayManager() + let oldPeerService = try makeService( + label: "favorite-rebind-commit-failure-peer", + relay: oldPeerRelay + ) + service.configureIfNeeded(identity: local) + oldPeerService.configureIfNeeded(identity: oldPeer) + try establishPairwiseSessions( + service, + oldPeerService, + firstIdentity: local, + secondIdentity: oldPeer, + firstRelay: relay, + secondRelay: oldPeerRelay + ) + + let noiseKey = Data(repeating: 0xd1, count: 32) + let peerID = PeerID(publicKey: noiseKey) + installMutualFavorite( + in: favoritesService, + noiseKey: noiseKey, + nostrIdentity: oldPeer + ) + transport.authenticatedPeerTransportStates[peerID] = + AuthenticatedPeerTransportState( + capabilities: [.doubleRatchet], + sessionGeneration: UUID(), + noisePublicKey: noiseKey + ) + viewModel.bootstrapDoubleRatchetIfNeeded(for: peerID) + #expect(favoritesService.isNdrRequired(for: noiseKey)) + + favoritesKeychain.simulatedGenericSaveFailureKeys.insert( + "chat.bitchat.favorites" + ) + favoritesService.updatePeerFavoritedUs( + peerNoisePublicKey: noiseKey, + favorited: true, + peerNostrPublicKey: newPeer.npub + ) + + #expect( + favoritesService.getFavoriteStatus(for: noiseKey)? + .peerNostrPublicKey == oldPeer.npub + ) + #expect(!service.hasPairwiseSession(with: oldPeer.publicKeyHex)) + #expect( + favoritesService.isNdrFallbackBlocked(for: peerID) ) #expect( - recipientRelay.subscriptions.filter { - $0.filter.kinds == [37368] && $0.filter.authors == [owner.publicKeyHex] - }.count == 1 - ) - - recipientService.processInboundRelayEvent( - try makeAppKeysEvent(owner: owner, devices: [device]) - ) - - #expect(recipientService.hasActiveSession(with: owner.publicKeyHex)) - #expect( - deferredResponses.contains { - (try? extractNostrKind(json: $0)) == 1059 - } + !favoritesService.canUseNdrBinding(for: peerID) ) #expect( - recipientRelay.activeSubscriptions.contains { - $0.filter.kinds == [37368] - && $0.filter.authors == [owner.publicKeyHex] + favoritesKeychain.load( + key: + "chat.bitchat.favorites.ndr-rebind-journal", + service: "chat.bitchat.favorites" + ) != nil + ) + + let replacementPeerService = try makeService( + label: "favorite-rebind-commit-failure-replacement" + ) + replacementPeerService.configureIfNeeded(identity: newPeer) + let replacementInvite = try #require( + replacementPeerService.currentInviteEventJson() + ) + let sentOobCount = transport.sentNdrEvents.count + + viewModel.handleNdrEventPayload( + from: peerID, + payload: Data(replacementInvite.utf8) + ) + + #expect(transport.sentNdrEvents.count == sentOobCount) + #expect( + !service.hasPairwiseSession(with: newPeer.publicKeyHex) + ) + } + + @Test("Unreadable binding state rejects authenticated OOB bootstrap") + @MainActor + func unreadableFavoriteBindingStateRejectsOutOfBandInvite() throws { + let favoritesKeychain = MockKeychain() + favoritesKeychain.simulatedGenericReadError = .accessDenied + let favoritesService = FavoritesPersistenceService( + keychain: favoritesKeychain + ) + favoritesKeychain.simulatedGenericReadError = nil + + let localService = try makeService( + label: "unreadable-binding-oob-local" + ) + let (viewModel, transport, _) = makeViewModel( + ndrService: localService, + favoritesService: favoritesService + ) + let remoteIdentity = try NostrIdentity.generate() + let remoteService = try makeService( + label: "unreadable-binding-oob-remote" + ) + remoteService.configureIfNeeded(identity: remoteIdentity) + let noiseKey = Data(repeating: 0xd3, count: 32) + let peerID = PeerID(publicKey: noiseKey) + installMutualFavorite( + in: favoritesService, + noiseKey: noiseKey, + nostrIdentity: remoteIdentity + ) + transport.authenticatedPeerTransportStates[peerID] = + AuthenticatedPeerTransportState( + capabilities: [.doubleRatchet], + sessionGeneration: UUID(), + noisePublicKey: noiseKey + ) + let invite = try #require( + remoteService.currentInviteEventJson() + ) + + viewModel.handleNdrEventPayload( + from: peerID, + payload: Data(invite.utf8) + ) + + #expect(transport.sentNdrEvents.isEmpty) + #expect( + !localService.hasPairwiseSession( + with: remoteIdentity.publicKeyHex + ) + ) + } + + @Test( + "A durable pin still journals and retires while rollout is disabled" + ) + @MainActor + func pinnedFavoriteRebindRetiresAcrossGateOffAndReenable() + throws + { + let storage = try makeTempDir(label: "favorite-rebind-gate-off") + let markerStore = InMemoryNdrSessionMarkerStore() + let nostrKeychain = MockKeychain() + let favoritesKeychain = MockKeychain() + let local = try #require( + try NostrIdentityBridge(keychain: nostrKeychain) + .getCurrentNostrIdentity() + ) + let oldPeer = try NostrIdentity.generate() + let newPeer = try NostrIdentity.generate() + let noiseKey = Data(repeating: 0xd2, count: 32) + let peerID = PeerID(publicKey: noiseKey) + + do { + let localRelay = FakeRelayManager() + let enabledService = NdrNostrService( + relayManager: localRelay, + rolloutEnabled: true, + storageDirectoryProvider: { storage }, + sessionMarkerStore: markerStore + ) + let favoritesService = FavoritesPersistenceService( + keychain: favoritesKeychain + ) + let (viewModel, transport, _) = makeViewModel( + ndrService: enabledService, + nostrKeychain: nostrKeychain, + favoritesService: favoritesService + ) + let peerRelay = FakeRelayManager() + let peerService = try makeService( + label: "favorite-rebind-gate-off-peer", + relay: peerRelay + ) + enabledService.configureIfNeeded(identity: local) + peerService.configureIfNeeded(identity: oldPeer) + try establishPairwiseSessions( + enabledService, + peerService, + firstIdentity: local, + secondIdentity: oldPeer, + firstRelay: localRelay, + secondRelay: peerRelay + ) + installMutualFavorite( + in: favoritesService, + noiseKey: noiseKey, + nostrIdentity: oldPeer + ) + transport.authenticatedPeerTransportStates[peerID] = + AuthenticatedPeerTransportState( + capabilities: [.doubleRatchet], + sessionGeneration: UUID(), + noisePublicKey: noiseKey + ) + viewModel.bootstrapDoubleRatchetIfNeeded(for: peerID) + #expect(favoritesService.isNdrRequired(for: noiseKey)) + } + + let disabledService = NdrNostrService( + relayManager: FakeRelayManager(), + rolloutEnabled: false, + storageDirectoryProvider: { storage }, + sessionMarkerStore: markerStore + ) + let restoredFavorites = FavoritesPersistenceService( + keychain: favoritesKeychain + ) + let (disabledViewModel, _, _) = makeViewModel( + ndrService: disabledService, + nostrKeychain: nostrKeychain, + favoritesService: restoredFavorites + ) + restoredFavorites.updatePeerFavoritedUs( + peerNoisePublicKey: noiseKey, + favorited: true, + peerNostrPublicKey: newPeer.npub + ) + + #expect( + restoredFavorites.getFavoriteStatus(for: noiseKey)? + .peerNostrPublicKey == newPeer.npub + ) + #expect( + favoritesKeychain.load( + key: + "chat.bitchat.favorites.ndr-rebind-journal", + service: "chat.bitchat.favorites" + ) == nil + ) + + let reenabledService = NdrNostrService( + relayManager: FakeRelayManager(), + rolloutEnabled: true, + storageDirectoryProvider: { storage }, + sessionMarkerStore: markerStore + ) + reenabledService.configureIfNeeded(identity: local) + #expect( + !reenabledService.hasPairwiseSession( + with: oldPeer.publicKeyHex + ) + ) + _ = disabledViewModel + } + + @Test("A favorite rebind after restart retires persisted pairwise state") + @MainActor + func restoredFavoriteRebindRetiresOldPeerBeforeMutation() throws { + let storage = try makeTempDir(label: "favorite-rebind-restart") + let markerStore = InMemoryNdrSessionMarkerStore() + let nostrKeychain = MockKeychain() + let local = try #require( + try NostrIdentityBridge(keychain: nostrKeychain) + .getCurrentNostrIdentity() + ) + let oldPeer = try NostrIdentity.generate() + let newPeer = try NostrIdentity.generate() + let oldPeerRelay = FakeRelayManager() + let firstRelay = FakeRelayManager() + + do { + let firstService = NdrNostrService( + relayManager: firstRelay, + rolloutEnabled: true, + storageDirectoryProvider: { storage }, + sessionMarkerStore: markerStore + ) + let (firstViewModel, _, _) = makeViewModel( + ndrService: firstService, + nostrKeychain: nostrKeychain + ) + let oldPeerService = try makeService( + label: "favorite-rebind-restart-peer", + relay: oldPeerRelay + ) + firstService.configureIfNeeded(identity: local) + oldPeerService.configureIfNeeded(identity: oldPeer) + try establishPairwiseSessions( + firstService, + oldPeerService, + firstIdentity: local, + secondIdentity: oldPeer, + firstRelay: firstRelay, + secondRelay: oldPeerRelay + ) + #expect( + firstService.hasActiveSession( + with: oldPeer.publicKeyHex + ) + ) + _ = firstViewModel + } + + let restoredService = NdrNostrService( + relayManager: FakeRelayManager(), + rolloutEnabled: true, + storageDirectoryProvider: { storage }, + sessionMarkerStore: markerStore + ) + let (restoredViewModel, restoredTransport, favoritesService) = + makeViewModel( + ndrService: restoredService, + nostrKeychain: nostrKeychain + ) + let noiseKey = Data((33..<65).map(UInt8.init)) + installMutualFavorite( + in: favoritesService, + noiseKey: noiseKey, + nostrIdentity: oldPeer + ) + defer { + removeFavorite(in: favoritesService, noiseKey: noiseKey) + } + #expect(restoredViewModel.ndrPeerPubkeyByNoiseKey.isEmpty) + #expect(restoredTransport.authenticatedPeerTransportStates.isEmpty) + + favoritesService.updatePeerFavoritedUs( + peerNoisePublicKey: noiseKey, + favorited: true, + peerNostrPublicKey: newPeer.npub + ) + + #expect( + favoritesService + .getFavoriteStatus(for: noiseKey)? + .peerNostrPublicKey == newPeer.npub + ) + #expect( + !restoredService.hasPairwiseSession( + with: oldPeer.publicKeyHex + ) + ) + } + + @Test("A shared old identity remains until its last Noise binding moves") + @MainActor + func sharedOldFavoriteBindingPreservesPairwiseSession() throws { + let local = try NostrIdentity.generate() + let oldPeer = try NostrIdentity.generate() + let firstNewPeer = try NostrIdentity.generate() + let secondNewPeer = try NostrIdentity.generate() + let localRelay = FakeRelayManager() + let oldPeerRelay = FakeRelayManager() + let storage = try makeTempDir( + label: "favorite-shared-old-local" + ) + let markerStore = InMemoryNdrSessionMarkerStore() + let localService = NdrNostrService( + relayManager: localRelay, + rolloutEnabled: true, + storageDirectoryProvider: { storage }, + sessionMarkerStore: markerStore + ) + let oldPeerService = try makeService( + label: "favorite-shared-old-peer", + relay: oldPeerRelay + ) + localService.configureIfNeeded(identity: local) + oldPeerService.configureIfNeeded(identity: oldPeer) + try establishPairwiseSessions( + localService, + oldPeerService, + firstIdentity: local, + secondIdentity: oldPeer, + firstRelay: localRelay, + secondRelay: oldPeerRelay + ) + + let nostrKeychain = MockKeychain() + let identityData = try JSONEncoder().encode(local) + nostrKeychain.save( + key: "nostr-current-identity", + data: identityData, + service: "chat.bitchat.nostr", + accessible: nil + ) + let favoritesService = FavoritesPersistenceService( + keychain: MockKeychain() + ) + let firstNoiseKey = Data((65..<97).map(UInt8.init)) + let secondNoiseKey = Data((97..<129).map(UInt8.init)) + // Model legacy persisted data created before one-to-one assignment + // authorization was installed. + installMutualFavorite( + in: favoritesService, + noiseKey: firstNoiseKey, + nostrIdentity: oldPeer + ) + installMutualFavorite( + in: favoritesService, + noiseKey: secondNoiseKey, + nostrIdentity: oldPeer + ) + let (viewModel, _, _) = makeViewModel( + ndrService: localService, + nostrKeychain: nostrKeychain, + favoritesService: favoritesService + ) + defer { + removeFavorite( + in: favoritesService, + noiseKey: firstNoiseKey + ) + removeFavorite( + in: favoritesService, + noiseKey: secondNoiseKey + ) + } + + favoritesService.updatePeerFavoritedUs( + peerNoisePublicKey: firstNoiseKey, + favorited: true, + peerNostrPublicKey: firstNewPeer.npub + ) + + #expect( + favoritesService + .getFavoriteStatus(for: firstNoiseKey)? + .peerNostrPublicKey == firstNewPeer.npub + ) + #expect( + localService.hasActiveSession(with: oldPeer.publicKeyHex) + ) + #expect( + viewModel.ndrPeerPubkeyByNoiseKey[firstNoiseKey] + == firstNewPeer.publicKeyHex + ) + + favoritesService.updatePeerFavoritedUs( + peerNoisePublicKey: secondNoiseKey, + favorited: true, + peerNostrPublicKey: secondNewPeer.npub + ) + #expect( + !localService.hasPairwiseSession( + with: oldPeer.publicKeyHex + ) + ) + #expect( + viewModel.ndrPeerPubkeyByNoiseKey[secondNoiseKey] + == secondNewPeer.publicKeyHex + ) + + let restartedService = NdrNostrService( + relayManager: FakeRelayManager(), + rolloutEnabled: true, + storageDirectoryProvider: { storage }, + sessionMarkerStore: markerStore + ) + restartedService.configureIfNeeded(identity: local) + #expect( + !restartedService.hasPairwiseSession( + with: oldPeer.publicKeyHex + ) + ) + } + + @Test("A Nostr identity cannot be rebound to a second Noise key") + @MainActor + func favoriteRebindRejectsDuplicateNewIdentityBinding() throws { + let service = try makeService(label: "favorite-one-to-one") + let (viewModel, _, favoritesService) = + makeViewModel(ndrService: service) + let oldPeer = try NostrIdentity.generate() + let alreadyBoundPeer = try NostrIdentity.generate() + let firstNoiseKey = Data((129..<161).map(UInt8.init)) + let secondNoiseKey = Data((161..<193).map(UInt8.init)) + installMutualFavorite( + in: favoritesService, + noiseKey: firstNoiseKey, + nostrIdentity: oldPeer + ) + installMutualFavorite( + in: favoritesService, + noiseKey: secondNoiseKey, + nostrIdentity: alreadyBoundPeer + ) + defer { + removeFavorite( + in: favoritesService, + noiseKey: firstNoiseKey + ) + removeFavorite( + in: favoritesService, + noiseKey: secondNoiseKey + ) + } + + favoritesService.updatePeerFavoritedUs( + peerNoisePublicKey: firstNoiseKey, + favorited: true, + peerNostrPublicKey: alreadyBoundPeer.npub + ) + + #expect( + favoritesService + .getFavoriteStatus(for: firstNoiseKey)? + .peerNostrPublicKey == oldPeer.npub + ) + _ = viewModel + } + + @Test("Initial favorite assignment rejects a duplicate Nostr identity") + @MainActor + func initialFavoriteAssignmentRejectsDuplicateIdentity() throws { + let service = try makeService(label: "favorite-initial-duplicate") + let (viewModel, _, favoritesService) = + makeViewModel(ndrService: service) + let peer = try NostrIdentity.generate() + let firstNoiseKey = Data(repeating: 0xa1, count: 32) + let secondNoiseKey = Data(repeating: 0xa2, count: 32) + defer { + removeFavorite( + in: favoritesService, + noiseKey: firstNoiseKey + ) + removeFavorite( + in: favoritesService, + noiseKey: secondNoiseKey + ) + } + + favoritesService.addFavorite( + peerNoisePublicKey: firstNoiseKey, + peerNostrPublicKey: peer.npub, + peerNickname: "First NDR test peer" + ) + favoritesService.addFavorite( + peerNoisePublicKey: secondNoiseKey, + peerNostrPublicKey: peer.npub, + peerNickname: "Second NDR test peer" + ) + + #expect( + favoritesService + .getFavoriteStatus(for: firstNoiseKey)? + .peerNostrPublicKey == peer.npub + ) + #expect( + favoritesService.getFavoriteStatus(for: secondNoiseKey) == nil + ) + _ = viewModel + } + + @Test("Nil favorite assignment rejects a duplicate Nostr identity") + @MainActor + func nilFavoriteAssignmentRejectsDuplicateIdentity() throws { + let service = try makeService(label: "favorite-nil-duplicate") + let (viewModel, _, favoritesService) = + makeViewModel(ndrService: service) + let peer = try NostrIdentity.generate() + let firstNoiseKey = Data(repeating: 0xb1, count: 32) + let secondNoiseKey = Data(repeating: 0xb2, count: 32) + defer { + removeFavorite( + in: favoritesService, + noiseKey: firstNoiseKey + ) + removeFavorite( + in: favoritesService, + noiseKey: secondNoiseKey + ) + } + + favoritesService.addFavorite( + peerNoisePublicKey: firstNoiseKey, + peerNostrPublicKey: peer.npub, + peerNickname: "First NDR test peer" + ) + favoritesService.addFavorite( + peerNoisePublicKey: secondNoiseKey, + peerNickname: "Second NDR test peer" + ) + favoritesService.updatePeerFavoritedUs( + peerNoisePublicKey: secondNoiseKey, + favorited: true, + peerNostrPublicKey: peer.npub + ) + + let secondRelationship = + favoritesService.getFavoriteStatus(for: secondNoiseKey) + #expect(secondRelationship?.peerNostrPublicKey == nil) + #expect(secondRelationship?.theyFavoritedUs == false) + _ = viewModel + } + + @Test("Malformed favorite identity rebinds fail closed") + @MainActor + func favoriteRebindRejectsMalformedOldOrNewIdentity() throws { + let service = try makeService(label: "favorite-malformed-rebind") + let favoritesService = FavoritesPersistenceService( + keychain: MockKeychain() + ) + let malformedOldNoiseKey = Data(repeating: 0xc1, count: 32) + // Model malformed legacy data loaded before assignment authorization. + favoritesService.addFavorite( + peerNoisePublicKey: malformedOldNoiseKey, + peerNostrPublicKey: "malformed-old", + peerNickname: "Malformed NDR test peer" + ) + let (viewModel, _, _) = + makeViewModel( + ndrService: service, + favoritesService: favoritesService + ) + let validPeer = try NostrIdentity.generate() + let validOldNoiseKey = Data(repeating: 0xc2, count: 32) + + installMutualFavorite( + in: favoritesService, + noiseKey: validOldNoiseKey, + nostrIdentity: validPeer + ) + defer { + removeFavorite( + in: favoritesService, + noiseKey: validOldNoiseKey + ) + removeFavorite( + in: favoritesService, + noiseKey: malformedOldNoiseKey + ) + } + + favoritesService.updatePeerFavoritedUs( + peerNoisePublicKey: validOldNoiseKey, + favorited: true, + peerNostrPublicKey: "malformed-new" + ) + #expect( + favoritesService + .getFavoriteStatus(for: validOldNoiseKey)? + .peerNostrPublicKey == validPeer.npub + ) + + favoritesService.updatePeerFavoritedUs( + peerNoisePublicKey: malformedOldNoiseKey, + favorited: true, + peerNostrPublicKey: validPeer.npub + ) + #expect( + favoritesService + .getFavoriteStatus(for: malformedOldNoiseKey)? + .peerNostrPublicKey == "malformed-old" + ) + _ = viewModel + } + + @Test("A failed invite retries without a reconnect") + @MainActor + func failedInviteRetriesWithCurrentAuthenticatedBinding() async throws { + let relay = FakeRelayManager() + let retryScheduler = FakeNdrRetryScheduler() + let storage = try makeTempDir(label: "invite-host-retry") + let service = NdrNostrService( + relayManager: relay, + rolloutEnabled: true, + storageDirectoryProvider: { storage }, + retryScheduler: retryScheduler.schedule + ) + let (viewModel, transport, favoritesService) = + makeViewModel(ndrService: service) + let remote = try NostrIdentity.generate() + let noiseKey = try #require( + Data(hexString: remote.publicKeyHex) + ) + let peerID = PeerID(str: "1021324354657687") + let binding = AuthenticatedPeerTransportState( + capabilities: [.doubleRatchet], + sessionGeneration: UUID(), + noisePublicKey: noiseKey + ) + installMutualFavorite( + in: favoritesService, + noiseKey: noiseKey, + nostrIdentity: remote + ) + defer { + removeFavorite(in: favoritesService, noiseKey: noiseKey) + } + transport.authenticatedPeerTransportStates[peerID] = binding + transport.ndrSendResults = [false, true] + + viewModel.bootstrapDoubleRatchetIfNeeded(for: peerID) + #expect(transport.sentNdrEvents.count == 1) + viewModel.bootstrapDoubleRatchetIfNeeded(for: peerID) + viewModel.bootstrapDoubleRatchetIfNeeded(for: peerID) + #expect( + transport.sentNdrEvents.count == 1, + "repeated triggers must share one invite retry chain" + ) + let retryScheduled = await TestHelpers.waitUntil( + { !retryScheduler.scheduled.isEmpty }, + timeout: TestConstants.settleTimeout + ) + #expect(retryScheduled) + #expect(retryScheduler.requestedDelays == [0.25]) + + retryScheduler.runNext() + + #expect(transport.sentNdrEvents.count == 2) + viewModel.bootstrapDoubleRatchetIfNeeded(for: peerID) + #expect( + transport.sentNdrEvents.count == 2, + "a handed-off invite stays deduplicated until the binding changes" + ) + #expect( + transport.sentNdrEvents.allSatisfy { + $0.expectedTransportState == binding } ) } - @Test("Delayed owner roster preserves invites from two devices on the same account") + @Test("Every failed OOB retry revalidates Noise generation and favorite") @MainActor - func multiDeviceInvites_sameOwnerDoNotOverwriteEachOther() throws { - let owner = try NostrIdentity.generate() - let firstDevice = try NostrIdentity.generate() - let secondDevice = try NostrIdentity.generate() - let recipient = try NostrIdentity.generate() - let firstManager = try makeChildManager( - identity: firstDevice, - owner: owner, - deviceID: "multi-device-first" - ) - let secondManager = try makeChildManager( - identity: secondDevice, - owner: owner, - deviceID: "multi-device-second" - ) - let firstInvite = try inviteEventJson(from: firstManager) - let secondInvite = try inviteEventJson(from: secondManager) + func outOfBandRetryRejectsStaleBindingsAndRemainsDurable() async throws { let relay = FakeRelayManager() + let retryScheduler = FakeNdrRetryScheduler() + let storage = try makeTempDir(label: "oob-host-retry") let service = NdrNostrService( relayManager: relay, - deviceId: "multi-device-recipient-two", rolloutEnabled: true, - storageDirectoryProvider: { - try makeTempDir(label: "ndr-multi-device-recipient-two") - } + storageDirectoryProvider: { storage }, + retryScheduler: retryScheduler.schedule ) - service.configureIfNeeded(identity: recipient) - var firstResponses: [String] = [] - var secondResponses: [String] = [] + let (viewModel, transport, favoritesService) = + makeViewModel(ndrService: service) + let remoteIdentity = try NostrIdentity.generate() + let remoteService = try makeService(label: "oob-host-remote") + remoteService.configureIfNeeded(identity: remoteIdentity) + let noiseKey = try #require( + Data(hexString: remoteIdentity.publicKeyHex) + ) + let peerID = PeerID(str: "2132435465768798") + let firstBinding = AuthenticatedPeerTransportState( + capabilities: [.doubleRatchet], + sessionGeneration: UUID(), + noisePublicKey: noiseKey + ) + installMutualFavorite( + in: favoritesService, + noiseKey: noiseKey, + nostrIdentity: remoteIdentity + ) + defer { + removeFavorite(in: favoritesService, noiseKey: noiseKey) + } + transport.authenticatedPeerTransportStates[peerID] = firstBinding + transport.ndrSendResults = [false, false, true] - #expect( - service.processOutOfBandEventJson( - firstInvite, - expectedPeerPubkeyHex: owner.publicKeyHex, - deferredResponseHandler: { firstResponses.append($0) } - ).isEmpty + let invite = try #require( + remoteService.currentInviteEventJson() ) - #expect( - service.processOutOfBandEventJson( - secondInvite, - expectedPeerPubkeyHex: owner.publicKeyHex, - deferredResponseHandler: { secondResponses.append($0) } - ).isEmpty + viewModel.handleNdrEventPayload( + from: peerID, + payload: Data(invite.utf8) ) + #expect(transport.sentNdrEvents.count == 1) + let firstRetryScheduled = await TestHelpers.waitUntil( + { !retryScheduler.scheduled.isEmpty }, + timeout: TestConstants.settleTimeout + ) + #expect(firstRetryScheduled) + + let secondBinding = AuthenticatedPeerTransportState( + capabilities: [.doubleRatchet], + sessionGeneration: UUID(), + noisePublicKey: noiseKey + ) + transport.authenticatedPeerTransportStates[peerID] = secondBinding + retryScheduler.runNext() #expect( - relay.subscriptions.filter { - $0.filter.kinds == [37368] && $0.filter.authors == [owner.publicKeyHex] - }.count == 1 + transport.sentNdrEvents.count == 1, + "the old Noise generation must not be retried" ) - service.processInboundRelayEvent( - try makeAppKeysEvent(owner: owner, devices: [firstDevice, secondDevice]) + viewModel.bootstrapDoubleRatchetIfNeeded(for: peerID) + #expect(transport.sentNdrEvents.count == 2) + let secondRetryScheduled = await TestHelpers.waitUntil( + { !retryScheduler.scheduled.isEmpty }, + timeout: TestConstants.settleTimeout + ) + #expect(secondRetryScheduled) + favoritesService.updatePeerFavoritedUs( + peerNoisePublicKey: noiseKey, + favorited: false + ) + retryScheduler.runNext() + #expect( + transport.sentNdrEvents.count == 2, + "a revoked favorite must stop the retry" ) - #expect(firstResponses.contains { (try? extractNostrKind(json: $0)) == 1059 }) - #expect(secondResponses.contains { (try? extractNostrKind(json: $0)) == 1059 }) + favoritesService.updatePeerFavoritedUs( + peerNoisePublicKey: noiseKey, + favorited: true, + peerNostrPublicKey: remoteIdentity.npub + ) + viewModel.bootstrapDoubleRatchetIfNeeded(for: peerID) + #expect(transport.sentNdrEvents.count == 3) + #expect( + transport.sentNdrEvents.last?.expectedTransportState + == secondBinding + ) + } + + @Test("A relay rejection retries without waiting for reconnect") + @MainActor + func rejectedPublishRetriesUntilNip01Acceptance() throws { + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() + let aliceRelay = FakeRelayManager() + let retryScheduler = FakeNdrRetryScheduler() + let aliceStorage = try makeTempDir(label: "publish-retry-alice") + let aliceService = NdrNostrService( + relayManager: aliceRelay, + rolloutEnabled: true, + storageDirectoryProvider: { aliceStorage }, + retryScheduler: retryScheduler.schedule + ) + let bobRelay = FakeRelayManager() + let bobService = try makeService( + label: "publish-retry-bob", + relay: bobRelay + ) + aliceService.configureIfNeeded(identity: alice) + bobService.configureIfNeeded(identity: bob) + try establishPairwiseSessions( + aliceService, + bobService, + firstIdentity: alice, + secondIdentity: bob, + firstRelay: aliceRelay, + secondRelay: bobRelay + ) + + aliceRelay.resetSentEvents() + aliceRelay.automaticallyCompletePublishes = false + let result = aliceService.send( + "bitchat1:retry-me", + to: bob.publicKeyHex + ) + guard case let .sent(_, outerEventID) = result else { + Issue.record("Expected a pairwise send") + return + } + #expect(aliceRelay.pendingPublishes.count == 1) + #expect(aliceRelay.sentEvents.map(\.id) == [outerEventID]) + + aliceRelay.completeNextPublish(accepted: false) + #expect(retryScheduler.scheduled.map(\.delay) == [0.25]) + retryScheduler.runNext() + #expect(aliceRelay.pendingPublishes.count == 1) + #expect( + aliceRelay.sentEvents.filter { $0.id == outerEventID }.count == 2 + ) + + aliceRelay.completeNextPublish(accepted: true) + #expect(retryScheduler.scheduled.isEmpty) + #expect( + aliceRelay.sentEvents.filter { $0.id == outerEventID }.count == 2 + ) + } + + @Test("A connectivity wake invalidates the old relay retry timer") + @MainActor + func connectivityWakeDoesNotDoubleRunStaleRetryTimer() throws { + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() + let relay = FakeRelayManager() + let retryScheduler = FakeNdrRetryScheduler() + let storage = try makeTempDir(label: "publish-retry-token-alice") + let aliceService = NdrNostrService( + relayManager: relay, + rolloutEnabled: true, + storageDirectoryProvider: { storage }, + retryScheduler: retryScheduler.schedule + ) + let bobRelay = FakeRelayManager() + let bobService = try makeService( + label: "publish-retry-token-bob", + relay: bobRelay + ) + aliceService.configureIfNeeded(identity: alice) + bobService.configureIfNeeded(identity: bob) + try establishPairwiseSessions( + aliceService, + bobService, + firstIdentity: alice, + secondIdentity: bob, + firstRelay: relay, + secondRelay: bobRelay + ) + + relay.resetSentEvents() + relay.automaticallyCompletePublishes = false + guard case let .sent(_, outerEventID) = aliceService.send( + "bitchat1:retry-token", + to: bob.publicKeyHex + ) else { + Issue.record("Expected a pairwise send") + return + } + + relay.completeNextPublish(accepted: false) + #expect(retryScheduler.scheduled.count == 1) + + aliceService.retryRelayActions() + #expect( + relay.sentEvents.filter { $0.id == outerEventID }.count == 2 + ) + relay.completeNextPublish(accepted: false) + #expect(retryScheduler.scheduled.count == 2) + + retryScheduler.runNext() + #expect( + relay.sentEvents.filter { $0.id == outerEventID }.count == 2, + "the timer from the previous connectivity epoch must be inert" + ) + + retryScheduler.runNext() + #expect( + relay.sentEvents.filter { $0.id == outerEventID }.count == 3 + ) + } + + @Test("Relay retry backoff is bounded") + @MainActor + func rejectedPublishStopsAfterBoundedBackoff() throws { + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() + let relay = FakeRelayManager() + let retryScheduler = FakeNdrRetryScheduler() + let storage = try makeTempDir(label: "publish-bounded-alice") + let aliceService = NdrNostrService( + relayManager: relay, + rolloutEnabled: true, + storageDirectoryProvider: { storage }, + retryScheduler: retryScheduler.schedule + ) + let bobRelay = FakeRelayManager() + let bobService = try makeService( + label: "publish-bounded-bob", + relay: bobRelay + ) + aliceService.configureIfNeeded(identity: alice) + bobService.configureIfNeeded(identity: bob) + try establishPairwiseSessions( + aliceService, + bobService, + firstIdentity: alice, + secondIdentity: bob, + firstRelay: relay, + secondRelay: bobRelay + ) + + relay.resetSentEvents() + relay.automaticallyCompletePublishes = false + guard case let .sent(_, outerEventID) = aliceService.send( + "bitchat1:bounded-retry", + to: bob.publicKeyHex + ) else { + Issue.record("Expected a pairwise send") + return + } + + for _ in 0..<4 { + relay.completeNextPublish(accepted: false) + retryScheduler.runNext() + } + relay.completeNextPublish(accepted: false) + + #expect( + retryScheduler.requestedDelays == [0.25, 0.5, 1, 2] + ) + #expect(retryScheduler.scheduled.isEmpty) + #expect( + relay.sentEvents.filter { $0.id == outerEventID }.count == 5 + ) + + _ = aliceService.processOutOfBandEventJson( + "not-a-valid-oob-event", + expectedPeerPubkeyHex: bob.publicKeyHex, + persistEstablishedBinding: { true } + ) + #expect( + relay.sentEvents.filter { $0.id == outerEventID }.count == 5, + "invalid OOB traffic must not release unrelated relay deferrals" + ) + for _ in 0..<3 { + aliceService.configureIfNeeded(identity: alice) + } + #expect( + relay.sentEvents.filter { $0.id == outerEventID }.count == 5, + "ordinary sends/configuration must not reset an exhausted budget" + ) + guard case .sent = aliceService.send( + "bitchat1:unrelated-traffic", + to: bob.publicKeyHex + ) else { + Issue.record("Expected unrelated pairwise send") + return + } + #expect( + relay.sentEvents.filter { $0.id == outerEventID }.count == 5 + ) + } + + @Test("Connectivity recovery wakes a subscription after retry exhaustion") + @MainActor + func subscriptionRegistrationRetriesWithoutRestart() throws { + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() + let relay = FakeRelayManager() + relay.subscriptionRegistrationSucceeds = false + let retryScheduler = FakeNdrRetryScheduler() + let storage = try makeTempDir(label: "subscription-retry-alice") + let aliceService = NdrNostrService( + relayManager: relay, + rolloutEnabled: true, + storageDirectoryProvider: { storage }, + retryScheduler: retryScheduler.schedule + ) + let bobRelay = FakeRelayManager() + let bobService = try makeService( + label: "subscription-retry-bob", + relay: bobRelay + ) + aliceService.configureIfNeeded(identity: alice) + bobService.configureIfNeeded(identity: bob) + + try establishPairwiseSessions( + aliceService, + bobService, + firstIdentity: alice, + secondIdentity: bob, + firstRelay: relay, + secondRelay: bobRelay + ) + + #expect(relay.activeSubscriptions.isEmpty) + while !retryScheduler.scheduled.isEmpty { + retryScheduler.runNext() + } + #expect( + Array(retryScheduler.requestedDelays.suffix(4)) + == [0.25, 0.5, 1, 2] + ) + #expect(relay.activeSubscriptions.isEmpty) + + relay.subscriptionRegistrationSucceeds = true + aliceService.retryRelayActions() #expect( relay.activeSubscriptions.contains { - $0.filter.kinds == [37368] - && $0.filter.authors == [owner.publicKeyHex] + $0.filter.kinds == [1060] } ) } - @Test("Owner roster updates stop outbound fanout to a removed device") + @Test("A restart drains mixed durable actions beyond one host batch") @MainActor - func durableOwnerRoster_removesRevokedDeviceFromOutboundFanout() throws { - let owner = try NostrIdentity.generate() - let firstDevice = try NostrIdentity.generate() - let removedDevice = try NostrIdentity.generate() - let recipient = try NostrIdentity.generate() - let firstManager = try makeChildManager( - identity: firstDevice, - owner: owner, - deviceID: "durable-roster-first" + func restartDrainsMoreThanMaximumActionBatch() async throws { + let storage = try makeTempDir(label: "large-action-batch") + let aliceKeys = generateKeypair() + let bobKeys = generateKeypair() + let aliceIdentity = try NostrIdentity( + privateKeyData: try #require( + Data(hexString: aliceKeys.privateKeyHex) + ) ) - let removedManager = try makeChildManager( - identity: removedDevice, - owner: owner, - deviceID: "durable-roster-removed" + let managerPath = storage + .appendingPathComponent("pairwise-v1", isDirectory: true) + .appendingPathComponent( + aliceKeys.publicKeyHex, + isDirectory: true + ) + .path + try seedMixedPendingActions( + outboundCount: 127, + deliveryCount: 128, + senderKeys: aliceKeys, + peerKeys: bobKeys, + storagePath: managerPath ) - let firstInvite = try inviteEventJson(from: firstManager) - let removedInvite = try inviteEventJson(from: removedManager) + let relay = FakeRelayManager() - let service = NdrNostrService( + let restored = NdrNostrService( relayManager: relay, - deviceId: "durable-roster-recipient", rolloutEnabled: true, - storageDirectoryProvider: { - try makeTempDir(label: "ndr-durable-roster-recipient") - } + storageDirectoryProvider: { storage } ) - service.configureIfNeeded(identity: recipient) - var firstResponses: [String] = [] - var removedResponses: [String] = [] + var deliveredCount = 0 + restored.onDecryptedMessage = { _, completion in + deliveredCount += 1 + completion(.consumed) + } + restored.configureIfNeeded(identity: aliceIdentity) - #expect( - service.processOutOfBandEventJson( - firstInvite, - expectedPeerPubkeyHex: owner.publicKeyHex, - deferredResponseHandler: { firstResponses.append($0) } - ).isEmpty + let drained = await TestHelpers.waitUntil( + { + relay.sentEvents.filter { $0.kind == 1060 }.count == 127 + && deliveredCount == 128 + }, + timeout: TestConstants.settleTimeout ) - #expect( - service.processOutOfBandEventJson( - removedInvite, - expectedPeerPubkeyHex: owner.publicKeyHex, - deferredResponseHandler: { removedResponses.append($0) } - ).isEmpty - ) - - let initialTimestamp = Int(Date().timeIntervalSince1970) - service.processInboundRelayEvent( - try makeAppKeysEvent( - owner: owner, - devices: [firstDevice, removedDevice], - timestamp: initialTimestamp - ) - ) - #expect(service.hasActiveSession(with: owner.publicKeyHex)) - #expect(firstResponses.contains { (try? extractNostrKind(json: $0)) == 1059 }) - #expect(removedResponses.contains { (try? extractNostrKind(json: $0)) == 1059 }) - - relay.resetSentEvents() - #expect( - service.sendIfPossible( - "bitchat1:before-device-removal", - to: owner.publicKeyHex - ) - ) - #expect(relay.sentEvents.filter { $0.kind == 1060 }.count == 2) - - let rosterSubscription = try #require( - relay.activeSubscriptions.first { - $0.filter.kinds == [37368] - && $0.filter.authors == [owner.publicKeyHex] - } - ) - #expect( - !relay.subscriptions.contains { - $0.filter.kinds?.contains(30078) == true - } - ) - relay.deliver( - try makeAppKeysEvent( - owner: owner, - devices: [firstDevice], - timestamp: initialTimestamp + 1 - ), - to: rosterSubscription.id - ) - - relay.resetSentEvents() - #expect( - service.sendIfPossible( - "bitchat1:after-device-removal", - to: owner.publicKeyHex - ) - ) - #expect(relay.sentEvents.filter { $0.kind == 1060 }.count == 1) + #expect(drained) + restored.retryRelayActions() + #expect(relay.sentEvents.filter { $0.kind == 1060 }.count == 127) + #expect(deliveredCount == 128) } - @Test("Persisted peer owners restore durable roster subscriptions") + @Test("The FFI remains the durable delivery queue until explicit ack") @MainActor - func durableOwnerRoster_restoresAfterRestart() throws { - let peer = try NostrIdentity.generate() - let recipient = try NostrIdentity.generate() - let peerManager = try makeChildManager( - identity: peer, - owner: peer, - deviceID: "durable-restart-peer" + func decryptedDeliveryRetriesUntilConsumerAcknowledges() throws { + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() + let aliceRelay = FakeRelayManager() + let aliceService = try makeService( + label: "delivery-alice", + relay: aliceRelay ) - let invite = try inviteEventJson(from: peerManager) - let storage = try makeTempDir(label: "ndr-durable-restart") + let bobRelay = FakeRelayManager() + let bobService = try makeService( + label: "delivery-bob", + relay: bobRelay + ) + aliceService.configureIfNeeded(identity: alice) + bobService.configureIfNeeded(identity: bob) + try establishPairwiseSessions( + aliceService, + bobService, + firstIdentity: alice, + secondIdentity: bob, + firstRelay: aliceRelay, + secondRelay: bobRelay + ) + + aliceRelay.resetSentEvents() + guard case .sent = aliceService.send( + "bitchat1:durable", + to: bob.publicKeyHex + ) else { + Issue.record("Expected a pairwise send") + return + } + let outer = try #require( + aliceRelay.sentEvents.first(where: { $0.kind == 1060 }) + ) + + bobService.processInboundRelayEvent(outer) + var deliveries: [NdrDecryptedMessage] = [] + bobService.onDecryptedMessage = { message, completion in + deliveries.append(message) + completion(deliveries.count == 1 ? .retry : .consumed) + } + #expect(deliveries.count == 1) + + bobService.retryPendingDeliveries() + #expect(deliveries.count == 2) + #expect(deliveries.allSatisfy { $0.event.content == "bitchat1:durable" }) + + bobService.retryPendingDeliveries() + bobService.processInboundRelayEvent(outer) + #expect(deliveries.count == 2) + } + + @Test("Switching identities isolates invites and persisted ratchet state") + @MainActor + func identitySwitchIsolatesPersistedState() throws { + let storage = try makeTempDir(label: "identity-switch") + let first = try NostrIdentity.generate() + let second = try NostrIdentity.generate() + let service = NdrNostrService( + relayManager: FakeRelayManager(), + rolloutEnabled: true, + storageDirectoryProvider: { storage } + ) + + service.configureIfNeeded(identity: first) + let firstInvite = try PairwiseInvite.fromEventJson( + eventJson: try #require(service.currentInviteEventJson()) + ) + #expect(firstInvite.getPeerPubkeyHex() == first.publicKeyHex) + + service.configureIfNeeded(identity: second) + let secondInvite = try PairwiseInvite.fromEventJson( + eventJson: try #require(service.currentInviteEventJson()) + ) + + #expect(service.configuredPubkeyHex == second.publicKeyHex) + #expect(secondInvite.getPeerPubkeyHex() == second.publicKeyHex) + #expect(!service.hasActiveSession(with: first.publicKeyHex)) + } + + @Test("Late relay callbacks cannot acknowledge another account's action") + @MainActor + func identityEpochRejectsLatePublishCompletion() throws { + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() + let replacement = try NostrIdentity.generate() + let relay = FakeRelayManager() + let storage = try makeTempDir(label: "late-callback") + let aliceService = NdrNostrService( + relayManager: relay, + rolloutEnabled: true, + storageDirectoryProvider: { storage } + ) + let bobRelay = FakeRelayManager() + let bobService = try makeService( + label: "late-callback-bob", + relay: bobRelay + ) + aliceService.configureIfNeeded(identity: alice) + bobService.configureIfNeeded(identity: bob) + try establishPairwiseSessions( + aliceService, + bobService, + firstIdentity: alice, + secondIdentity: bob, + firstRelay: relay, + secondRelay: bobRelay + ) + + relay.resetSentEvents() + relay.automaticallyCompletePublishes = false + let sendResult = aliceService.send( + "bitchat1:old-account", + to: bob.publicKeyHex + ) + guard case let .sent(_, outerEventID) = sendResult else { + Issue.record("Expected a pairwise send") + return + } + #expect(relay.pendingPublishes.count == 1) + + aliceService.configureIfNeeded(identity: replacement) + relay.completeNextPublish(accepted: true) + #expect(aliceService.configuredPubkeyHex == replacement.publicKeyHex) + #expect(!aliceService.hasActiveSession(with: bob.publicKeyHex)) + + aliceService.configureIfNeeded(identity: alice) + #expect( + relay.sentEvents.filter { $0.id == outerEventID }.count == 2 + ) + relay.completeNextPublish(accepted: true) + } + + @Test("Pairwise state and kind-1060 subscription survive restart") + @MainActor + func pairwiseSessionRestoresAfterRestart() throws { + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() + let aliceStorage = try makeTempDir(label: "restart-alice") + let bobRelay = FakeRelayManager() + let bobService = try makeService( + label: "restart-bob", + relay: bobRelay + ) + bobService.configureIfNeeded(identity: bob) + + do { + let initialRelay = FakeRelayManager() + let initial = NdrNostrService( + relayManager: initialRelay, + rolloutEnabled: true, + storageDirectoryProvider: { aliceStorage } + ) + initial.configureIfNeeded(identity: alice) + try establishPairwiseSessions( + initial, + bobService, + firstIdentity: alice, + secondIdentity: bob, + firstRelay: initialRelay, + secondRelay: bobRelay + ) + #expect(initial.hasActiveSession(with: bob.publicKeyHex)) + } + + let restoredRelay = FakeRelayManager() + let restored = NdrNostrService( + relayManager: restoredRelay, + rolloutEnabled: true, + storageDirectoryProvider: { aliceStorage } + ) + restored.configureIfNeeded(identity: alice) + + #expect(restored.hasActiveSession(with: bob.publicKeyHex)) + #expect( + restoredRelay.activeSubscriptions.contains { + $0.filter.kinds == [1060] + && $0.filter.authors?.isEmpty == false + } + ) + restoredRelay.resetSentEvents() + let restoredSend = restored.send( + "bitchat1:after-restart", + to: bob.publicKeyHex + ) + guard case .sent = restoredSend else { + Issue.record( + "Expected restored session to send, got \(restoredSend)" + ) + return + } + #expect(restoredRelay.sentEvents.filter { $0.kind == 1060 }.count == 1) + } + + @Test("Startup backfills the durable per-Noise pin before NDR delivery") + @MainActor + func startupBackfillsFavoritePinForRestoredPairwiseSession() throws { + let local = try NostrIdentity.generate() + let peer = try NostrIdentity.generate() + let localStorage = try makeTempDir(label: "startup-pin-local") + let markerStore = InMemoryNdrSessionMarkerStore() + let peerRelay = FakeRelayManager() + let peerService = try makeService( + label: "startup-pin-peer", + relay: peerRelay + ) + peerService.configureIfNeeded(identity: peer) do { let initialRelay = FakeRelayManager() let initialService = NdrNostrService( relayManager: initialRelay, - deviceId: "durable-restart-recipient", rolloutEnabled: true, - storageDirectoryProvider: { storage } + storageDirectoryProvider: { localStorage }, + sessionMarkerStore: markerStore ) - initialService.configureIfNeeded(identity: recipient) - _ = initialService.processOutOfBandEventJson( - invite, - expectedPeerPubkeyHex: peer.publicKeyHex - ) - #expect(initialService.hasActiveSession(with: peer.publicKeyHex)) - } - - let restoredRelay = FakeRelayManager() - let restoredService = NdrNostrService( - relayManager: restoredRelay, - deviceId: "durable-restart-recipient", - rolloutEnabled: true, - storageDirectoryProvider: { storage } - ) - restoredService.configureIfNeeded(identity: recipient) - - #expect(restoredService.hasActiveSession(with: peer.publicKeyHex)) - #expect( - restoredRelay.activeSubscriptions.contains { - $0.filter.kinds == [37368] - && $0.filter.authors == [peer.publicKeyHex] - } - ) - #expect( - !restoredRelay.subscriptions.contains { - $0.filter.kinds?.contains(30078) == true - } - ) - } - - @Test("Delayed owner roster cannot resume after its authenticated binding is revoked") - @MainActor - func multiDeviceInvite_rechecksAuthorizationBeforeRetry() throws { - let owner = try NostrIdentity.generate() - let device = try NostrIdentity.generate() - let recipient = try NostrIdentity.generate() - let deviceManager = try makeChildManager( - identity: device, - owner: owner, - deviceID: "revoked-device" - ) - let invite = try inviteEventJson(from: deviceManager) - let relay = FakeRelayManager() - let service = NdrNostrService( - relayManager: relay, - deviceId: "revoked-recipient", - rolloutEnabled: true, - storageDirectoryProvider: { - try makeTempDir(label: "ndr-revoked-recipient") - } - ) - service.configureIfNeeded(identity: recipient) - var isAuthorized = true - var deferredResponses: [String] = [] - - #expect( - service.processOutOfBandEventJson( - invite, - expectedPeerPubkeyHex: owner.publicKeyHex, - authorization: { isAuthorized }, - deferredResponseHandler: { deferredResponses.append($0) } - ).isEmpty - ) - isAuthorized = false - - service.processInboundRelayEvent( - try makeAppKeysEvent(owner: owner, devices: [device]) - ) - - #expect(deferredResponses.isEmpty) - #expect(!service.hasActiveSession(with: owner.publicKeyHex)) - #expect(relay.unsubscribedIDs.count == 1) - } - - @Test("Pending-invite cap eviction preserves the shared owner roster subscription") - @MainActor - func pendingInviteCap_sameOwnerKeepsRosterSubscription() throws { - let owner = try NostrIdentity.generate() - let recipient = try NostrIdentity.generate() - let relay = FakeRelayManager() - let service = NdrNostrService( - relayManager: relay, - deviceId: "pending-cap-recipient", - rolloutEnabled: true, - storageDirectoryProvider: { - try makeTempDir(label: "ndr-pending-cap-recipient") - } - ) - service.configureIfNeeded(identity: recipient) - - for index in 0...64 { - let device = try NostrIdentity.generate() - let manager = try makeChildManager( - identity: device, - owner: owner, - deviceID: "pending-cap-\(index)" + initialService.configureIfNeeded(identity: local) + try establishPairwiseSessions( + initialService, + peerService, + firstIdentity: local, + secondIdentity: peer, + firstRelay: initialRelay, + secondRelay: peerRelay ) #expect( - service.processOutOfBandEventJson( - try inviteEventJson(from: manager), - expectedPeerPubkeyHex: owner.publicKeyHex, - deferredResponseHandler: { _ in } - ).isEmpty + initialService.hasActiveSession( + with: peer.publicKeyHex + ) ) } - #expect( - relay.subscriptions.filter { - $0.filter.kinds == [37368] && $0.filter.authors == [owner.publicKeyHex] - }.count == 1 + let nostrKeychain = MockKeychain() + nostrKeychain.save( + key: "nostr-current-identity", + data: try JSONEncoder().encode(local), + service: "chat.bitchat.nostr", + accessible: nil ) - #expect(relay.unsubscribedIDs.isEmpty) - } + let favoritesService = FavoritesPersistenceService( + keychain: MockKeychain() + ) + let noiseKey = Data(repeating: 0xd4, count: 32) + installMutualFavorite( + in: favoritesService, + noiseKey: noiseKey, + nostrIdentity: peer + ) + #expect(!favoritesService.isNdrRequired(for: noiseKey)) - @Test("Validated decrypted messages wait for a consumer instead of being dropped") - @MainActor - func decryptedMessage_buffersUntilCallbackInstalled() throws { - let identity = try NostrIdentity.generate() - let inner = try makeInnerMessageEvent( - identity: identity, - content: "bitchat1:buffered" - ) - let service = NdrNostrService( + let restoredService = NdrNostrService( relayManager: FakeRelayManager(), - deviceId: "buffered-decrypted", rolloutEnabled: true, - storageDirectoryProvider: { try makeTempDir(label: "ndr-buffered-decrypted") } + storageDirectoryProvider: { localStorage }, + sessionMarkerStore: markerStore + ) + let (viewModel, _, _) = makeViewModel( + ndrService: restoredService, + nostrKeychain: nostrKeychain, + favoritesService: favoritesService ) - var received: [NdrDecryptedMessage] = [] - service.consumeDecryptedPubSubEvent( - makeDecryptedPubSubEvent( - inner, - authenticatedSender: identity.publicKeyHex + viewModel.setupNostrMessageHandling() + + #expect(favoritesService.isNdrRequired(for: noiseKey)) + #expect( + !favoritesService.canAcceptLegacyNostrDM( + from: peer.publicKeyHex + ) + ) + #expect( + restoredService.hasActiveSession( + with: peer.publicKeyHex ) ) - #expect(received.isEmpty) - - service.onDecryptedMessage = { received.append($0) } - #expect(received.map(\.event.id) == [inner.id]) } - @Test("Switching identities never reuses another owner's ratchet state") + @Test("A failed binding pin makes zero native OOB mutation") @MainActor - func identitySwitch_isolatesPersistedState() throws { - let storage = try makeTempDir(label: "ndr-identity-switch") - let firstIdentity = try NostrIdentity.generate() - let secondIdentity = try NostrIdentity.generate() - let service = NdrNostrService( + func bindingPinFailurePrecedesNativeInviteAcceptance() throws { + let local = try NostrIdentity.generate() + let peer = try NostrIdentity.generate() + let markerStore = InMemoryNdrSessionMarkerStore() + var nativeMutationCalls = 0 + let localService = NdrNostrService( relayManager: FakeRelayManager(), - deviceId: "identity-switch-device", rolloutEnabled: true, - storageDirectoryProvider: { storage } + storageDirectoryProvider: { + try makeTempDir(label: "pin-order-local") + }, + sessionMarkerStore: markerStore, + nativeOutOfBandMutationObserver: { + nativeMutationCalls += 1 + } + ) + let peerService = try makeService(label: "pin-order-peer") + localService.configureIfNeeded(identity: local) + peerService.configureIfNeeded(identity: peer) + let invite = try #require( + peerService.currentInviteEventJson() ) - service.configureIfNeeded(identity: firstIdentity) - let firstInviteJson = try #require(service.currentInviteEventJson()) - let firstInvite = try InviteHandle.fromEventJson(eventJson: firstInviteJson) - #expect(try firstInvite.getOwnerPubkeyHex() == firstIdentity.publicKeyHex) + let rejected = localService.processOutOfBandEventJson( + invite, + expectedPeerPubkeyHex: peer.publicKeyHex, + persistEstablishedBinding: { false } + ) - service.configureIfNeeded(identity: secondIdentity) - let secondInviteJson = try #require(service.currentInviteEventJson()) - let secondInvite = try InviteHandle.fromEventJson(eventJson: secondInviteJson) + #expect(rejected.isEmpty) + #expect(nativeMutationCalls == 0) + #expect( + !localService.hasPairwiseSession(with: peer.publicKeyHex) + ) + #expect( + try !markerStore.contains( + identityPubkeyHex: local.publicKeyHex + ) + ) - #expect(service.configuredPubkeyHex == secondIdentity.publicKeyHex) - #expect(try secondInvite.getOwnerPubkeyHex() == secondIdentity.publicKeyHex) + let accepted = localService.processOutOfBandEventJson( + invite, + expectedPeerPubkeyHex: peer.publicKeyHex, + persistEstablishedBinding: { true } + ) + + #expect(!accepted.isEmpty) + #expect(nativeMutationCalls == 1) + #expect( + localService.hasPairwiseSession(with: peer.publicKeyHex) + ) + #expect( + try markerStore.contains( + identityPubkeyHex: local.publicKeyHex + ) + ) } - @Test("Panic reset invalidates the session and deletes ratchet storage") + @Test("A failed marker precommit makes zero native OOB mutation") @MainActor - func panicReset_removesState() throws { + func establishedMarkerWriteFailureIsRetriableBeforeNativeResponse() + throws + { + let local = try NostrIdentity.generate() + let peer = try NostrIdentity.generate() + let localStorage = try makeTempDir(label: "marker-write-local") + let markerStore = ControllableNdrSessionMarkerStore() + markerStore.failMark = true + let localRelay = FakeRelayManager() + var nativeMutationCalls = 0 + let localService = NdrNostrService( + relayManager: localRelay, + rolloutEnabled: true, + storageDirectoryProvider: { localStorage }, + sessionMarkerStore: markerStore, + nativeOutOfBandMutationObserver: { + nativeMutationCalls += 1 + } + ) + let peerService = try makeService( + label: "marker-write-peer" + ) + #expect(localService.configureIfNeeded(identity: local)) + peerService.configureIfNeeded(identity: peer) + + let invite = try #require( + localService.currentInviteEventJson() + ) + let responses = peerService.processOutOfBandEventJson( + invite, + expectedPeerPubkeyHex: local.publicKeyHex, + persistEstablishedBinding: { true } + ) + let response = try #require( + handOff(responses, from: peerService).first + ) + let actions = localService.processOutOfBandEventJson( + response, + expectedPeerPubkeyHex: peer.publicKeyHex, + persistEstablishedBinding: { true } + ) + + #expect(actions.isEmpty) + #expect(nativeMutationCalls == 0) + #expect(localService.isConfigured) + #expect( + !localService.hasPairwiseSession(with: peer.publicKeyHex) + ) + #expect( + localService.send( + "blocked", + to: peer.publicKeyHex + ) == .noSession + ) + + markerStore.failMark = false + _ = localService.processOutOfBandEventJson( + response, + expectedPeerPubkeyHex: peer.publicKeyHex, + persistEstablishedBinding: { true } + ) + + #expect(nativeMutationCalls == 1) + #expect( + localService.hasPairwiseSession(with: peer.publicKeyHex) + ) + } + + @Test("An established marker rejects missing pairwise state on restart") + @MainActor + func establishedMarkerFailsClosedWhenStateDisappears() throws { + let markerStore = InMemoryNdrSessionMarkerStore() + let aliceStorage = try makeTempDir(label: "marker-missing-alice") + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() + + do { + let aliceRelay = FakeRelayManager() + let bobRelay = FakeRelayManager() + let aliceService = NdrNostrService( + relayManager: aliceRelay, + rolloutEnabled: true, + storageDirectoryProvider: { aliceStorage }, + sessionMarkerStore: markerStore + ) + let bobService = try makeService( + label: "marker-missing-bob", + relay: bobRelay + ) + aliceService.configureIfNeeded(identity: alice) + bobService.configureIfNeeded(identity: bob) + try establishPairwiseSessions( + aliceService, + bobService, + firstIdentity: alice, + secondIdentity: bob, + firstRelay: aliceRelay, + secondRelay: bobRelay + ) + #expect( + try markerStore.contains( + identityPubkeyHex: alice.publicKeyHex + ) + ) + } + + let identityStateDirectory = aliceStorage + .appendingPathComponent("pairwise-v1", isDirectory: true) + .appendingPathComponent( + alice.publicKeyHex, + isDirectory: true + ) + try FileManager.default.removeItem(at: identityStateDirectory) + + let restored = NdrNostrService( + relayManager: FakeRelayManager(), + rolloutEnabled: true, + storageDirectoryProvider: { aliceStorage }, + sessionMarkerStore: markerStore + ) + restored.configureIfNeeded(identity: alice) + + #expect(!restored.isConfigured) + #expect( + restored.send("blocked", to: bob.publicKeyHex) == .failed + ) + } + + @Test("An established marker rejects corrupt pairwise state on restart") + @MainActor + func establishedMarkerFailsClosedWhenStateIsCorrupt() throws { + let markerStore = InMemoryNdrSessionMarkerStore() + let aliceStorage = try makeTempDir(label: "marker-corrupt-alice") + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() + + do { + let aliceRelay = FakeRelayManager() + let bobRelay = FakeRelayManager() + let aliceService = NdrNostrService( + relayManager: aliceRelay, + rolloutEnabled: true, + storageDirectoryProvider: { aliceStorage }, + sessionMarkerStore: markerStore + ) + let bobService = try makeService( + label: "marker-corrupt-bob", + relay: bobRelay + ) + aliceService.configureIfNeeded(identity: alice) + bobService.configureIfNeeded(identity: bob) + try establishPairwiseSessions( + aliceService, + bobService, + firstIdentity: alice, + secondIdentity: bob, + firstRelay: aliceRelay, + secondRelay: bobRelay + ) + } + + let identityStateDirectory = aliceStorage + .appendingPathComponent("pairwise-v1", isDirectory: true) + .appendingPathComponent( + alice.publicKeyHex, + isDirectory: true + ) + let stateFile = try #require( + FileManager.default + .contentsOfDirectory( + at: identityStateDirectory, + includingPropertiesForKeys: nil + ) + .first { + $0.lastPathComponent.hasPrefix( + "ndr-pairwise-state-v1-" + ) + } + ) + try Data("corrupt".utf8).write(to: stateFile, options: .atomic) + + let restored = NdrNostrService( + relayManager: FakeRelayManager(), + rolloutEnabled: true, + storageDirectoryProvider: { aliceStorage }, + sessionMarkerStore: markerStore + ) + restored.configureIfNeeded(identity: alice) + + #expect(!restored.isConfigured) + #expect( + restored.send("blocked", to: bob.publicKeyHex) == .failed + ) + } + + @Test("Panic reset clears established state and its marker") + @MainActor + func panicResetClearsEstablishedMarker() throws { + let markerStore = InMemoryNdrSessionMarkerStore() + let aliceStorage = try makeTempDir(label: "marker-panic-alice") + let aliceRelay = FakeRelayManager() + let bobRelay = FakeRelayManager() + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() + let aliceService = NdrNostrService( + relayManager: aliceRelay, + rolloutEnabled: true, + storageDirectoryProvider: { aliceStorage }, + sessionMarkerStore: markerStore + ) + let bobService = try makeService( + label: "marker-panic-bob", + relay: bobRelay + ) + aliceService.configureIfNeeded(identity: alice) + bobService.configureIfNeeded(identity: bob) + try establishPairwiseSessions( + aliceService, + bobService, + firstIdentity: alice, + secondIdentity: bob, + firstRelay: aliceRelay, + secondRelay: bobRelay + ) + #expect( + try markerStore.contains( + identityPubkeyHex: alice.publicKeyHex + ) + ) + + try aliceService.resetForPanic() + + #expect( + try !markerStore.contains( + identityPubkeyHex: alice.publicKeyHex + ) + ) + #expect( + !FileManager.default.fileExists( + atPath: aliceStorage.path + ) + ) + } + + @Test("Panic reset invalidates callbacks and removes pairwise storage") + @MainActor + func panicResetRemovesState() throws { let relay = FakeRelayManager() - let storage = try makeTempDir(label: "ndr-panic") + let storage = try makeTempDir(label: "panic") let identity = try NostrIdentity.generate() - var clearedDeviceID = false let service = NdrNostrService( relayManager: relay, - deviceId: "panic-device", rolloutEnabled: true, - storageDirectoryProvider: { storage }, - clearPersistedDeviceId: { clearedDeviceID = true } + storageDirectoryProvider: { storage } ) service.configureIfNeeded(identity: identity) #expect(service.isConfigured) #expect(FileManager.default.fileExists(atPath: storage.path)) - let buffered = try makeInnerMessageEvent( - identity: identity, - content: "bitchat1:must-not-survive-panic" - ) - service.consumeDecryptedPubSubEvent( - makeDecryptedPubSubEvent( - buffered, - authenticatedSender: identity.publicKeyHex - ) - ) try service.resetForPanic() - var deliveredAfterPanic: [NdrDecryptedMessage] = [] - service.onDecryptedMessage = { deliveredAfterPanic.append($0) } #expect(!service.isConfigured) #expect(service.configuredPubkeyHex == nil) - #expect(clearedDeviceID) #expect(!FileManager.default.fileExists(atPath: storage.path)) - #expect(deliveredAfterPanic.isEmpty) } - @Test("NdrNostrService does not publish invite/response events to Nostr relays") + @Test("Only kind 1060 is exposed to Nostr relays") @MainActor - func ndrNostrService_doesNotPublishHandshakeEvents() throws { + func relaySurfaceIsOnlyKind1060() throws { let relay = FakeRelayManager() - let storage = try makeTempDir(label: "ndr-no-publish") + let service = try makeService(label: "relay-surface", relay: relay) let identity = try NostrIdentity.generate() - let svc = NdrNostrService( - relayManager: relay, - deviceId: "test-device", - rolloutEnabled: true, - storageDirectoryProvider: { storage } + service.configureIfNeeded(identity: identity) + + let invite = try #require(service.currentInviteEventJson()) + #expect(try extractNostrKind(json: invite) == 30078) + #expect(relay.sentEvents.allSatisfy { $0.kind == 1060 }) + #expect(relay.subscriptions.allSatisfy { $0.filter.kinds == [1060] }) + } + + @Test("Kind 1060 relay envelopes never expose recipient tags") + @MainActor + func publishValidationRejectsRecipientTaggedEnvelope() throws { + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let tagged = try signedKind1060Event( + identity: sender, + tags: [["p", recipient.publicKeyHex]] + ) + let tagless = try signedKind1060Event( + identity: sender, + tags: [] ) - svc.configureIfNeeded(identity: identity) - - let inviteJson = try #require(svc.currentInviteEventJson(), "Expected device invite to be cached") - #expect(try extractNostrKind(json: inviteJson) == 30078) - - // Service may publish other maintenance events, but not invites or giftwrap responses. - #expect(!relay.sentEvents.contains(where: { isDoubleRatchetInviteEvent($0) })) - #expect(!relay.sentEvents.contains(where: { $0.kind == 1059 })) - - // Service should not ask relays to subscribe to giftwrap responses (kind 1059) or invite discovery. - #expect(!relay.subscriptions.contains(where: { $0.filter.kinds?.contains(1059) == true })) - #expect(!relay.subscriptions.contains(where: { sub in - (sub.filter.kinds?.contains(30078) == true) && - (sub.filter.tagFilters?["l"]?.contains("double-ratchet/invites") == true) - })) + #expect( + NdrNostrService.validatedPublishAction( + makePublishAction(tagged) + ) == nil + ) + #expect( + NdrNostrService.validatedPublishAction( + makePublishAction(tagless) + )?.id == tagless.id + ) } - @Test("Out-of-band invite/response over BLE can establish a session and decrypt kind 1060 messages") + @Test("Two native managers handshake, send, decrypt, and restart") @MainActor - func oobHandshake_establishesSession_andDecrypts() throws { + func nativePairwiseFlowWorksEndToEnd() throws { + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() let aliceRelay = FakeRelayManager() let bobRelay = FakeRelayManager() - let aliceStorage = try makeTempDir(label: "ndr-alice") - let bobStorage = try makeTempDir(label: "ndr-bob") - - let aliceKeys = generateKeypair() - let bobKeys = generateKeypair() - let aliceIdentity = try NostrIdentity(privateKeyData: try #require(Data(hexString: aliceKeys.privateKeyHex))) - let bobIdentity = try NostrIdentity(privateKeyData: try #require(Data(hexString: bobKeys.privateKeyHex))) - - let aliceSvc = NdrNostrService( + let aliceStorage = try makeTempDir(label: "e2e-alice") + let bobStorage = try makeTempDir(label: "e2e-bob") + let aliceService = NdrNostrService( relayManager: aliceRelay, - deviceId: "alice-device", rolloutEnabled: true, storageDirectoryProvider: { aliceStorage } ) - let bobSvc = NdrNostrService( + let bobService = NdrNostrService( relayManager: bobRelay, - deviceId: "bob-device", rolloutEnabled: true, storageDirectoryProvider: { bobStorage } ) + aliceService.configureIfNeeded(identity: alice) + bobService.configureIfNeeded(identity: bob) - aliceSvc.configureIfNeeded(identity: aliceIdentity) - bobSvc.configureIfNeeded(identity: bobIdentity) + try establishPairwiseSessions( + aliceService, + bobService, + firstIdentity: alice, + secondIdentity: bob, + firstRelay: aliceRelay, + secondRelay: bobRelay + ) + #expect(aliceService.hasActiveSession(with: bob.publicKeyHex)) + #expect(bobService.hasActiveSession(with: alice.publicKeyHex)) + #expect(!aliceRelay.sentEvents.contains { $0.kind == 37368 }) + #expect(!bobRelay.sentEvents.contains { $0.kind == 37368 }) - // Exchange BOTH device invites out-of-band (mutual favorites) and bounce any resulting - // handshake events until both sides are quiescent. - let aliceInvite = try #require(aliceSvc.currentInviteEventJson()) - let bobInvite = try #require(bobSvc.currentInviteEventJson()) - var aToB: [String] = [aliceInvite] - var bToA: [String] = [bobInvite] - var sawResponse1059 = false - for _ in 0..<10 { - let nextBToA = aToB.flatMap { - bobSvc.processOutOfBandEventJson( - $0, - expectedPeerPubkeyHex: aliceIdentity.publicKeyHex - ) - } // Bob -> Alice - let nextAToB = bToA.flatMap { - aliceSvc.processOutOfBandEventJson( - $0, - expectedPeerPubkeyHex: bobIdentity.publicKeyHex - ) - } // Alice -> Bob - if nextBToA.contains(where: { (try? extractNostrKind(json: $0)) == 1059 }) { sawResponse1059 = true } - if nextAToB.contains(where: { (try? extractNostrKind(json: $0)) == 1059 }) { sawResponse1059 = true } - aToB = nextAToB - bToA = nextBToA - if aToB.isEmpty && bToA.isEmpty { break } - } - #expect(sawResponse1059) - - #expect(aliceSvc.hasActiveSession(with: bobIdentity.publicKeyHex)) - #expect(bobSvc.hasActiveSession(with: aliceIdentity.publicKeyHex)) - - // Now Alice can send via DR (kind 1060), which is published to Nostr relays. aliceRelay.resetSentEvents() - #expect(aliceSvc.sendIfPossible("bitchat1:hello", to: bobIdentity.publicKeyHex)) - let outbound = aliceRelay.sentEvents.filter { $0.kind == 1060 } - #expect(!outbound.isEmpty) + let outboundSend = aliceService.send( + "bitchat1:hello", + to: bob.publicKeyHex + ) + guard case .sent = outboundSend else { + Issue.record("Expected a pairwise send, got \(outboundSend)") + return + } + let outbound = try #require( + aliceRelay.sentEvents.first(where: { $0.kind == 1060 }) + ) + var decrypted: NostrEvent? + bobService.onDecryptedMessage = { message, completion in + decrypted = message.event + completion(.consumed) + } + bobService.processInboundRelayEvent(outbound) - var decryptedInner: NostrEvent? - bobSvc.onDecryptedMessage = { message in - decryptedInner = message.event + #expect(decrypted?.pubkey == alice.publicKeyHex) + #expect(decrypted?.content == "bitchat1:hello") + + let restored = NdrNostrService( + relayManager: FakeRelayManager(), + rolloutEnabled: true, + storageDirectoryProvider: { bobStorage } + ) + restored.configureIfNeeded(identity: bob) + #expect(restored.hasActiveSession(with: alice.publicKeyHex)) + } + + @Test("Absolute message expiry survives the pairwise round trip") + @MainActor + func outboundExpirationRoundTripsToDelivery() throws { + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() + let aliceRelay = FakeRelayManager() + let aliceService = try makeService( + label: "expiry-alice", + relay: aliceRelay + ) + let bobRelay = FakeRelayManager() + let bobService = try makeService( + label: "expiry-bob", + relay: bobRelay + ) + aliceService.configureIfNeeded(identity: alice) + bobService.configureIfNeeded(identity: bob) + try establishPairwiseSessions( + aliceService, + bobService, + firstIdentity: alice, + secondIdentity: bob, + firstRelay: aliceRelay, + secondRelay: bobRelay + ) + + aliceRelay.resetSentEvents() + let expiration: UInt64 = 4_000_000_000 + let expiringSend = aliceService.send( + "bitchat1:disappearing", + to: bob.publicKeyHex, + expiresAtSeconds: expiration + ) + guard case .sent = expiringSend else { + Issue.record("Expected a pairwise send, got \(expiringSend)") + return + } + let outbound = try #require( + aliceRelay.sentEvents.first { $0.kind == 1060 } + ) + var deliveredExpiration: UInt64? + bobService.onDecryptedMessage = { message, completion in + deliveredExpiration = message.expiresAtSeconds + completion(.consumed) } - for event in outbound { - bobSvc.processInboundRelayEvent(event) + bobService.processInboundRelayEvent(outbound) + + #expect(deliveredExpiration == expiration) + } + + @MainActor + private func establishPairwiseSessions( + _ firstService: NdrNostrService, + _ secondService: NdrNostrService, + firstIdentity: NostrIdentity, + secondIdentity: NostrIdentity, + firstRelay: FakeRelayManager, + secondRelay: FakeRelayManager + ) throws { + let firstRelayEventCount = firstRelay.sentEvents.count + let secondRelayEventCount = secondRelay.sentEvents.count + let firstInvite = try #require( + firstService.currentInviteEventJson() + ) + let secondInvite = try #require( + secondService.currentInviteEventJson() + ) + + // Simultaneous invite glare is intentionally exercised here. Complete + // the authenticated OOB responses before delivering either bootstrap + // publish; the native runtime deterministically rejects the losing + // response/bootstrap pair and converges on the winning session. + let generatedBySecond = secondService.processOutOfBandEventJson( + firstInvite, + expectedPeerPubkeyHex: firstIdentity.publicKeyHex, + persistEstablishedBinding: { true } + ) + let generatedByFirst = firstService.processOutOfBandEventJson( + secondInvite, + expectedPeerPubkeyHex: secondIdentity.publicKeyHex, + persistEstablishedBinding: { true } + ) + for response in handOff(generatedBySecond, from: secondService) { + _ = firstService.processOutOfBandEventJson( + response, + expectedPeerPubkeyHex: secondIdentity.publicKeyHex, + persistEstablishedBinding: { true } + ) + } + for response in handOff(generatedByFirst, from: firstService) { + _ = secondService.processOutOfBandEventJson( + response, + expectedPeerPubkeyHex: firstIdentity.publicKeyHex, + persistEstablishedBinding: { true } + ) } - let inner = try #require(decryptedInner, "Expected decrypted inner event to surface from SessionManagerHandle") - #expect(inner.pubkey.lowercased() == aliceIdentity.publicKeyHex.lowercased()) - #expect(inner.content == "bitchat1:hello") + for event in firstRelay.sentEvents + .dropFirst(firstRelayEventCount) + where event.kind == 1060 + { + secondService.processInboundRelayEvent(event) + } + for event in secondRelay.sentEvents + .dropFirst(secondRelayEventCount) + where event.kind == 1060 + { + firstService.processInboundRelayEvent(event) + } + + guard firstService.hasActiveSession( + with: secondIdentity.publicKeyHex + ), + secondService.hasActiveSession( + with: firstIdentity.publicKeyHex + ) + else { + Issue.record( + "Pairwise OOB handshake did not establish both sessions" + ) + return + } + } + + @MainActor + private func handOff( + _ actions: [NdrOutOfBandAction], + from service: NdrNostrService + ) -> [String] { + actions.map { action in + service.completeOutOfBandAction(action, succeeded: true) + return action.eventJson + } + } + + @MainActor + private func makeService( + label: String, + relay: FakeRelayManager? = nil, + sessionMarkerStore: NdrSessionMarkerStoring? = nil + ) throws -> NdrNostrService { + let storage = try makeTempDir(label: label) + return NdrNostrService( + relayManager: relay ?? FakeRelayManager(), + rolloutEnabled: true, + storageDirectoryProvider: { storage }, + sessionMarkerStore: + sessionMarkerStore ?? InMemoryNdrSessionMarkerStore() + ) + } + + @MainActor + private func makeViewModel( + ndrService: NdrNostrService, + nostrKeychain: KeychainManagerProtocol? = nil, + favoritesService: FavoritesPersistenceService? = nil + ) -> ( + ChatViewModel, + MockTransport, + FavoritesPersistenceService + ) { + let keychain = MockKeychain() + let identityManager = MockIdentityManager(keychain) + let transport = MockTransport() + let resolvedFavoritesService = + favoritesService + ?? FavoritesPersistenceService(keychain: MockKeychain()) + let resolvedNostrKeychain = + nostrKeychain ?? MockKeychainHelper() + let viewModel = ChatViewModel( + keychain: keychain, + idBridge: NostrIdentityBridge( + keychain: resolvedNostrKeychain + ), + identityManager: identityManager, + transport: transport, + ndrService: ndrService, + favoritesService: resolvedFavoritesService + ) + return (viewModel, transport, resolvedFavoritesService) + } + + @MainActor + private func installMutualFavorite( + in favoritesService: FavoritesPersistenceService, + noiseKey: Data, + nostrIdentity: NostrIdentity + ) { + favoritesService.addFavorite( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: nostrIdentity.npub, + peerNickname: "NDR test peer" + ) + favoritesService.updatePeerFavoritedUs( + peerNoisePublicKey: noiseKey, + favorited: true, + peerNostrPublicKey: nostrIdentity.npub + ) + } + + @MainActor + private func removeFavorite( + in favoritesService: FavoritesPersistenceService, + noiseKey: Data + ) { + favoritesService.updatePeerFavoritedUs( + peerNoisePublicKey: noiseKey, + favorited: false + ) + favoritesService.removeFavorite( + peerNoisePublicKey: noiseKey + ) } private func makeTempDir(label: String) throws -> URL { - let dir = FileManager.default.temporaryDirectory.appendingPathComponent( - "bitchat-tests-\(label)-\(UUID().uuidString)", - isDirectory: true + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "bitchat-tests-\(label)-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil ) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) - return dir - } - - private func isDoubleRatchetInviteEvent(_ event: NostrEvent) -> Bool { - guard event.kind == 30078 else { return false } - for tag in event.tags where tag.count >= 2 { - if tag[0] == "l", tag[1] == "double-ratchet/invites" { return true } - if tag[0] == "d", tag[1].hasPrefix("double-ratchet/invites/") { return true } - } - return false - } - - private func isInvitePublish(_ event: PubSubEvent) -> Bool { - guard event.kind == "publish_signed", - let json = event.eventJson, - (try? extractNostrKind(json: json)) == 30078, - let nostrEvent = try? JSONDecoder().decode( - NostrEvent.self, - from: Data(json.utf8) - ) else { - return false - } - return isDoubleRatchetInviteEvent(nostrEvent) + return directory } private func makeInnerMessageEvent( @@ -864,86 +2724,150 @@ struct NdrOutOfBandTransportTests { tags: [], content: content ) - return try event.sign(with: identity.schnorrSigningKey()) + var rumor = try event.sign(with: identity.schnorrSigningKey()) + rumor.sig = nil + return rumor } - private func makeDecryptedPubSubEvent( + private func makeDeliveryAction( _ event: NostrEvent, authenticatedSender: String, - conversationOwner: String? = nil - ) -> PubSubEvent { - PubSubEvent( - kind: "decrypted_message", - subid: nil, + innerEventID: String? = nil, + expiresAtSeconds: UInt64? = nil + ) -> PairwiseAction { + PairwiseAction( + actionId: "delivery-test", + kind: "delivery", + sessionId: nil, + subscriptionId: nil, filterJson: nil, eventJson: nil, - senderPubkeyHex: authenticatedSender, - senderDevicePubkeyHex: authenticatedSender, - conversationOwnerPubkeyHex: conversationOwner, - content: try? event.jsonString(), - eventId: String(repeating: "a", count: 64) + peerPubkeyHex: authenticatedSender, + innerEventJson: try? event.jsonString(), + innerEventId: innerEventID ?? event.id, + outerEventId: String(repeating: "a", count: 64), + expiresAtSeconds: expiresAtSeconds ) } - private func makeChildManager( + private func signedKind1060Event( identity: NostrIdentity, - owner: NostrIdentity, - deviceID: String - ) throws -> SessionManagerHandle { - let manager = try SessionManagerHandle( - ourPubkeyHex: identity.publicKeyHex, - ourIdentityPrivkeyHex: identity.privateKey.hexEncodedString(), - deviceId: deviceID, - ownerPubkeyHex: owner.publicKeyHex - ) - try manager.`init`() - return manager - } - - private func inviteEventJson(from manager: SessionManagerHandle) throws -> String { - try #require( - try manager.drainEvents() - .first(where: { isInvitePublish($0) })? - .eventJson - ) - } - - private func makeAppKeysEvent( - owner: NostrIdentity, - devices: [NostrIdentity], - timestamp: Int? = nil + tags: [[String]] ) throws -> NostrEvent { - let profileID = UUID().uuidString.lowercased() - let timestamp = timestamp ?? Int(Date().timeIntervalSince1970) - var tags: [[String]] = [ - ["d", profileID], - ["i", profileID, "subject"], - ["owner_pubkey", owner.publicKeyHex], - ["p", owner.publicKeyHex], - ["schema", "1"], - ["type", "app_keys_roster_snapshot"] - ] - for device in devices { - tags.append(["device", device.publicKeyHex, String(timestamp)]) - tags.append(["p", device.publicKeyHex]) - } - let event = try NostrEvent( + let unsigned = try NostrEvent( from: [ - "pubkey": owner.publicKeyHex, - "created_at": timestamp, - "kind": 37368, + "pubkey": identity.publicKeyHex, + "created_at": 1_750_000_000, + "kind": 1060, "tags": tags, - "content": "" + "content": "opaque-ratchet-envelope" ] ) - return try event.sign(with: owner.schnorrSigningKey()) + return try unsigned.sign(with: identity.schnorrSigningKey()) + } + + private func makePublishAction( + _ event: NostrEvent + ) -> PairwiseAction { + PairwiseAction( + actionId: "publish-test-\(event.id)", + kind: "publish", + sessionId: "session-test", + subscriptionId: nil, + filterJson: nil, + eventJson: try? event.jsonString(), + peerPubkeyHex: nil, + innerEventJson: nil, + innerEventId: nil, + outerEventId: event.id, + expiresAtSeconds: nil + ) + } + + private func seedMixedPendingActions( + outboundCount: Int, + deliveryCount: Int, + senderKeys: FfiKeyPair, + peerKeys: FfiKeyPair, + storagePath: String + ) throws { + let sender = try PairwiseManager.newWithStoragePath( + ourPubkeyHex: senderKeys.publicKeyHex, + ourIdentityPrivateKeyHex: senderKeys.privateKeyHex, + storagePath: storagePath + ) + let peer = try PairwiseManager.newWithStoragePath( + ourPubkeyHex: peerKeys.publicKeyHex, + ourIdentityPrivateKeyHex: peerKeys.privateKeyHex, + storagePath: "\(storagePath)-peer" + ) + _ = try sender.acceptInviteFromEventJson( + eventJson: peer.currentInviteEventJson(), + authenticatedPeerPubkeyHex: peerKeys.publicKeyHex + ) + let senderSetup = try sender.pendingActions() + let response = try #require( + senderSetup.first { $0.kind == "out_of_band" }?.eventJson + ) + let bootstrap = try #require( + senderSetup.first { action in + action.kind == "publish" + && action.innerEventId == nil + }?.eventJson + ) + try peer.processOutOfBandResponse( + eventJson: response, + authenticatedPeerPubkeyHex: senderKeys.publicKeyHex + ) + try peer.processEvent(eventJson: bootstrap) + // Seed only post-handshake relay/delivery work. A real host must ack + // the authenticated OOB response before its bootstrap can publish. + let senderSetupActionIDs = senderSetup.map(\.actionId) + try sender.ackActions(actionIds: senderSetupActionIDs) + let peerSetupActionIDs = try peer.pendingActions() + .filter { $0.kind != "delivery" } + .map(\.actionId) + try peer.ackActions(actionIds: peerSetupActionIDs) + + for index in 0.. Int { - let data = Data(json.utf8) - let obj = try JSONSerialization.jsonObject(with: data, options: []) - let dict = try #require(obj as? [String: Any], "Event should be a JSON object") - return try #require(dict["kind"] as? Int, "Event should have integer kind") + let object = try JSONSerialization.jsonObject( + with: Data(json.utf8), + options: [] + ) + let dictionary = try #require( + object as? [String: Any], + "Event should be a JSON object" + ) + return try #require( + dictionary["kind"] as? Int, + "Event should have an integer kind" + ) } - } diff --git a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift index 80ecf50d..f1b83bb7 100644 --- a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift +++ b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift @@ -403,14 +403,22 @@ struct PrivateMediaEndToEndTests { await alice._test_drainNoiseMessagePipeline() let tap = PacketTap() + let admission = ReceiptCapabilityRecorder() alice._test_onOutboundPacket = tap.record alice.sendNdrEvent( to: bob.myPeerID, eventJson: #"{"kind":1059}"#, - expectedTransportState: authenticatedState + expectedTransportState: authenticatedState, + completion: admission.record ) await alice._test_drainNoiseMessagePipeline() + #expect( + await TestHelpers.waitUntil( + { admission.snapshot() == [false] }, + timeout: TestConstants.longTimeout + ) + ) #expect( tap.snapshot().allSatisfy { $0.type != MessageType.noiseEncrypted.rawValue @@ -453,11 +461,13 @@ struct PrivateMediaEndToEndTests { #expect(authenticatedState.noisePublicKey == bob.noiseStaticPublicKeyData()) let tap = PacketTap() + let admission = ReceiptCapabilityRecorder() alice._test_onOutboundPacket = tap.record alice.sendNdrEvent( to: bob.myPeerID, eventJson: #"{"kind":1059}"#, - expectedTransportState: authenticatedState + expectedTransportState: authenticatedState, + completion: admission.record ) let sent = await TestHelpers.waitUntil( { @@ -470,6 +480,12 @@ struct PrivateMediaEndToEndTests { ) #expect(sent) + #expect( + await TestHelpers.waitUntil( + { admission.snapshot().count == 1 }, + timeout: TestConstants.longTimeout + ) + ) #expect( tap.snapshot().filter { $0.type == MessageType.noiseEncrypted.rawValue @@ -568,14 +584,22 @@ struct PrivateMediaEndToEndTests { await alice._test_drainNoiseMessagePipeline() let tap = PacketTap() + let admission = ReceiptCapabilityRecorder() alice._test_onOutboundPacket = tap.record alice.sendNdrEvent( to: bob.myPeerID, eventJson: #"{"kind":1059}"#, - expectedTransportState: oldState + expectedTransportState: oldState, + completion: admission.record ) await alice._test_drainNoiseMessagePipeline() + #expect( + await TestHelpers.waitUntil( + { admission.snapshot() == [false] }, + timeout: TestConstants.longTimeout + ) + ) #expect( tap.snapshot().allSatisfy { $0.type != MessageType.noiseEncrypted.rawValue diff --git a/bitchatTests/Mocks/MockKeychain.swift b/bitchatTests/Mocks/MockKeychain.swift index d5e99f1d..2bd38ca6 100644 --- a/bitchatTests/Mocks/MockKeychain.swift +++ b/bitchatTests/Mocks/MockKeychain.swift @@ -18,6 +18,8 @@ final class MockKeychain: KeychainManagerProtocol { var simulatedReadError: KeychainReadResult? var simulatedSaveError: KeychainSaveResult? var simulatedGenericReadError: KeychainReadResult? + var simulatedGenericSaveFailureKeys = Set() + var simulatedGenericDeleteFailureKeys = Set() var simulatedDeleteAllResult = true private(set) var deleteAllCallCount = 0 @@ -77,6 +79,9 @@ final class MockKeychain: KeychainManagerProtocol { // MARK: - Generic Data Storage (consolidated from KeychainHelper) func save(key: String, data: Data, service: String, accessible: CFString?) { + guard !simulatedGenericSaveFailureKeys.contains(key) else { + return + } if serviceStorage[service] == nil { serviceStorage[service] = [:] } @@ -98,6 +103,9 @@ final class MockKeychain: KeychainManagerProtocol { } func delete(key: String, service: String) { + guard !simulatedGenericDeleteFailureKeys.contains(key) else { + return + } serviceStorage[service]?.removeValue(forKey: key) } diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index 4620408f..792acc23 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -45,6 +45,13 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting { private(set) var protectedPrivateMediaRelativePaths: [Set] = [] private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] + private(set) var sentNdrEvents: [ + ( + peerID: PeerID, + eventJson: String, + expectedTransportState: AuthenticatedPeerTransportState + ) + ] = [] private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = [] private(set) var startServicesCallCount = 0 private(set) var stopServicesCallCount = 0 @@ -66,6 +73,10 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting { var peerNoiseStates: [PeerID: LazyHandshakeState] = [:] var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:] var privateMediaReceiptSessionGenerations: [PeerID: UUID] = [:] + var authenticatedPeerTransportStates: [ + PeerID: AuthenticatedPeerTransportState + ] = [:] + var ndrSendResults: [Bool] = [] var persistDeletedPrivateMediaResult = true var deferDeletedPrivateMediaPersistence = false private var pendingDeletedPrivateMediaCompletions: [ @@ -132,6 +143,32 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting { triggeredHandshakes.append(peerID) } + func authenticatedPeerTransportState( + _ peerID: PeerID + ) -> AuthenticatedPeerTransportState? { + authenticatedPeerTransportStates[peerID] + } + + func sendNdrEvent( + to peerID: PeerID, + eventJson: String, + expectedTransportState: AuthenticatedPeerTransportState, + completion: @escaping @MainActor (Bool) -> Void + ) { + sentNdrEvents.append( + ( + peerID: peerID, + eventJson: eventJson, + expectedTransportState: expectedTransportState + ) + ) + let succeeded = + ndrSendResults.isEmpty ? false : ndrSendResults.removeFirst() + Task { @MainActor in + completion(succeeded) + } + } + func purgeArchivedPublicMessages(from peerID: PeerID) { purgedArchivePeers.append(peerID) } diff --git a/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift b/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift index c58d5bf5..9e3db51e 100644 --- a/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift +++ b/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift @@ -5,6 +5,24 @@ import Testing @Suite("BLE outbound fragment planner tests") struct BLEOutboundFragmentPlannerTests { + @Test("exact-generation admission promotes fragments to FIFO-high priority") + func exactGenerationAdmissionUsesHighPriority() { + let ordinary = BLEOutboundWritePriority.fragment(totalFragments: 4) + + #expect( + BLEAuthenticatedTransportAdmission.writePriority( + ordinaryPriority: ordinary, + requiresExactGeneration: true + ) == .high + ) + #expect( + BLEAuthenticatedTransportAdmission.writePriority( + ordinaryPriority: ordinary, + requiresExactGeneration: false + ) == ordinary + ) + } + @Test("planner splits packets and preserves reassembled payload") func plannerSplitsAndReassemblesPacket() throws { let packet = makePacket(payload: makePayload(count: 384)) @@ -160,6 +178,68 @@ struct BLEOutboundFragmentPlannerTests { #expect(!BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at257)) } + @Test("Noise rotation stops exact-generation fragment admission") + func noiseRotationStopsStrictNdrFragmentTrain() { + let peer = PeerID(str: "8877665544332211") + let expected = AuthenticatedPeerTransportState( + capabilities: [.doubleRatchet], + sessionGeneration: UUID(), + noisePublicKey: Data(repeating: 0x42, count: 32) + ) + let rotated = AuthenticatedPeerTransportState( + capabilities: [.doubleRatchet], + sessionGeneration: UUID(), + noisePublicKey: expected.noisePublicKey + ) + let request = BLEOutboundFragmentTransferRequest( + packet: BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: Data(hexString: "0011223344556677") + ?? Data(), + recipientID: Data(hexString: peer.id), + timestamp: 0x0102030405, + payload: Data(repeating: 0x55, count: 384), + signature: nil, + ttl: 3 + ), + pad: false, + maxChunk: 128, + directedPeer: peer, + transferId: nil, + requireDirectPeerLink: true, + requireNoiseAuthenticatedPeerLink: true, + requiredAuthenticatedTransportState: expected + ) + var current: AuthenticatedPeerTransportState? = expected + var admitted: [Int] = [] + + let fullyAdmitted = BLEStrictFragmentAdmission.admitAll( + [0, 1, 2] + ) { index in + guard let carried = + request.requiredAuthenticatedTransportState, + BLEAuthenticatedTransportAdmission.isCurrent( + expected: carried, + current: current + ) + else { + return false + } + admitted.append(index) + current = rotated + return true + } + + #expect(!fullyAdmitted) + #expect(admitted == [0]) + #expect( + !BLEAuthenticatedTransportAdmission.isCurrent( + expected: expected, + current: rotated + ) + ) + } + private func makePacket( payload: Data, route: [Data]? = nil, diff --git a/bitchatTests/Services/BLEOutboundWriteBufferTests.swift b/bitchatTests/Services/BLEOutboundWriteBufferTests.swift index 801de777..1e507fe9 100644 --- a/bitchatTests/Services/BLEOutboundWriteBufferTests.swift +++ b/bitchatTests/Services/BLEOutboundWriteBufferTests.swift @@ -95,6 +95,46 @@ struct BLEOutboundWriteBufferTests { #expect(buffer.takeAll(for: peerID).compactMap(\.data.first) == [0x01]) } + @Test + func admittedStrictFramesSurviveLaterHighPriorityTraffic() { + var buffer = BLEOutboundWriteBuffer() + let peerID = "peer-1" + + let first = buffer.enqueueReportingAcceptance( + data: Data(repeating: 0x01, count: 8), + for: peerID, + priority: .high, + capBytes: 16 + ) + let second = buffer.enqueueReportingAcceptance( + data: Data(repeating: 0x02, count: 8), + for: peerID, + priority: .high, + capBytes: 16 + ) + let laterNormal = buffer.enqueueReportingAcceptance( + data: Data(repeating: 0x03, count: 8), + for: peerID, + priority: .fragment(totalFragments: 2), + capBytes: 16 + ) + let laterHigh = buffer.enqueueReportingAcceptance( + data: Data(repeating: 0x04, count: 8), + for: peerID, + priority: .high, + capBytes: 16 + ) + + #expect(first.accepted) + #expect(second.accepted) + #expect(!laterNormal.accepted) + #expect(!laterHigh.accepted) + #expect( + buffer.takeAll(for: peerID).compactMap(\.data.first) + == [0x01, 0x02] + ) + } + @Test func disconnectDiscardRemovesOnlyThatPeripheralQueue() { var buffer = BLEOutboundWriteBuffer() diff --git a/bitchatTests/Services/FavoritesPersistenceServiceTests.swift b/bitchatTests/Services/FavoritesPersistenceServiceTests.swift index 29eff020..50f68190 100644 --- a/bitchatTests/Services/FavoritesPersistenceServiceTests.swift +++ b/bitchatTests/Services/FavoritesPersistenceServiceTests.swift @@ -6,6 +6,10 @@ import BitFoundation final class FavoritesPersistenceServiceTests: XCTestCase { private let storageKey = "chat.bitchat.favorites" private let serviceKey = "chat.bitchat.favorites" + private let rebindJournalKey = + "chat.bitchat.favorites.ndr-rebind-journal" + private let ndrRequiredKey = + "chat.bitchat.favorites.ndr-required-noise-keys" func test_addFavorite_persistsAndPostsNotification() throws { let keychain = MockKeychain() @@ -110,4 +114,530 @@ final class FavoritesPersistenceServiceTests: XCTestCase { let decoded = try JSONDecoder().decode([FavoritesPersistenceService.FavoriteRelationship].self, from: cleaned) XCTAssertEqual(decoded.count, 1) } + + func test_preNdrIdentityRebindDoesNotPinOrRetire() { + let keychain = MockKeychain() + let service = FavoritesPersistenceService(keychain: keychain) + let peerKey = Data(repeating: 0x41, count: 32) + let owner = UUID() + var commitCalled = false + service.addFavorite( + peerNoisePublicKey: peerKey, + peerNostrPublicKey: "old", + peerNickname: "Pre-NDR" + ) + service.installNostrIdentityRebindAuthorization( + owner: owner, + required: true, + authorize: { _, _, _ in true }, + commit: { _, _, _ in + commitCalled = true + return true + } + ) + + service.updatePeerFavoritedUs( + peerNoisePublicKey: peerKey, + favorited: true, + peerNostrPublicKey: "new" + ) + + XCTAssertEqual( + service.getFavoriteStatus(for: peerKey)? + .peerNostrPublicKey, + "new" + ) + XCTAssertFalse(commitCalled) + XCTAssertFalse(service.isNdrRequired(for: peerKey)) + XCTAssertFalse( + service.isNdrFallbackBlocked( + for: PeerID(publicKey: peerKey) + ) + ) + } + + func test_postSessionRebindJournalsBeforeRetireAndPreservesPin() + throws + { + let keychain = MockKeychain() + let service = FavoritesPersistenceService(keychain: keychain) + let peerKey = Data(repeating: 0x42, count: 32) + service.addFavorite( + peerNoisePublicKey: peerKey, + peerNostrPublicKey: "old", + peerNickname: "Pinned" + ) + XCTAssertTrue(service.markNdrRequired(for: peerKey)) + var commitCalled = false + service.installNostrIdentityRebindAuthorization( + owner: UUID(), + required: true, + authorize: { _, _, _ in true }, + commit: { _, old, new in + commitCalled = true + XCTAssertEqual(old, "old") + XCTAssertEqual(new, "new") + XCTAssertNotNil( + keychain.load( + key: self.rebindJournalKey, + service: self.serviceKey + ) + ) + let stored = try? JSONDecoder().decode( + [FavoritesPersistenceService.FavoriteRelationship].self, + from: keychain.load( + key: self.storageKey, + service: self.serviceKey + ) ?? Data() + ) + XCTAssertEqual( + stored?.first?.peerNostrPublicKey, + "old" + ) + return true + } + ) + + service.updatePeerFavoritedUs( + peerNoisePublicKey: peerKey, + favorited: true, + peerNostrPublicKey: "new" + ) + + XCTAssertTrue(commitCalled) + XCTAssertEqual( + service.getFavoriteStatus(for: peerKey)? + .peerNostrPublicKey, + "new" + ) + XCTAssertTrue(service.isNdrRequired(for: peerKey)) + XCTAssertNil( + keychain.load( + key: rebindJournalKey, + service: serviceKey + ) + ) + } + + func test_failedFavoriteCommitKeepsJournalAndRecoversOnRestart() { + let keychain = MockKeychain() + let peerKey = Data(repeating: 0x43, count: 32) + let first = FavoritesPersistenceService(keychain: keychain) + first.addFavorite( + peerNoisePublicKey: peerKey, + peerNostrPublicKey: "old", + peerNickname: "Recoverable" + ) + XCTAssertTrue(first.markNdrRequired(for: peerKey)) + first.installNostrIdentityRebindAuthorization( + owner: UUID(), + required: true, + authorize: { _, _, _ in true }, + commit: { _, _, _ in true } + ) + keychain.simulatedGenericSaveFailureKeys.insert(storageKey) + + first.updatePeerFavoritedUs( + peerNoisePublicKey: peerKey, + favorited: true, + peerNostrPublicKey: "new" + ) + + XCTAssertEqual( + first.getFavoriteStatus(for: peerKey)? + .peerNostrPublicKey, + "old" + ) + XCTAssertNotNil( + keychain.load( + key: rebindJournalKey, + service: serviceKey + ) + ) + XCTAssertTrue( + first.isNdrFallbackBlocked( + for: PeerID(publicKey: peerKey) + ) + ) + + let restarted = FavoritesPersistenceService(keychain: keychain) + XCTAssertFalse( + restarted.canUseNdrBinding( + for: PeerID(publicKey: peerKey) + ) + ) + XCTAssertTrue( + restarted.isNdrFallbackBlocked( + for: PeerID(publicKey: peerKey) + ) + ) + + keychain.simulatedGenericSaveFailureKeys.remove(storageKey) + restarted.installNostrIdentityRebindAuthorization( + owner: UUID(), + required: true, + authorize: { _, _, _ in true }, + commit: { _, _, _ in true } + ) + + XCTAssertEqual( + restarted.getFavoriteStatus(for: peerKey)? + .peerNostrPublicKey, + "new" + ) + XCTAssertTrue(restarted.isNdrRequired(for: peerKey)) + XCTAssertNil( + keychain.load( + key: rebindJournalKey, + service: serviceKey + ) + ) + } + + func test_recoveryTreatsEquivalentHexAndNpubJournalIdentityAsSame() + throws + { + let keychain = MockKeychain() + let peerKey = Data(repeating: 0x4a, count: 32) + let oldIdentity = try NostrIdentity.generate() + let targetIdentity = try NostrIdentity.generate() + let first = FavoritesPersistenceService(keychain: keychain) + first.addFavorite( + peerNoisePublicKey: peerKey, + peerNostrPublicKey: oldIdentity.npub, + peerNickname: "Equivalent recovery" + ) + XCTAssertTrue(first.markNdrRequired(for: peerKey)) + first.installNostrIdentityRebindAuthorization( + owner: UUID(), + required: true, + authorize: { _, _, _ in true }, + commit: { _, _, _ in true } + ) + keychain.simulatedGenericSaveFailureKeys.insert(storageKey) + first.updatePeerFavoritedUs( + peerNoisePublicKey: peerKey, + favorited: true, + peerNostrPublicKey: targetIdentity.npub + ) + keychain.simulatedGenericSaveFailureKeys.remove(storageKey) + + let storedData = try XCTUnwrap( + keychain.load(key: storageKey, service: serviceKey) + ) + let storedRelationships = try JSONDecoder().decode( + [FavoritesPersistenceService.FavoriteRelationship].self, + from: storedData + ) + let stored = try XCTUnwrap(storedRelationships.first) + let equivalentOld = FavoritesPersistenceService + .FavoriteRelationship( + peerNoisePublicKey: stored.peerNoisePublicKey, + peerNostrPublicKey: + oldIdentity.publicKeyHex.uppercased(), + peerNickname: stored.peerNickname, + isFavorite: stored.isFavorite, + theyFavoritedUs: stored.theyFavoritedUs, + favoritedAt: stored.favoritedAt, + lastUpdated: stored.lastUpdated + ) + keychain.save( + key: storageKey, + data: try JSONEncoder().encode([equivalentOld]), + service: serviceKey, + accessible: nil + ) + + let restarted = FavoritesPersistenceService(keychain: keychain) + restarted.installNostrIdentityRebindAuthorization( + owner: UUID(), + required: true, + authorize: { _, _, _ in true }, + commit: { _, _, _ in true } + ) + + XCTAssertEqual( + restarted.getFavoriteStatus(for: peerKey)? + .peerNostrPublicKey, + targetIdentity.npub + ) + XCTAssertNil( + keychain.load( + key: rebindJournalKey, + service: serviceKey + ) + ) + } + + func test_equivalentHexAndNpubIdentityUpdateIsANondestructiveNoop() + throws + { + let keychain = MockKeychain() + let service = FavoritesPersistenceService(keychain: keychain) + let peerKey = Data(repeating: 0x44, count: 32) + let identity = try NostrIdentity.generate() + let storedHex = identity.publicKeyHex.uppercased() + service.addFavorite( + peerNoisePublicKey: peerKey, + peerNostrPublicKey: storedHex, + peerNickname: "Equivalent" + ) + XCTAssertTrue(service.markNdrRequired(for: peerKey)) + var authorizeCalled = false + var commitCalled = false + service.installNostrIdentityRebindAuthorization( + owner: UUID(), + required: true, + authorize: { _, _, _ in + authorizeCalled = true + return true + }, + commit: { _, _, _ in + commitCalled = true + return true + } + ) + + service.updatePeerFavoritedUs( + peerNoisePublicKey: peerKey, + favorited: true, + peerNostrPublicKey: identity.npub + ) + + XCTAssertEqual( + service.getFavoriteStatus(for: peerKey)? + .peerNostrPublicKey, + storedHex + ) + XCTAssertFalse(authorizeCalled) + XCTAssertFalse(commitCalled) + XCTAssertNil( + keychain.load( + key: rebindJournalKey, + service: serviceKey + ) + ) + } + + func test_pendingJournalReservesTargetAcrossFavoritesAndRestart() + throws + { + let keychain = MockKeychain() + let firstNoiseKey = Data(repeating: 0x45, count: 32) + let secondNoiseKey = Data(repeating: 0x46, count: 32) + let firstOld = try NostrIdentity.generate() + let secondOld = try NostrIdentity.generate() + let target = try NostrIdentity.generate() + let first = FavoritesPersistenceService(keychain: keychain) + first.addFavorite( + peerNoisePublicKey: firstNoiseKey, + peerNostrPublicKey: firstOld.npub, + peerNickname: "First" + ) + first.addFavorite( + peerNoisePublicKey: secondNoiseKey, + peerNostrPublicKey: secondOld.npub, + peerNickname: "Second" + ) + XCTAssertTrue(first.markNdrRequired(for: firstNoiseKey)) + first.installNostrIdentityRebindAuthorization( + owner: UUID(), + required: true, + authorize: { _, _, _ in true }, + commit: { _, _, _ in true } + ) + keychain.simulatedGenericSaveFailureKeys.insert(storageKey) + first.updatePeerFavoritedUs( + peerNoisePublicKey: firstNoiseKey, + favorited: true, + peerNostrPublicKey: target.npub + ) + XCTAssertNotNil( + keychain.load( + key: rebindJournalKey, + service: serviceKey + ) + ) + + keychain.simulatedGenericSaveFailureKeys.remove(storageKey) + first.updatePeerFavoritedUs( + peerNoisePublicKey: secondNoiseKey, + favorited: true, + peerNostrPublicKey: target.npub + ) + XCTAssertEqual( + first.getFavoriteStatus(for: secondNoiseKey)? + .peerNostrPublicKey, + secondOld.npub + ) + + let restarted = FavoritesPersistenceService(keychain: keychain) + restarted.installNostrIdentityRebindAuthorization( + owner: UUID(), + required: true, + authorize: { _, _, _ in true }, + commit: { _, _, _ in true } + ) + XCTAssertEqual( + restarted.getFavoriteStatus(for: firstNoiseKey)? + .peerNostrPublicKey, + target.npub + ) + XCTAssertEqual( + restarted.getFavoriteStatus(for: secondNoiseKey)? + .peerNostrPublicKey, + secondOld.npub + ) + XCTAssertNil( + keychain.load( + key: rebindJournalKey, + service: serviceKey + ) + ) + } + + func test_journalClearFailureAllowsCommittedTargetButBlocksFallback() + throws + { + let keychain = MockKeychain() + let service = FavoritesPersistenceService(keychain: keychain) + let peerKey = Data(repeating: 0x47, count: 32) + let oldIdentity = try NostrIdentity.generate() + let targetIdentity = try NostrIdentity.generate() + service.addFavorite( + peerNoisePublicKey: peerKey, + peerNostrPublicKey: oldIdentity.npub, + peerNickname: "Clear failure" + ) + XCTAssertTrue(service.markNdrRequired(for: peerKey)) + service.installNostrIdentityRebindAuthorization( + owner: UUID(), + required: true, + authorize: { _, _, _ in true }, + commit: { _, _, _ in true } + ) + keychain.simulatedGenericDeleteFailureKeys.insert( + rebindJournalKey + ) + + service.updatePeerFavoritedUs( + peerNoisePublicKey: peerKey, + favorited: true, + peerNostrPublicKey: targetIdentity.npub + ) + + XCTAssertEqual( + service.getFavoriteStatus(for: peerKey)? + .peerNostrPublicKey, + targetIdentity.npub + ) + XCTAssertNotNil( + keychain.load( + key: rebindJournalKey, + service: serviceKey + ) + ) + XCTAssertTrue( + service.canUseNdrBinding( + peerNoisePublicKey: peerKey, + peerNostrPublicKey: targetIdentity.npub + ) + ) + XCTAssertTrue( + service.canUseNdrBinding( + for: PeerID(publicKey: peerKey) + ) + ) + XCTAssertTrue(service.canActivateDoubleRatchetRelay) + XCTAssertTrue( + service.isNdrFallbackBlocked( + for: PeerID(publicKey: peerKey) + ) + ) + } + + func test_pinWriteFailureFailsClosedForBindingsAndLegacyInbound() + throws + { + let keychain = MockKeychain() + let service = FavoritesPersistenceService(keychain: keychain) + let peerKey = Data(repeating: 0x48, count: 32) + let identity = try NostrIdentity.generate() + service.addFavorite( + peerNoisePublicKey: peerKey, + peerNostrPublicKey: identity.npub, + peerNickname: "Pin failure" + ) + keychain.simulatedGenericSaveFailureKeys.insert(ndrRequiredKey) + + XCTAssertFalse(service.markNdrRequired(for: peerKey)) + XCTAssertNil( + keychain.load( + key: ndrRequiredKey, + service: serviceKey + ) + ) + XCTAssertTrue(service.isNdrRequired(for: peerKey)) + XCTAssertFalse( + service.canUseNdrBinding( + for: PeerID(publicKey: peerKey) + ) + ) + XCTAssertFalse(service.canActivateDoubleRatchetRelay) + XCTAssertFalse( + service.canAcceptLegacyNostrDM( + from: identity.publicKeyHex + ) + ) + } + + func test_legacyInboundPolicyRejectsPinnedAndJournalIdentities() + throws + { + let keychain = MockKeychain() + let service = FavoritesPersistenceService(keychain: keychain) + let peerKey = Data(repeating: 0x49, count: 32) + let oldIdentity = try NostrIdentity.generate() + let targetIdentity = try NostrIdentity.generate() + let unrelatedIdentity = try NostrIdentity.generate() + service.addFavorite( + peerNoisePublicKey: peerKey, + peerNostrPublicKey: oldIdentity.npub, + peerNickname: "Inbound" + ) + XCTAssertTrue(service.markNdrRequired(for: peerKey)) + XCTAssertFalse( + service.canAcceptLegacyNostrDM( + from: oldIdentity.publicKeyHex + ) + ) + XCTAssertTrue( + service.canAcceptLegacyNostrDM( + from: unrelatedIdentity.publicKeyHex + ) + ) + service.installNostrIdentityRebindAuthorization( + owner: UUID(), + required: true, + authorize: { _, _, _ in true }, + commit: { _, _, _ in true } + ) + keychain.simulatedGenericSaveFailureKeys.insert(storageKey) + service.updatePeerFavoritedUs( + peerNoisePublicKey: peerKey, + favorited: true, + peerNostrPublicKey: targetIdentity.npub + ) + + XCTAssertFalse( + service.canAcceptLegacyNostrDM( + from: oldIdentity.publicKeyHex + ) + ) + XCTAssertFalse( + service.canAcceptLegacyNostrDM( + from: targetIdentity.publicKeyHex + ) + ) + } } diff --git a/bitchatTests/Services/NostrRelayManagerTests.swift b/bitchatTests/Services/NostrRelayManagerTests.swift index e6c79201..379b3045 100644 --- a/bitchatTests/Services/NostrRelayManagerTests.swift +++ b/bitchatTests/Services/NostrRelayManagerTests.swift @@ -151,6 +151,117 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertTrue(connected) } + func test_subscribe_offlineRegistersReplayIntentAndFlushesWhenOnline() async { + let relayURL = "wss://offline-subscribe.example" + let context = makeContext( + permission: .denied, + userTorEnabled: true, + torEnforced: true, + torIsReady: false + ) + + let registered = context.manager.subscribe( + filter: makeFilter(), + id: "offline-sub", + relayUrls: [relayURL], + handler: { _ in } + ) + + XCTAssertTrue( + registered, + "offline success means the replayable request was registered" + ) + XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) + + context.torWaiter.resolve(true) + + let flushed = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)? + .sentStrings.contains { + $0.contains("offline-sub") + } == true + } + XCTAssertTrue(flushed) + } + + func test_ndrHandshakeWhileActivationBlockedRegistersAfterConnectivityWake() + throws + { + let context = makeContext( + permission: .authorized, + activationAllowed: false + ) + let localIdentity = try NostrIdentity.generate() + let remoteIdentity = try NostrIdentity.generate() + let localStorage = FileManager.default.temporaryDirectory + .appendingPathComponent( + "ndr-activation-local-\(UUID().uuidString)", + isDirectory: true + ) + let remoteStorage = FileManager.default.temporaryDirectory + .appendingPathComponent( + "ndr-activation-remote-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: localStorage, + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: remoteStorage, + withIntermediateDirectories: true + ) + defer { + try? FileManager.default.removeItem(at: localStorage) + try? FileManager.default.removeItem(at: remoteStorage) + } + + var scheduledNdrRetries: [@MainActor () -> Void] = [] + let service = NdrNostrService( + relayManager: context.manager, + rolloutEnabled: true, + storageDirectoryProvider: { localStorage }, + retryScheduler: { _, operation in + scheduledNdrRetries.append(operation) + } + ) + let remote = NdrNostrService( + relayManager: FakeRelayManager(), + rolloutEnabled: true, + storageDirectoryProvider: { remoteStorage } + ) + service.configureIfNeeded(identity: localIdentity) + remote.configureIfNeeded(identity: remoteIdentity) + + let responseActions = service.processOutOfBandEventJson( + try XCTUnwrap(remote.currentInviteEventJson()), + expectedPeerPubkeyHex: remoteIdentity.publicKeyHex, + persistEstablishedBinding: { true } + ) + XCTAssertFalse(responseActions.isEmpty) + for action in responseActions { + service.completeOutOfBandAction(action, succeeded: true) + } + while !scheduledNdrRetries.isEmpty { + scheduledNdrRetries.removeFirst()() + } + + XCTAssertEqual( + context.manager.debugSubscriptionRequestCount, + 0, + "activation policy must reject registration, not falsely ack it" + ) + + context.activationAllowed.value = true + service.retryRelayActions() + + XCTAssertGreaterThan( + context.manager.debugSubscriptionRequestCount, + 0, + "the connectivity wake must register the durable native intent" + ) + } + func test_subscribe_unblocksDeferredEOSEWhenTorWaitAttemptsExhausted() async { let relayURL = "wss://tor-eose-unblock.example" let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) @@ -469,6 +580,48 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertEqual(results, [true]) } + func test_sendEventImmediately_duplicateOKCountsAsDurableAcceptanceOnlyForExactPrefix() async throws { + let relay = "wss://confirmed-duplicate.example" + let context = makeContext(permission: .denied) + context.manager.ensureConnections(to: [relay]) + let connected = await waitUntil { + context.manager.relays.first(where: { $0.url == relay })? + .isConnected == true + } + XCTAssertTrue(connected) + + let duplicate = try makeSignedEvent(content: "duplicate") + var results: [Bool] = [] + context.manager.sendEventImmediately( + duplicate, + to: [relay] + ) { results.append($0) } + try context.sessionFactory.latestConnection(for: relay)?.emitOK( + eventID: duplicate.id, + success: false, + reason: "duplicate: already stored" + ) + let duplicateSettled = await waitUntil { results.count == 1 } + XCTAssertTrue(duplicateSettled) + XCTAssertEqual(results, [true]) + + let notMachineReadable = try makeSignedEvent( + content: "not an exact duplicate prefix" + ) + context.manager.sendEventImmediately( + notMachineReadable, + to: [relay] + ) { results.append($0) } + try context.sessionFactory.latestConnection(for: relay)?.emitOK( + eventID: notMachineReadable.id, + success: false, + reason: " duplicate: leading whitespace" + ) + let rejectionSettled = await waitUntil { results.count == 2 } + XCTAssertTrue(rejectionSettled) + XCTAssertEqual(results, [true, false]) + } + func test_sendEventImmediately_timeoutFailsAndIgnoresLateWriteAndOK() async throws { let relay = "wss://confirmed-timeout.example" let context = makeContext(permission: .denied) diff --git a/bitchatTests/Services/NostrTransportTests.swift b/bitchatTests/Services/NostrTransportTests.swift index 2148ed61..12fe9a3c 100644 --- a/bitchatTests/Services/NostrTransportTests.swift +++ b/bitchatTests/Services/NostrTransportTests.swift @@ -195,19 +195,24 @@ struct NostrTransportTests { let recipientStorage = try makeTempDir(label: "transport-ndr-recipient") let senderNdr = NdrNostrService( relayManager: senderRelay, - deviceId: "transport-ndr-sender", rolloutEnabled: true, storageDirectoryProvider: { senderStorage } ) let recipientNdr = NdrNostrService( relayManager: recipientRelay, - deviceId: "transport-ndr-recipient", rolloutEnabled: true, storageDirectoryProvider: { recipientStorage } ) senderNdr.configureIfNeeded(identity: sender) recipientNdr.configureIfNeeded(identity: recipient) - try establishMutualSession(senderNdr, recipientNdr, senderIdentity: sender, recipientIdentity: recipient) + try establishMutualSession( + senderNdr, + recipientNdr, + senderIdentity: sender, + recipientIdentity: recipient, + senderRelay: senderRelay, + recipientRelay: recipientRelay + ) let noiseKey = Data((64..<96).map(UInt8.init)) let fullPeerID = PeerID(hexData: noiseKey) @@ -239,9 +244,205 @@ struct NostrTransportTests { #expect(senderRelay.sentEvents.contains(where: { $0.kind == 1060 })) } - @Test("Private message waits for an owner-verified roster before preferring NDR") + @Test("Disappearing-message expiry reaches the pairwise delivery") @MainActor - func sendPrivateMessageRequiresVerifiedNdrRoster() throws { + func disappearingMessageForwardsAbsoluteExpiryToNdr() throws { + let keychain = MockKeychain() + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let senderRelay = FakeRelayManager() + let recipientRelay = FakeRelayManager() + let senderStorage = try makeTempDir( + label: "transport-expiry-sender" + ) + let recipientStorage = try makeTempDir( + label: "transport-expiry-recipient" + ) + let senderNdr = NdrNostrService( + relayManager: senderRelay, + rolloutEnabled: true, + storageDirectoryProvider: { senderStorage } + ) + let recipientNdr = NdrNostrService( + relayManager: recipientRelay, + rolloutEnabled: true, + storageDirectoryProvider: { recipientStorage } + ) + senderNdr.configureIfNeeded(identity: sender) + recipientNdr.configureIfNeeded(identity: recipient) + try establishMutualSession( + senderNdr, + recipientNdr, + senderIdentity: sender, + recipientIdentity: recipient, + senderRelay: senderRelay, + recipientRelay: recipientRelay + ) + senderRelay.resetSentEvents() + + let noiseKey = Data((72..<104).map(UInt8.init)) + let peerID = PeerID(hexData: noiseKey) + let relationship = makeRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: recipient.npub, + peerNickname: "Expires" + ) + let transport = NostrTransport( + keychain: keychain, + idBridge: NostrIdentityBridge(keychain: keychain), + ndrService: senderNdr, + dependencies: makeDependencies( + favoriteStatusForNoiseKey: { + $0 == noiseKey ? relationship : nil + }, + favoriteStatusForPeerID: { _ in nil }, + currentIdentity: { sender } + ) + ) + transport.senderPeerID = PeerID(str: "0123456789abcdef") + let expiration: UInt64 = 4_000_000_000 + + let used = try transport.sendPrivateMessageAndReturnTransport( + "vanish later", + to: peerID, + recipientNickname: "Expires", + messageID: "pm-expiring", + expiresAtSeconds: expiration + ) + let outbound = try #require( + senderRelay.sentEvents.first { $0.kind == 1060 } + ) + var deliveredExpiration: UInt64? + recipientNdr.onDecryptedMessage = { message, completion in + deliveredExpiration = message.expiresAtSeconds + completion(.consumed) + } + recipientNdr.processInboundRelayEvent(outbound) + + #expect(used == .ndr) + #expect(deliveredExpiration == expiration) + } + + @Test("A disappearing message never downgrades to kind 1059") + @MainActor + func disappearingMessageWithoutSessionFailsClosed() throws { + let keychain = MockKeychain() + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let relay = FakeRelayManager() + let ndrService = NdrNostrService( + relayManager: relay, + rolloutEnabled: true, + storageDirectoryProvider: { + try makeTempDir(label: "transport-expiry-no-session") + } + ) + let noiseKey = Data((104..<136).map(UInt8.init)) + let peerID = PeerID(hexData: noiseKey) + let relationship = makeRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: recipient.npub, + peerNickname: "No Session" + ) + let probe = NostrTransportProbe() + let transport = NostrTransport( + keychain: keychain, + idBridge: NostrIdentityBridge(keychain: keychain), + ndrService: ndrService, + dependencies: makeDependencies( + favoriteStatusForNoiseKey: { + $0 == noiseKey ? relationship : nil + }, + favoriteStatusForPeerID: { _ in nil }, + currentIdentity: { sender }, + sendEvent: probe.record(event:) + ) + ) + transport.senderPeerID = PeerID(str: "0123456789abcdef") + + do { + _ = try transport.sendPrivateMessageAndReturnTransport( + "must not become legacy", + to: peerID, + recipientNickname: "No Session", + messageID: "pm-expiry-no-session", + expiresAtSeconds: 4_000_000_000 + ) + Issue.record( + "Expected an expiring send without NDR to fail closed" + ) + } catch let error as NostrTransport.OutboundPrivateMessageError { + guard case .expiringMessageRequiresNdrSession = error else { + Issue.record("Unexpected transport error: \(error)") + return + } + } + + #expect(probe.sentEvents.isEmpty) + #expect(relay.sentEvents.isEmpty) + } + + @Test("A durable NDR pin blocks legacy fallback with rollout off") + @MainActor + func durableNdrPinBlocksGateOffFallback() throws { + let keychain = MockKeychain() + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let relay = FakeRelayManager() + let ndrService = NdrNostrService( + relayManager: relay, + rolloutEnabled: false, + storageDirectoryProvider: { + try makeTempDir(label: "transport-gate-off-pin") + } + ) + let noiseKey = Data(repeating: 0x7d, count: 32) + let peerID = PeerID(hexData: noiseKey) + let relationship = makeRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: recipient.npub, + peerNickname: "Pinned" + ) + let probe = NostrTransportProbe() + let transport = NostrTransport( + keychain: keychain, + idBridge: NostrIdentityBridge(keychain: keychain), + ndrService: ndrService, + dependencies: makeDependencies( + favoriteStatusForNoiseKey: { + $0 == noiseKey ? relationship : nil + }, + canUseNdrBindingForPeerID: { _ in true }, + isNdrFallbackBlockedForPeerID: { + $0.toShort() == peerID.toShort() + }, + currentIdentity: { sender }, + sendEvent: probe.record(event:) + ) + ) + + do { + _ = try transport.sendPrivateMessageAndReturnTransport( + "must remain pairwise", + to: peerID, + recipientNickname: "Pinned", + messageID: "pm-gate-off-pin" + ) + Issue.record("Expected a pinned gate-off send to fail closed") + } catch let error as NostrTransport.OutboundPrivateMessageError { + guard case .ndrSessionFailure = error else { + Issue.record("Unexpected transport error: \(error)") + return + } + } + + #expect(probe.sentEvents.isEmpty) + #expect(relay.sentEvents.isEmpty) + } + + @Test("A pairwise session is send-ready without a device roster") + @MainActor + func sendPrivateMessageDoesNotRequireDeviceRoster() throws { let keychain = MockKeychain() let idBridge = NostrIdentityBridge(keychain: keychain) let sender = try NostrIdentity.generate() @@ -252,41 +453,28 @@ struct NostrTransportTests { let recipientStorage = try makeTempDir(label: "transport-ndr-queued-recipient") let senderNdr = NdrNostrService( relayManager: senderRelay, - deviceId: "transport-ndr-queued-sender", rolloutEnabled: true, storageDirectoryProvider: { senderStorage } ) let recipientNdr = NdrNostrService( relayManager: recipientRelay, - deviceId: "transport-ndr-queued-recipient", rolloutEnabled: true, storageDirectoryProvider: { recipientStorage } ) senderNdr.configureIfNeeded(identity: sender) recipientNdr.configureIfNeeded(identity: recipient) - - let senderInvite = try #require(senderNdr.currentInviteEventJson()) - let recipientPublishes = recipientNdr.processOutOfBandEventJson( - senderInvite, - expectedPeerPubkeyHex: sender.publicKeyHex + try establishMutualSession( + senderNdr, + recipientNdr, + senderIdentity: sender, + recipientIdentity: recipient, + senderRelay: senderRelay, + recipientRelay: recipientRelay ) - let recipientResponse = try #require( - recipientPublishes.first(where: { (try? extractNostrKind(json: $0)) == 1059 }), - "Recipient should return a response after processing sender invite" - ) - let recipientBootstrap = try #require( - recipientRelay.sentEvents.first(where: { $0.kind == 1060 }), - "Recipient should publish a bootstrap message event after accepting sender invite" - ) - let recipientAppKeys = try #require( - recipientRelay.sentEvents.first(where: { isAppKeysEvent($0) }), - "Recipient should publish an AppKeys roster event" - ) - _ = senderNdr.processOutOfBandEventJson( - recipientResponse, - expectedPeerPubkeyHex: recipient.publicKeyHex - ) - #expect(senderNdr.hasActiveSession(with: recipient.publicKeyHex)) + #expect(!senderRelay.sentEvents.contains { $0.kind == 37368 }) + #expect(!recipientRelay.sentEvents.contains { $0.kind == 37368 }) + #expect(!senderRelay.subscriptions.contains { $0.filter.kinds?.contains(37368) == true }) + #expect(!recipientRelay.subscriptions.contains { $0.filter.kinds?.contains(37368) == true }) senderRelay.resetSentEvents() let probe = NostrTransportProbe() @@ -310,33 +498,163 @@ struct NostrTransportTests { ) transport.senderPeerID = PeerID(str: "0123456789abcdef") - let transportBeforeRoster = try transport.sendPrivateMessageAndReturnTransport( - "before verified roster", + let transportUsed = try transport.sendPrivateMessageAndReturnTransport( + "pairwise ndr", to: fullPeerID, recipientNickname: "Queued", - messageID: "pm-before-ndr-roster" + messageID: "pm-pairwise-ndr" ) - #expect(transportBeforeRoster == .legacy1059) - #expect(probe.sentEvents.count == 1) - #expect(probe.sentEvents.first?.kind == 1059) - #expect(senderRelay.sentEvents.filter { $0.kind == 1060 }.isEmpty) + #expect(transportUsed == .ndr) + #expect(probe.sentEvents.isEmpty) + #expect(senderRelay.sentEvents.filter { $0.kind == 1060 }.count == 1) + } - senderNdr.processInboundRelayEvent(recipientBootstrap) - #expect(senderRelay.sentEvents.filter { $0.kind == 1060 }.isEmpty) + @Test("An existing ratchet session never downgrades to legacy encryption") + @MainActor + func activeButNotSendReadySessionFailsClosed() throws { + let keychain = MockKeychain() + let idBridge = NostrIdentityBridge(keychain: keychain) + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let senderRelay = FakeRelayManager() + let recipientRelay = FakeRelayManager() + let senderNdr = NdrNostrService( + relayManager: senderRelay, + rolloutEnabled: true, + storageDirectoryProvider: { + try makeTempDir(label: "fail-closed-sender") + } + ) + let recipientNdr = NdrNostrService( + relayManager: recipientRelay, + rolloutEnabled: true, + storageDirectoryProvider: { + try makeTempDir(label: "fail-closed-recipient") + } + ) + senderNdr.configureIfNeeded(identity: sender) + recipientNdr.configureIfNeeded(identity: recipient) - senderNdr.processInboundRelayEvent(recipientAppKeys) - - let transportAfterRoster = try transport.sendPrivateMessageAndReturnTransport( - "after verified roster", - to: fullPeerID, - recipientNickname: "Queued", - messageID: "pm-after-ndr-roster" + // Processing the response installs a receive path. Until its relay + // bootstrap arrives, this is intentionally not a send-ready session. + let senderInvite = try #require(senderNdr.currentInviteEventJson()) + let response = try #require( + recipientNdr.processOutOfBandEventJson( + senderInvite, + expectedPeerPubkeyHex: sender.publicKeyHex, + persistEstablishedBinding: { true } + ).first + ) + recipientNdr.completeOutOfBandAction(response, succeeded: true) + _ = senderNdr.processOutOfBandEventJson( + response.eventJson, + expectedPeerPubkeyHex: recipient.publicKeyHex, + persistEstablishedBinding: { true } + ) + #expect( + senderNdr.hasPairwiseSession(with: recipient.publicKeyHex) ) - #expect(transportAfterRoster == .ndr) - #expect(probe.sentEvents.count == 1) - #expect(senderRelay.sentEvents.contains(where: { $0.kind == 1060 })) + let noiseKey = Data((88..<120).map(UInt8.init)) + let peerID = PeerID(hexData: noiseKey) + let relationship = makeRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: recipient.npub, + peerNickname: "Fail Closed" + ) + let probe = NostrTransportProbe() + let transport = NostrTransport( + keychain: keychain, + idBridge: idBridge, + ndrService: senderNdr, + dependencies: makeDependencies( + favoriteStatusForNoiseKey: { + $0 == noiseKey ? relationship : nil + }, + favoriteStatusForPeerID: { _ in nil }, + currentIdentity: { sender }, + sendEvent: probe.record(event:) + ) + ) + transport.senderPeerID = PeerID(str: "0123456789abcdef") + + do { + _ = try transport.sendPrivateMessageAndReturnTransport( + "must not downgrade", + to: peerID, + recipientNickname: "Fail Closed", + messageID: "pm-fail-closed" + ) + Issue.record("Expected the non-send-ready NDR session to fail") + } catch let error as NostrTransport.OutboundPrivateMessageError { + guard case .ndrSessionFailure = error else { + Issue.record("Unexpected transport error: \(error)") + return + } + } + + #expect(probe.sentEvents.isEmpty) + #expect(senderRelay.sentEvents.allSatisfy { $0.kind == 1060 }) + } + + @Test("An NDR storage-open failure never falls back to legacy encryption") + @MainActor + func ndrConfigurationFailureFailsClosed() throws { + let keychain = MockKeychain() + let idBridge = NostrIdentityBridge(keychain: keychain) + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let relay = FakeRelayManager() + let ndrService = NdrNostrService( + relayManager: relay, + rolloutEnabled: true, + storageDirectoryProvider: { + throw NostrTransportTestError.storageUnavailable + } + ) + let noiseKey = Data((96..<128).map(UInt8.init)) + let peerID = PeerID(hexData: noiseKey) + let relationship = makeRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: recipient.npub, + peerNickname: "Corrupt State" + ) + let probe = NostrTransportProbe() + let transport = NostrTransport( + keychain: keychain, + idBridge: idBridge, + ndrService: ndrService, + dependencies: makeDependencies( + favoriteStatusForNoiseKey: { + $0 == noiseKey ? relationship : nil + }, + favoriteStatusForPeerID: { _ in nil }, + currentIdentity: { sender }, + sendEvent: probe.record(event:) + ) + ) + transport.senderPeerID = PeerID(str: "0123456789abcdef") + + for _ in 0..<2 { + do { + _ = try transport.sendPrivateMessageAndReturnTransport( + "must remain failed", + to: peerID, + recipientNickname: "Corrupt State", + messageID: UUID().uuidString + ) + Issue.record("Expected NDR configuration failure") + } catch let error as NostrTransport.OutboundPrivateMessageError { + guard case .ndrSessionFailure = error else { + Issue.record("Unexpected transport error: \(error)") + return + } + } + } + + #expect(probe.sentEvents.isEmpty) + #expect(relay.sentEvents.isEmpty) } @Test("Favorite notification embeds current npub") @@ -591,6 +909,8 @@ struct NostrTransportTests { loadFavorites: @escaping @MainActor () -> [Data: FavoriteRelationship] = { [:] }, favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoriteRelationship? = { _ in nil }, favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil }, + canUseNdrBindingForPeerID: @escaping @MainActor (PeerID) -> Bool = { _ in true }, + isNdrFallbackBlockedForPeerID: @escaping @MainActor (PeerID) -> Bool = { _ in false }, currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil }, registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in }, sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in }, @@ -602,6 +922,10 @@ struct NostrTransportTests { loadFavorites: loadFavorites, favoriteStatusForNoiseKey: favoriteStatusForNoiseKey, favoriteStatusForPeerID: favoriteStatusForPeerID, + canUseNdrBindingForPeerID: + canUseNdrBindingForPeerID, + isNdrFallbackBlockedForPeerID: + isNdrFallbackBlockedForPeerID, currentIdentity: currentIdentity, registerPendingGiftWrap: registerPendingGiftWrap, sendEvent: sendEvent, @@ -615,7 +939,6 @@ struct NostrTransportTests { let storage = try makeTempDir(label: label) return NdrNostrService( relayManager: FakeRelayManager(), - deviceId: "nostr-transport-\(label)", rolloutEnabled: true, storageDirectoryProvider: { storage } ) @@ -651,36 +974,76 @@ struct NostrTransportTests { _ senderService: NdrNostrService, _ recipientService: NdrNostrService, senderIdentity: NostrIdentity, - recipientIdentity: NostrIdentity + recipientIdentity: NostrIdentity, + senderRelay: FakeRelayManager, + recipientRelay: FakeRelayManager ) throws { - var toRecipient: [String] = [try #require(senderService.currentInviteEventJson())] - var toSender: [String] = [try #require(recipientService.currentInviteEventJson())] + let senderRelayIndex = senderRelay.sentEvents.count + let recipientRelayIndex = recipientRelay.sentEvents.count + let senderInvite = try #require( + senderService.currentInviteEventJson() + ) + let recipientInvite = try #require( + recipientService.currentInviteEventJson() + ) + let generatedByRecipient = + recipientService.processOutOfBandEventJson( + senderInvite, + expectedPeerPubkeyHex: senderIdentity.publicKeyHex, + persistEstablishedBinding: { true } + ) + let generatedBySender = + senderService.processOutOfBandEventJson( + recipientInvite, + expectedPeerPubkeyHex: recipientIdentity.publicKeyHex, + persistEstablishedBinding: { true } + ) - for _ in 0..<10 { - let nextToSender = toRecipient.flatMap { - recipientService.processOutOfBandEventJson( - $0, - expectedPeerPubkeyHex: senderIdentity.publicKeyHex - ) - } - let nextToRecipient = toSender.flatMap { - senderService.processOutOfBandEventJson( - $0, - expectedPeerPubkeyHex: recipientIdentity.publicKeyHex - ) - } - toRecipient = nextToRecipient - toSender = nextToSender - if senderService.hasActiveSession(with: recipientIdentity.publicKeyHex), - recipientService.hasActiveSession(with: senderIdentity.publicKeyHex) { - return - } - if toRecipient.isEmpty && toSender.isEmpty { - break - } + for response in generatedByRecipient { + recipientService.completeOutOfBandAction( + response, + succeeded: true + ) + _ = senderService.processOutOfBandEventJson( + response.eventJson, + expectedPeerPubkeyHex: recipientIdentity.publicKeyHex, + persistEstablishedBinding: { true } + ) + } + for response in generatedBySender { + senderService.completeOutOfBandAction( + response, + succeeded: true + ) + _ = recipientService.processOutOfBandEventJson( + response.eventJson, + expectedPeerPubkeyHex: senderIdentity.publicKeyHex, + persistEstablishedBinding: { true } + ) } - throw NostrTransportTestError.failedToEstablishNdrSession + for event in senderRelay.sentEvents + .dropFirst(senderRelayIndex) + where event.kind == 1060 + { + recipientService.processInboundRelayEvent(event) + } + for event in recipientRelay.sentEvents + .dropFirst(recipientRelayIndex) + where event.kind == 1060 + { + senderService.processInboundRelayEvent(event) + } + + guard senderService.hasActiveSession( + with: recipientIdentity.publicKeyHex + ), + recipientService.hasActiveSession( + with: senderIdentity.publicKeyHex + ) + else { + throw NostrTransportTestError.failedToEstablishNdrSession + } } private func decodeEmbeddedPayload( @@ -711,18 +1074,6 @@ struct NostrTransportTests { return message } - private func extractNostrKind(json: String) throws -> Int { - let data = Data(json.utf8) - let obj = try JSONSerialization.jsonObject(with: data, options: []) - let dict = try #require(obj as? [String: Any], "Nostr event should be a JSON object") - return try #require(dict["kind"] as? Int, "Nostr event should include kind") - } - - private func isAppKeysEvent(_ event: NostrEvent) -> Bool { - event.kind == 37368 && event.tags.contains { tag in - tag.count >= 2 && tag[0] == "type" && tag[1] == "app_keys_roster_snapshot" - } - } } private enum NostrTransportTestError: Error { @@ -730,6 +1081,7 @@ private enum NostrTransportTestError: Error { case invalidPacket case invalidPrivateMessage case failedToEstablishNdrSession + case storageUnavailable } private func base64URLDecode(_ string: String) -> Data? { diff --git a/localPackages/NdrFfi/Package.swift b/localPackages/NdrFfi/Package.swift index 5fbe928d..2d5caf16 100644 --- a/localPackages/NdrFfi/Package.swift +++ b/localPackages/NdrFfi/Package.swift @@ -21,7 +21,7 @@ let package = Package( dependencies: ["ndr_ffiFFI"], path: "Sources/NdrFfi" ), - // Dynamic XCFramework built from iris-chat-rs protocol-ffi. Keeping + // Dynamic XCFramework built from nostr-double-ratchet's pairwise FFI. Keeping // this runtime dynamic avoids linking a second Rust static runtime // beside Arti's source-built static library. .binaryTarget( diff --git a/localPackages/NdrFfi/README.md b/localPackages/NdrFfi/README.md index 5f77ce50..4b38a52f 100644 --- a/localPackages/NdrFfi/README.md +++ b/localPackages/NdrFfi/README.md @@ -1,20 +1,21 @@ # NdrFfi -Generated Swift bindings and an ignored dynamic Apple XCFramework for the upstream -`iris-chat-rs` `protocol-ffi` crate. Its locked dependency graph uses -`nostr-double-ratchet` and `nostr-double-ratchet-pairwise-codec` `0.0.164`. +Generated Swift bindings and an ignored dynamic Apple XCFramework for the +single-device pairwise UniFFI crate in `nostr-double-ratchet`. The binary does +not include AppKeys, linked-device, sibling-sync, or group runtime code. ## Source Of Truth The generated files in this package come from the upstream -`iris-chat-rs` checkout, specifically the Rust `protocol-ffi` crate and its -UniFFI-generated Swift bindings. The built library keeps the compatibility -name `ndr_ffi`. +`nostr-double-ratchet` checkout, specifically the Rust `ndr-pairwise-ffi` +crate and its UniFFI-generated Swift bindings. The built library keeps the +module name `ndr_ffi`. -The exact upstream revision is pinned by the `vendor/iris-chat-rs` submodule -and repeated in `SOURCE_REVISION`. Native libraries are deliberately not -tracked in this repository. Apple deployment targets are fixed in the build -script, so ambient shell settings cannot change the output. +The exact upstream revision is pinned by the +`vendor/nostr-double-ratchet` submodule and repeated in `SOURCE_REVISION`. +Native libraries are deliberately not tracked in this repository. Apple +deployment targets are fixed in the build script, so ambient shell settings +cannot change the output. ## Rebuild From Source @@ -38,7 +39,7 @@ rustup target add \ aarch64-apple-ios \ aarch64-apple-ios-sim \ x86_64-apple-ios -git submodule update --init --checkout vendor/iris-chat-rs +git submodule update --init --checkout vendor/nostr-double-ratchet ./localPackages/NdrFfi/build-apple.sh ``` @@ -46,12 +47,12 @@ Or: ```bash cd localPackages/NdrFfi -IRIS_CHAT_RS_DIR=/path/to/iris-chat-rs ./build-apple.sh +NOSTR_DOUBLE_RATCHET_DIR=/path/to/nostr-double-ratchet ./build-apple.sh ``` The script: -- builds the upstream `protocol-ffi` crate +- builds the upstream `ndr-pairwise-ffi` crate - reuses an ignored Cargo target cache under `.cache/ndr-ffi/apple` - regenerates `Sources/NdrFfi/NdrFfi.swift` via UniFFI - rebuilds the ignored dynamic Apple XCFramework at `Frameworks/NdrFfi.xcframework` diff --git a/localPackages/NdrFfi/SOURCE_REVISION b/localPackages/NdrFfi/SOURCE_REVISION index e72a077d..9c18dcab 100644 --- a/localPackages/NdrFfi/SOURCE_REVISION +++ b/localPackages/NdrFfi/SOURCE_REVISION @@ -1 +1 @@ -095e70489345df4d92dded686902f3dccb54cc45 +0fe8caf2d4e24e2030ffae195597a2764613a659 diff --git a/localPackages/NdrFfi/Sources/NdrFfi/NdrFfi.swift b/localPackages/NdrFfi/Sources/NdrFfi/NdrFfi.swift index 10f7799b..b9d900fb 100644 --- a/localPackages/NdrFfi/Sources/NdrFfi/NdrFfi.swift +++ b/localPackages/NdrFfi/Sources/NdrFfi/NdrFfi.swift @@ -481,16 +481,14 @@ fileprivate struct FfiConverterString: FfiConverter { -public protocol InviteHandleProtocol: AnyObject, Sendable { +public protocol PairwiseInviteProtocol: AnyObject, Sendable { - func getInviterPubkeyHex() -> String - - func getOwnerPubkeyHex() throws -> String + func getPeerPubkeyHex() -> String func toUrl(root: String) throws -> String } -open class InviteHandle: InviteHandleProtocol, @unchecked Sendable { +open class PairwiseInvite: PairwiseInviteProtocol, @unchecked Sendable { fileprivate let pointer: UnsafeMutableRawPointer! /// Used to instantiate a [FFIObject] without an actual pointer, for fakes in tests, mostly. @@ -527,7 +525,7 @@ open class InviteHandle: InviteHandleProtocol, @unchecked Sendable { @_documentation(visibility: private) #endif public func uniffiClonePointer() -> UnsafeMutableRawPointer { - return try! rustCall { uniffi_ndr_ffi_fn_clone_invitehandle(self.pointer, $0) } + return try! rustCall { uniffi_ndr_ffi_fn_clone_pairwiseinvite(self.pointer, $0) } } // No primary constructor declared for this class. @@ -536,21 +534,21 @@ open class InviteHandle: InviteHandleProtocol, @unchecked Sendable { return } - try! rustCall { uniffi_ndr_ffi_fn_free_invitehandle(pointer, $0) } + try! rustCall { uniffi_ndr_ffi_fn_free_pairwiseinvite(pointer, $0) } } -public static func fromEventJson(eventJson: String)throws -> InviteHandle { - return try FfiConverterTypeInviteHandle_lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { - uniffi_ndr_ffi_fn_constructor_invitehandle_from_event_json( +public static func fromEventJson(eventJson: String)throws -> PairwiseInvite { + return try FfiConverterTypePairwiseInvite_lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { + uniffi_ndr_ffi_fn_constructor_pairwiseinvite_from_event_json( FfiConverterString.lower(eventJson),$0 ) }) } -public static func fromUrl(url: String)throws -> InviteHandle { - return try FfiConverterTypeInviteHandle_lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { - uniffi_ndr_ffi_fn_constructor_invitehandle_from_url( +public static func fromUrl(url: String)throws -> PairwiseInvite { + return try FfiConverterTypePairwiseInvite_lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { + uniffi_ndr_ffi_fn_constructor_pairwiseinvite_from_url( FfiConverterString.lower(url),$0 ) }) @@ -558,23 +556,16 @@ public static func fromUrl(url: String)throws -> InviteHandle { -open func getInviterPubkeyHex() -> String { +open func getPeerPubkeyHex() -> String { return try! FfiConverterString.lift(try! rustCall() { - uniffi_ndr_ffi_fn_method_invitehandle_get_inviter_pubkey_hex(self.uniffiClonePointer(),$0 - ) -}) -} - -open func getOwnerPubkeyHex()throws -> String { - return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { - uniffi_ndr_ffi_fn_method_invitehandle_get_owner_pubkey_hex(self.uniffiClonePointer(),$0 + uniffi_ndr_ffi_fn_method_pairwiseinvite_get_peer_pubkey_hex(self.uniffiClonePointer(),$0 ) }) } open func toUrl(root: String)throws -> String { return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { - uniffi_ndr_ffi_fn_method_invitehandle_to_url(self.uniffiClonePointer(), + uniffi_ndr_ffi_fn_method_pairwiseinvite_to_url(self.uniffiClonePointer(), FfiConverterString.lower(root),$0 ) }) @@ -587,20 +578,20 @@ open func toUrl(root: String)throws -> String { #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeInviteHandle: FfiConverter { +public struct FfiConverterTypePairwiseInvite: FfiConverter { typealias FfiType = UnsafeMutableRawPointer - typealias SwiftType = InviteHandle + typealias SwiftType = PairwiseInvite - public static func lift(_ pointer: UnsafeMutableRawPointer) throws -> InviteHandle { - return InviteHandle(unsafeFromRawPointer: pointer) + public static func lift(_ pointer: UnsafeMutableRawPointer) throws -> PairwiseInvite { + return PairwiseInvite(unsafeFromRawPointer: pointer) } - public static func lower(_ value: InviteHandle) -> UnsafeMutableRawPointer { + public static func lower(_ value: PairwiseInvite) -> UnsafeMutableRawPointer { return value.uniffiClonePointer() } - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> InviteHandle { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PairwiseInvite { let v: UInt64 = try readInt(&buf) // The Rust code won't compile if a pointer won't fit in a UInt64. // We have to go via `UInt` because that's the thing that's the size of a pointer. @@ -611,7 +602,7 @@ public struct FfiConverterTypeInviteHandle: FfiConverter { return try lift(ptr!) } - public static func write(_ value: InviteHandle, into buf: inout [UInt8]) { + public static func write(_ value: PairwiseInvite, into buf: inout [UInt8]) { // This fiddling is because `Int` is the thing that's the same size as a pointer. // The Rust code won't compile if a pointer won't fit in a `UInt64`. writeInt(&buf, UInt64(bitPattern: Int64(Int(bitPattern: lower(value))))) @@ -622,15 +613,15 @@ public struct FfiConverterTypeInviteHandle: FfiConverter { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeInviteHandle_lift(_ pointer: UnsafeMutableRawPointer) throws -> InviteHandle { - return try FfiConverterTypeInviteHandle.lift(pointer) +public func FfiConverterTypePairwiseInvite_lift(_ pointer: UnsafeMutableRawPointer) throws -> PairwiseInvite { + return try FfiConverterTypePairwiseInvite.lift(pointer) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeInviteHandle_lower(_ value: InviteHandle) -> UnsafeMutableRawPointer { - return FfiConverterTypeInviteHandle.lower(value) +public func FfiConverterTypePairwiseInvite_lower(_ value: PairwiseInvite) -> UnsafeMutableRawPointer { + return FfiConverterTypePairwiseInvite.lower(value) } @@ -638,44 +629,40 @@ public func FfiConverterTypeInviteHandle_lower(_ value: InviteHandle) -> UnsafeM -public protocol SessionManagerHandleProtocol: AnyObject, Sendable { +public protocol PairwiseManagerProtocol: AnyObject, Sendable { - func acceptInviteFromEventJson(eventJson: String, ownerPubkeyHintHex: String?) throws -> SessionManagerAcceptInviteResult + func acceptInviteFromEventJson(eventJson: String, authenticatedPeerPubkeyHex: String) throws -> PairwiseAcceptResult - func acceptInviteFromUrl(inviteUrl: String, ownerPubkeyHintHex: String?) throws -> SessionManagerAcceptInviteResult + func acceptInviteFromUrl(inviteUrl: String, authenticatedPeerPubkeyHex: String) throws -> PairwiseAcceptResult - func drainEvents() throws -> [PubSubEvent] + func ackActions(actionIds: [String]) throws - func getActiveSessionState(peerPubkeyHex: String) throws -> String? + func currentInviteEventJson() throws -> String - func getDeviceId() -> String + func currentInviteUrl(root: String) throws -> String - func getMessagePushAuthorPubkeys(peerOwnerPubkeyHex: String) throws -> [String] + func getOurPubkeyHex() throws -> String - func getMessagePushSessionStates(peerOwnerPubkeyHex: String) throws -> [MessagePushSessionStateResult] + func getTotalSessions() throws -> UInt64 - func getOurPubkeyHex() -> String + func knownPeerPubkeys() throws -> [String] - func getOwnerPubkeyHex() -> String + func pendingActions() throws -> [PairwiseAction] - func getTotalSessions() -> UInt64 - - func `init`() throws - - func knownPeerOwnerPubkeys() -> [String] + func pendingActionsAt(nowSeconds: UInt64) throws -> [PairwiseAction] func processEvent(eventJson: String) throws - func processOutOfBandResponse(eventJson: String, expectedOwnerPubkeyHex: String) throws + func processOutOfBandResponse(eventJson: String, authenticatedPeerPubkeyHex: String) throws - func sendText(recipientPubkeyHex: String, text: String, expiresAtSeconds: UInt64?) throws -> [String] + func retirePeer(peerPubkeyHex: String) throws -> Bool - func sendTextWithInnerId(recipientPubkeyHex: String, text: String, expiresAtSeconds: UInt64?) throws -> SendTextResult + func sendText(peerPubkeyHex: String, text: String, expiresAtSeconds: UInt64?) throws -> PairwiseSendResult - func setupUser(userPubkeyHex: String) throws + func sessionInfo(peerPubkeyHex: String) throws -> PairwiseSessionInfo? } -open class SessionManagerHandle: SessionManagerHandleProtocol, @unchecked Sendable { +open class PairwiseManager: PairwiseManagerProtocol, @unchecked Sendable { fileprivate let pointer: UnsafeMutableRawPointer! /// Used to instantiate a [FFIObject] without an actual pointer, for fakes in tests, mostly. @@ -712,176 +699,148 @@ open class SessionManagerHandle: SessionManagerHandleProtocol, @unchecked Sendab @_documentation(visibility: private) #endif public func uniffiClonePointer() -> UnsafeMutableRawPointer { - return try! rustCall { uniffi_ndr_ffi_fn_clone_sessionmanagerhandle(self.pointer, $0) } + return try! rustCall { uniffi_ndr_ffi_fn_clone_pairwisemanager(self.pointer, $0) } } -public convenience init(ourPubkeyHex: String, ourIdentityPrivkeyHex: String, deviceId: String, ownerPubkeyHex: String?)throws { - let pointer = - try rustCallWithError(FfiConverterTypeNdrError_lift) { - uniffi_ndr_ffi_fn_constructor_sessionmanagerhandle_new( - FfiConverterString.lower(ourPubkeyHex), - FfiConverterString.lower(ourIdentityPrivkeyHex), - FfiConverterString.lower(deviceId), - FfiConverterOptionString.lower(ownerPubkeyHex),$0 - ) -} - self.init(unsafeFromRawPointer: pointer) -} + // No primary constructor declared for this class. deinit { guard let pointer = pointer else { return } - try! rustCall { uniffi_ndr_ffi_fn_free_sessionmanagerhandle(pointer, $0) } + try! rustCall { uniffi_ndr_ffi_fn_free_pairwisemanager(pointer, $0) } } -public static func newWithStoragePath(ourPubkeyHex: String, ourIdentityPrivkeyHex: String, deviceId: String, storagePath: String, ownerPubkeyHex: String?)throws -> SessionManagerHandle { - return try FfiConverterTypeSessionManagerHandle_lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { - uniffi_ndr_ffi_fn_constructor_sessionmanagerhandle_new_with_storage_path( +public static func newWithStoragePath(ourPubkeyHex: String, ourIdentityPrivateKeyHex: String, storagePath: String)throws -> PairwiseManager { + return try FfiConverterTypePairwiseManager_lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { + uniffi_ndr_ffi_fn_constructor_pairwisemanager_new_with_storage_path( FfiConverterString.lower(ourPubkeyHex), - FfiConverterString.lower(ourIdentityPrivkeyHex), - FfiConverterString.lower(deviceId), - FfiConverterString.lower(storagePath), - FfiConverterOptionString.lower(ownerPubkeyHex),$0 + FfiConverterString.lower(ourIdentityPrivateKeyHex), + FfiConverterString.lower(storagePath),$0 ) }) } -open func acceptInviteFromEventJson(eventJson: String, ownerPubkeyHintHex: String?)throws -> SessionManagerAcceptInviteResult { - return try FfiConverterTypeSessionManagerAcceptInviteResult_lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { - uniffi_ndr_ffi_fn_method_sessionmanagerhandle_accept_invite_from_event_json(self.uniffiClonePointer(), +open func acceptInviteFromEventJson(eventJson: String, authenticatedPeerPubkeyHex: String)throws -> PairwiseAcceptResult { + return try FfiConverterTypePairwiseAcceptResult_lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { + uniffi_ndr_ffi_fn_method_pairwisemanager_accept_invite_from_event_json(self.uniffiClonePointer(), FfiConverterString.lower(eventJson), - FfiConverterOptionString.lower(ownerPubkeyHintHex),$0 + FfiConverterString.lower(authenticatedPeerPubkeyHex),$0 ) }) } -open func acceptInviteFromUrl(inviteUrl: String, ownerPubkeyHintHex: String?)throws -> SessionManagerAcceptInviteResult { - return try FfiConverterTypeSessionManagerAcceptInviteResult_lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { - uniffi_ndr_ffi_fn_method_sessionmanagerhandle_accept_invite_from_url(self.uniffiClonePointer(), +open func acceptInviteFromUrl(inviteUrl: String, authenticatedPeerPubkeyHex: String)throws -> PairwiseAcceptResult { + return try FfiConverterTypePairwiseAcceptResult_lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { + uniffi_ndr_ffi_fn_method_pairwisemanager_accept_invite_from_url(self.uniffiClonePointer(), FfiConverterString.lower(inviteUrl), - FfiConverterOptionString.lower(ownerPubkeyHintHex),$0 + FfiConverterString.lower(authenticatedPeerPubkeyHex),$0 ) }) } -open func drainEvents()throws -> [PubSubEvent] { - return try FfiConverterSequenceTypePubSubEvent.lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { - uniffi_ndr_ffi_fn_method_sessionmanagerhandle_drain_events(self.uniffiClonePointer(),$0 +open func ackActions(actionIds: [String])throws {try rustCallWithError(FfiConverterTypeNdrError_lift) { + uniffi_ndr_ffi_fn_method_pairwisemanager_ack_actions(self.uniffiClonePointer(), + FfiConverterSequenceString.lower(actionIds),$0 + ) +} +} + +open func currentInviteEventJson()throws -> String { + return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { + uniffi_ndr_ffi_fn_method_pairwisemanager_current_invite_event_json(self.uniffiClonePointer(),$0 ) }) } -open func getActiveSessionState(peerPubkeyHex: String)throws -> String? { - return try FfiConverterOptionString.lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { - uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_active_session_state(self.uniffiClonePointer(), - FfiConverterString.lower(peerPubkeyHex),$0 +open func currentInviteUrl(root: String)throws -> String { + return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { + uniffi_ndr_ffi_fn_method_pairwisemanager_current_invite_url(self.uniffiClonePointer(), + FfiConverterString.lower(root),$0 ) }) } -open func getDeviceId() -> String { - return try! FfiConverterString.lift(try! rustCall() { - uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_device_id(self.uniffiClonePointer(),$0 +open func getOurPubkeyHex()throws -> String { + return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { + uniffi_ndr_ffi_fn_method_pairwisemanager_get_our_pubkey_hex(self.uniffiClonePointer(),$0 ) }) } -open func getMessagePushAuthorPubkeys(peerOwnerPubkeyHex: String)throws -> [String] { +open func getTotalSessions()throws -> UInt64 { + return try FfiConverterUInt64.lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { + uniffi_ndr_ffi_fn_method_pairwisemanager_get_total_sessions(self.uniffiClonePointer(),$0 + ) +}) +} + +open func knownPeerPubkeys()throws -> [String] { return try FfiConverterSequenceString.lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { - uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_message_push_author_pubkeys(self.uniffiClonePointer(), - FfiConverterString.lower(peerOwnerPubkeyHex),$0 + uniffi_ndr_ffi_fn_method_pairwisemanager_known_peer_pubkeys(self.uniffiClonePointer(),$0 ) }) } -open func getMessagePushSessionStates(peerOwnerPubkeyHex: String)throws -> [MessagePushSessionStateResult] { - return try FfiConverterSequenceTypeMessagePushSessionStateResult.lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { - uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_message_push_session_states(self.uniffiClonePointer(), - FfiConverterString.lower(peerOwnerPubkeyHex),$0 +open func pendingActions()throws -> [PairwiseAction] { + return try FfiConverterSequenceTypePairwiseAction.lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { + uniffi_ndr_ffi_fn_method_pairwisemanager_pending_actions(self.uniffiClonePointer(),$0 ) }) } -open func getOurPubkeyHex() -> String { - return try! FfiConverterString.lift(try! rustCall() { - uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_our_pubkey_hex(self.uniffiClonePointer(),$0 - ) -}) -} - -open func getOwnerPubkeyHex() -> String { - return try! FfiConverterString.lift(try! rustCall() { - uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_owner_pubkey_hex(self.uniffiClonePointer(),$0 - ) -}) -} - -open func getTotalSessions() -> UInt64 { - return try! FfiConverterUInt64.lift(try! rustCall() { - uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_total_sessions(self.uniffiClonePointer(),$0 - ) -}) -} - -open func `init`()throws {try rustCallWithError(FfiConverterTypeNdrError_lift) { - uniffi_ndr_ffi_fn_method_sessionmanagerhandle_init(self.uniffiClonePointer(),$0 - ) -} -} - -open func knownPeerOwnerPubkeys() -> [String] { - return try! FfiConverterSequenceString.lift(try! rustCall() { - uniffi_ndr_ffi_fn_method_sessionmanagerhandle_known_peer_owner_pubkeys(self.uniffiClonePointer(),$0 +open func pendingActionsAt(nowSeconds: UInt64)throws -> [PairwiseAction] { + return try FfiConverterSequenceTypePairwiseAction.lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { + uniffi_ndr_ffi_fn_method_pairwisemanager_pending_actions_at(self.uniffiClonePointer(), + FfiConverterUInt64.lower(nowSeconds),$0 ) }) } open func processEvent(eventJson: String)throws {try rustCallWithError(FfiConverterTypeNdrError_lift) { - uniffi_ndr_ffi_fn_method_sessionmanagerhandle_process_event(self.uniffiClonePointer(), + uniffi_ndr_ffi_fn_method_pairwisemanager_process_event(self.uniffiClonePointer(), FfiConverterString.lower(eventJson),$0 ) } } -open func processOutOfBandResponse(eventJson: String, expectedOwnerPubkeyHex: String)throws {try rustCallWithError(FfiConverterTypeNdrError_lift) { - uniffi_ndr_ffi_fn_method_sessionmanagerhandle_process_out_of_band_response(self.uniffiClonePointer(), +open func processOutOfBandResponse(eventJson: String, authenticatedPeerPubkeyHex: String)throws {try rustCallWithError(FfiConverterTypeNdrError_lift) { + uniffi_ndr_ffi_fn_method_pairwisemanager_process_out_of_band_response(self.uniffiClonePointer(), FfiConverterString.lower(eventJson), - FfiConverterString.lower(expectedOwnerPubkeyHex),$0 + FfiConverterString.lower(authenticatedPeerPubkeyHex),$0 ) } } -open func sendText(recipientPubkeyHex: String, text: String, expiresAtSeconds: UInt64?)throws -> [String] { - return try FfiConverterSequenceString.lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { - uniffi_ndr_ffi_fn_method_sessionmanagerhandle_send_text(self.uniffiClonePointer(), - FfiConverterString.lower(recipientPubkeyHex), +open func retirePeer(peerPubkeyHex: String)throws -> Bool { + return try FfiConverterBool.lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { + uniffi_ndr_ffi_fn_method_pairwisemanager_retire_peer(self.uniffiClonePointer(), + FfiConverterString.lower(peerPubkeyHex),$0 + ) +}) +} + +open func sendText(peerPubkeyHex: String, text: String, expiresAtSeconds: UInt64?)throws -> PairwiseSendResult { + return try FfiConverterTypePairwiseSendResult_lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { + uniffi_ndr_ffi_fn_method_pairwisemanager_send_text(self.uniffiClonePointer(), + FfiConverterString.lower(peerPubkeyHex), FfiConverterString.lower(text), FfiConverterOptionUInt64.lower(expiresAtSeconds),$0 ) }) } -open func sendTextWithInnerId(recipientPubkeyHex: String, text: String, expiresAtSeconds: UInt64?)throws -> SendTextResult { - return try FfiConverterTypeSendTextResult_lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { - uniffi_ndr_ffi_fn_method_sessionmanagerhandle_send_text_with_inner_id(self.uniffiClonePointer(), - FfiConverterString.lower(recipientPubkeyHex), - FfiConverterString.lower(text), - FfiConverterOptionUInt64.lower(expiresAtSeconds),$0 +open func sessionInfo(peerPubkeyHex: String)throws -> PairwiseSessionInfo? { + return try FfiConverterOptionTypePairwiseSessionInfo.lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { + uniffi_ndr_ffi_fn_method_pairwisemanager_session_info(self.uniffiClonePointer(), + FfiConverterString.lower(peerPubkeyHex),$0 ) }) } -open func setupUser(userPubkeyHex: String)throws {try rustCallWithError(FfiConverterTypeNdrError_lift) { - uniffi_ndr_ffi_fn_method_sessionmanagerhandle_setup_user(self.uniffiClonePointer(), - FfiConverterString.lower(userPubkeyHex),$0 - ) -} -} - } @@ -889,20 +848,20 @@ open func setupUser(userPubkeyHex: String)throws {try rustCallWithError(FfiCon #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeSessionManagerHandle: FfiConverter { +public struct FfiConverterTypePairwiseManager: FfiConverter { typealias FfiType = UnsafeMutableRawPointer - typealias SwiftType = SessionManagerHandle + typealias SwiftType = PairwiseManager - public static func lift(_ pointer: UnsafeMutableRawPointer) throws -> SessionManagerHandle { - return SessionManagerHandle(unsafeFromRawPointer: pointer) + public static func lift(_ pointer: UnsafeMutableRawPointer) throws -> PairwiseManager { + return PairwiseManager(unsafeFromRawPointer: pointer) } - public static func lower(_ value: SessionManagerHandle) -> UnsafeMutableRawPointer { + public static func lower(_ value: PairwiseManager) -> UnsafeMutableRawPointer { return value.uniffiClonePointer() } - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionManagerHandle { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PairwiseManager { let v: UInt64 = try readInt(&buf) // The Rust code won't compile if a pointer won't fit in a UInt64. // We have to go via `UInt` because that's the thing that's the size of a pointer. @@ -913,7 +872,7 @@ public struct FfiConverterTypeSessionManagerHandle: FfiConverter { return try lift(ptr!) } - public static func write(_ value: SessionManagerHandle, into buf: inout [UInt8]) { + public static func write(_ value: PairwiseManager, into buf: inout [UInt8]) { // This fiddling is because `Int` is the thing that's the same size as a pointer. // The Rust code won't compile if a pointer won't fit in a `UInt64`. writeInt(&buf, UInt64(bitPattern: Int64(Int(bitPattern: lower(value))))) @@ -924,15 +883,15 @@ public struct FfiConverterTypeSessionManagerHandle: FfiConverter { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSessionManagerHandle_lift(_ pointer: UnsafeMutableRawPointer) throws -> SessionManagerHandle { - return try FfiConverterTypeSessionManagerHandle.lift(pointer) +public func FfiConverterTypePairwiseManager_lift(_ pointer: UnsafeMutableRawPointer) throws -> PairwiseManager { + return try FfiConverterTypePairwiseManager.lift(pointer) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSessionManagerHandle_lower(_ value: SessionManagerHandle) -> UnsafeMutableRawPointer { - return FfiConverterTypeSessionManagerHandle.lower(value) +public func FfiConverterTypePairwiseManager_lower(_ value: PairwiseManager) -> UnsafeMutableRawPointer { + return FfiConverterTypePairwiseManager.lower(value) } @@ -1008,310 +967,26 @@ public func FfiConverterTypeFfiKeyPair_lower(_ value: FfiKeyPair) -> RustBuffer } -public struct MessagePushSessionStateResult { - public var stateJson: String - public var trackedSenderPubkeys: [String] - public var hasReceivingCapability: Bool - - // Default memberwise initializers are never public by default, so we - // declare one manually. - public init(stateJson: String, trackedSenderPubkeys: [String], hasReceivingCapability: Bool) { - self.stateJson = stateJson - self.trackedSenderPubkeys = trackedSenderPubkeys - self.hasReceivingCapability = hasReceivingCapability - } -} - -#if compiler(>=6) -extension MessagePushSessionStateResult: Sendable {} -#endif - - -extension MessagePushSessionStateResult: Equatable, Hashable { - public static func ==(lhs: MessagePushSessionStateResult, rhs: MessagePushSessionStateResult) -> Bool { - if lhs.stateJson != rhs.stateJson { - return false - } - if lhs.trackedSenderPubkeys != rhs.trackedSenderPubkeys { - return false - } - if lhs.hasReceivingCapability != rhs.hasReceivingCapability { - return false - } - return true - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(stateJson) - hasher.combine(trackedSenderPubkeys) - hasher.combine(hasReceivingCapability) - } -} - - - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public struct FfiConverterTypeMessagePushSessionStateResult: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MessagePushSessionStateResult { - return - try MessagePushSessionStateResult( - stateJson: FfiConverterString.read(from: &buf), - trackedSenderPubkeys: FfiConverterSequenceString.read(from: &buf), - hasReceivingCapability: FfiConverterBool.read(from: &buf) - ) - } - - public static func write(_ value: MessagePushSessionStateResult, into buf: inout [UInt8]) { - FfiConverterString.write(value.stateJson, into: &buf) - FfiConverterSequenceString.write(value.trackedSenderPubkeys, into: &buf) - FfiConverterBool.write(value.hasReceivingCapability, into: &buf) - } -} - - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeMessagePushSessionStateResult_lift(_ buf: RustBuffer) throws -> MessagePushSessionStateResult { - return try FfiConverterTypeMessagePushSessionStateResult.lift(buf) -} - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeMessagePushSessionStateResult_lower(_ value: MessagePushSessionStateResult) -> RustBuffer { - return FfiConverterTypeMessagePushSessionStateResult.lower(value) -} - - -public struct PubSubEvent { - public var kind: String - public var subid: String? - public var filterJson: String? - public var eventJson: String? - public var senderPubkeyHex: String? - public var senderDevicePubkeyHex: String? - public var conversationOwnerPubkeyHex: String? - public var content: String? - public var eventId: String? - - // Default memberwise initializers are never public by default, so we - // declare one manually. - public init(kind: String, subid: String?, filterJson: String?, eventJson: String?, senderPubkeyHex: String?, senderDevicePubkeyHex: String?, conversationOwnerPubkeyHex: String?, content: String?, eventId: String?) { - self.kind = kind - self.subid = subid - self.filterJson = filterJson - self.eventJson = eventJson - self.senderPubkeyHex = senderPubkeyHex - self.senderDevicePubkeyHex = senderDevicePubkeyHex - self.conversationOwnerPubkeyHex = conversationOwnerPubkeyHex - self.content = content - self.eventId = eventId - } -} - -#if compiler(>=6) -extension PubSubEvent: Sendable {} -#endif - - -extension PubSubEvent: Equatable, Hashable { - public static func ==(lhs: PubSubEvent, rhs: PubSubEvent) -> Bool { - if lhs.kind != rhs.kind { - return false - } - if lhs.subid != rhs.subid { - return false - } - if lhs.filterJson != rhs.filterJson { - return false - } - if lhs.eventJson != rhs.eventJson { - return false - } - if lhs.senderPubkeyHex != rhs.senderPubkeyHex { - return false - } - if lhs.senderDevicePubkeyHex != rhs.senderDevicePubkeyHex { - return false - } - if lhs.conversationOwnerPubkeyHex != rhs.conversationOwnerPubkeyHex { - return false - } - if lhs.content != rhs.content { - return false - } - if lhs.eventId != rhs.eventId { - return false - } - return true - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(kind) - hasher.combine(subid) - hasher.combine(filterJson) - hasher.combine(eventJson) - hasher.combine(senderPubkeyHex) - hasher.combine(senderDevicePubkeyHex) - hasher.combine(conversationOwnerPubkeyHex) - hasher.combine(content) - hasher.combine(eventId) - } -} - - - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public struct FfiConverterTypePubSubEvent: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PubSubEvent { - return - try PubSubEvent( - kind: FfiConverterString.read(from: &buf), - subid: FfiConverterOptionString.read(from: &buf), - filterJson: FfiConverterOptionString.read(from: &buf), - eventJson: FfiConverterOptionString.read(from: &buf), - senderPubkeyHex: FfiConverterOptionString.read(from: &buf), - senderDevicePubkeyHex: FfiConverterOptionString.read(from: &buf), - conversationOwnerPubkeyHex: FfiConverterOptionString.read(from: &buf), - content: FfiConverterOptionString.read(from: &buf), - eventId: FfiConverterOptionString.read(from: &buf) - ) - } - - public static func write(_ value: PubSubEvent, into buf: inout [UInt8]) { - FfiConverterString.write(value.kind, into: &buf) - FfiConverterOptionString.write(value.subid, into: &buf) - FfiConverterOptionString.write(value.filterJson, into: &buf) - FfiConverterOptionString.write(value.eventJson, into: &buf) - FfiConverterOptionString.write(value.senderPubkeyHex, into: &buf) - FfiConverterOptionString.write(value.senderDevicePubkeyHex, into: &buf) - FfiConverterOptionString.write(value.conversationOwnerPubkeyHex, into: &buf) - FfiConverterOptionString.write(value.content, into: &buf) - FfiConverterOptionString.write(value.eventId, into: &buf) - } -} - - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypePubSubEvent_lift(_ buf: RustBuffer) throws -> PubSubEvent { - return try FfiConverterTypePubSubEvent.lift(buf) -} - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypePubSubEvent_lower(_ value: PubSubEvent) -> RustBuffer { - return FfiConverterTypePubSubEvent.lower(value) -} - - -public struct SendTextResult { - public var innerId: String - public var outerEventIds: [String] - - // Default memberwise initializers are never public by default, so we - // declare one manually. - public init(innerId: String, outerEventIds: [String]) { - self.innerId = innerId - self.outerEventIds = outerEventIds - } -} - -#if compiler(>=6) -extension SendTextResult: Sendable {} -#endif - - -extension SendTextResult: Equatable, Hashable { - public static func ==(lhs: SendTextResult, rhs: SendTextResult) -> Bool { - if lhs.innerId != rhs.innerId { - return false - } - if lhs.outerEventIds != rhs.outerEventIds { - return false - } - return true - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(innerId) - hasher.combine(outerEventIds) - } -} - - - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public struct FfiConverterTypeSendTextResult: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SendTextResult { - return - try SendTextResult( - innerId: FfiConverterString.read(from: &buf), - outerEventIds: FfiConverterSequenceString.read(from: &buf) - ) - } - - public static func write(_ value: SendTextResult, into buf: inout [UInt8]) { - FfiConverterString.write(value.innerId, into: &buf) - FfiConverterSequenceString.write(value.outerEventIds, into: &buf) - } -} - - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeSendTextResult_lift(_ buf: RustBuffer) throws -> SendTextResult { - return try FfiConverterTypeSendTextResult.lift(buf) -} - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeSendTextResult_lower(_ value: SendTextResult) -> RustBuffer { - return FfiConverterTypeSendTextResult.lower(value) -} - - -public struct SessionManagerAcceptInviteResult { - public var ownerPubkeyHex: String - public var inviterDevicePubkeyHex: String - public var deviceId: String +public struct PairwiseAcceptResult { + public var peerPubkeyHex: String public var createdNewSession: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(ownerPubkeyHex: String, inviterDevicePubkeyHex: String, deviceId: String, createdNewSession: Bool) { - self.ownerPubkeyHex = ownerPubkeyHex - self.inviterDevicePubkeyHex = inviterDevicePubkeyHex - self.deviceId = deviceId + public init(peerPubkeyHex: String, createdNewSession: Bool) { + self.peerPubkeyHex = peerPubkeyHex self.createdNewSession = createdNewSession } } #if compiler(>=6) -extension SessionManagerAcceptInviteResult: Sendable {} +extension PairwiseAcceptResult: Sendable {} #endif -extension SessionManagerAcceptInviteResult: Equatable, Hashable { - public static func ==(lhs: SessionManagerAcceptInviteResult, rhs: SessionManagerAcceptInviteResult) -> Bool { - if lhs.ownerPubkeyHex != rhs.ownerPubkeyHex { - return false - } - if lhs.inviterDevicePubkeyHex != rhs.inviterDevicePubkeyHex { - return false - } - if lhs.deviceId != rhs.deviceId { +extension PairwiseAcceptResult: Equatable, Hashable { + public static func ==(lhs: PairwiseAcceptResult, rhs: PairwiseAcceptResult) -> Bool { + if lhs.peerPubkeyHex != rhs.peerPubkeyHex { return false } if lhs.createdNewSession != rhs.createdNewSession { @@ -1321,9 +996,7 @@ extension SessionManagerAcceptInviteResult: Equatable, Hashable { } public func hash(into hasher: inout Hasher) { - hasher.combine(ownerPubkeyHex) - hasher.combine(inviterDevicePubkeyHex) - hasher.combine(deviceId) + hasher.combine(peerPubkeyHex) hasher.combine(createdNewSession) } } @@ -1333,21 +1006,17 @@ extension SessionManagerAcceptInviteResult: Equatable, Hashable { #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeSessionManagerAcceptInviteResult: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionManagerAcceptInviteResult { +public struct FfiConverterTypePairwiseAcceptResult: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PairwiseAcceptResult { return - try SessionManagerAcceptInviteResult( - ownerPubkeyHex: FfiConverterString.read(from: &buf), - inviterDevicePubkeyHex: FfiConverterString.read(from: &buf), - deviceId: FfiConverterString.read(from: &buf), + try PairwiseAcceptResult( + peerPubkeyHex: FfiConverterString.read(from: &buf), createdNewSession: FfiConverterBool.read(from: &buf) ) } - public static func write(_ value: SessionManagerAcceptInviteResult, into buf: inout [UInt8]) { - FfiConverterString.write(value.ownerPubkeyHex, into: &buf) - FfiConverterString.write(value.inviterDevicePubkeyHex, into: &buf) - FfiConverterString.write(value.deviceId, into: &buf) + public static func write(_ value: PairwiseAcceptResult, into buf: inout [UInt8]) { + FfiConverterString.write(value.peerPubkeyHex, into: &buf) FfiConverterBool.write(value.createdNewSession, into: &buf) } } @@ -1356,15 +1025,305 @@ public struct FfiConverterTypeSessionManagerAcceptInviteResult: FfiConverterRust #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSessionManagerAcceptInviteResult_lift(_ buf: RustBuffer) throws -> SessionManagerAcceptInviteResult { - return try FfiConverterTypeSessionManagerAcceptInviteResult.lift(buf) +public func FfiConverterTypePairwiseAcceptResult_lift(_ buf: RustBuffer) throws -> PairwiseAcceptResult { + return try FfiConverterTypePairwiseAcceptResult.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSessionManagerAcceptInviteResult_lower(_ value: SessionManagerAcceptInviteResult) -> RustBuffer { - return FfiConverterTypeSessionManagerAcceptInviteResult.lower(value) +public func FfiConverterTypePairwiseAcceptResult_lower(_ value: PairwiseAcceptResult) -> RustBuffer { + return FfiConverterTypePairwiseAcceptResult.lower(value) +} + + +public struct PairwiseAction { + public var actionId: String + public var kind: String + public var sessionId: String? + public var subscriptionId: String? + public var filterJson: String? + public var eventJson: String? + public var peerPubkeyHex: String? + public var innerEventJson: String? + public var innerEventId: String? + public var outerEventId: String? + public var expiresAtSeconds: UInt64? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(actionId: String, kind: String, sessionId: String?, subscriptionId: String?, filterJson: String?, eventJson: String?, peerPubkeyHex: String?, innerEventJson: String?, innerEventId: String?, outerEventId: String?, expiresAtSeconds: UInt64?) { + self.actionId = actionId + self.kind = kind + self.sessionId = sessionId + self.subscriptionId = subscriptionId + self.filterJson = filterJson + self.eventJson = eventJson + self.peerPubkeyHex = peerPubkeyHex + self.innerEventJson = innerEventJson + self.innerEventId = innerEventId + self.outerEventId = outerEventId + self.expiresAtSeconds = expiresAtSeconds + } +} + +#if compiler(>=6) +extension PairwiseAction: Sendable {} +#endif + + +extension PairwiseAction: Equatable, Hashable { + public static func ==(lhs: PairwiseAction, rhs: PairwiseAction) -> Bool { + if lhs.actionId != rhs.actionId { + return false + } + if lhs.kind != rhs.kind { + return false + } + if lhs.sessionId != rhs.sessionId { + return false + } + if lhs.subscriptionId != rhs.subscriptionId { + return false + } + if lhs.filterJson != rhs.filterJson { + return false + } + if lhs.eventJson != rhs.eventJson { + return false + } + if lhs.peerPubkeyHex != rhs.peerPubkeyHex { + return false + } + if lhs.innerEventJson != rhs.innerEventJson { + return false + } + if lhs.innerEventId != rhs.innerEventId { + return false + } + if lhs.outerEventId != rhs.outerEventId { + return false + } + if lhs.expiresAtSeconds != rhs.expiresAtSeconds { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(actionId) + hasher.combine(kind) + hasher.combine(sessionId) + hasher.combine(subscriptionId) + hasher.combine(filterJson) + hasher.combine(eventJson) + hasher.combine(peerPubkeyHex) + hasher.combine(innerEventJson) + hasher.combine(innerEventId) + hasher.combine(outerEventId) + hasher.combine(expiresAtSeconds) + } +} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePairwiseAction: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PairwiseAction { + return + try PairwiseAction( + actionId: FfiConverterString.read(from: &buf), + kind: FfiConverterString.read(from: &buf), + sessionId: FfiConverterOptionString.read(from: &buf), + subscriptionId: FfiConverterOptionString.read(from: &buf), + filterJson: FfiConverterOptionString.read(from: &buf), + eventJson: FfiConverterOptionString.read(from: &buf), + peerPubkeyHex: FfiConverterOptionString.read(from: &buf), + innerEventJson: FfiConverterOptionString.read(from: &buf), + innerEventId: FfiConverterOptionString.read(from: &buf), + outerEventId: FfiConverterOptionString.read(from: &buf), + expiresAtSeconds: FfiConverterOptionUInt64.read(from: &buf) + ) + } + + public static func write(_ value: PairwiseAction, into buf: inout [UInt8]) { + FfiConverterString.write(value.actionId, into: &buf) + FfiConverterString.write(value.kind, into: &buf) + FfiConverterOptionString.write(value.sessionId, into: &buf) + FfiConverterOptionString.write(value.subscriptionId, into: &buf) + FfiConverterOptionString.write(value.filterJson, into: &buf) + FfiConverterOptionString.write(value.eventJson, into: &buf) + FfiConverterOptionString.write(value.peerPubkeyHex, into: &buf) + FfiConverterOptionString.write(value.innerEventJson, into: &buf) + FfiConverterOptionString.write(value.innerEventId, into: &buf) + FfiConverterOptionString.write(value.outerEventId, into: &buf) + FfiConverterOptionUInt64.write(value.expiresAtSeconds, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePairwiseAction_lift(_ buf: RustBuffer) throws -> PairwiseAction { + return try FfiConverterTypePairwiseAction.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePairwiseAction_lower(_ value: PairwiseAction) -> RustBuffer { + return FfiConverterTypePairwiseAction.lower(value) +} + + +public struct PairwiseSendResult { + public var innerEventId: String + public var outerEventId: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(innerEventId: String, outerEventId: String) { + self.innerEventId = innerEventId + self.outerEventId = outerEventId + } +} + +#if compiler(>=6) +extension PairwiseSendResult: Sendable {} +#endif + + +extension PairwiseSendResult: Equatable, Hashable { + public static func ==(lhs: PairwiseSendResult, rhs: PairwiseSendResult) -> Bool { + if lhs.innerEventId != rhs.innerEventId { + return false + } + if lhs.outerEventId != rhs.outerEventId { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(innerEventId) + hasher.combine(outerEventId) + } +} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePairwiseSendResult: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PairwiseSendResult { + return + try PairwiseSendResult( + innerEventId: FfiConverterString.read(from: &buf), + outerEventId: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: PairwiseSendResult, into buf: inout [UInt8]) { + FfiConverterString.write(value.innerEventId, into: &buf) + FfiConverterString.write(value.outerEventId, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePairwiseSendResult_lift(_ buf: RustBuffer) throws -> PairwiseSendResult { + return try FfiConverterTypePairwiseSendResult.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePairwiseSendResult_lower(_ value: PairwiseSendResult) -> RustBuffer { + return FfiConverterTypePairwiseSendResult.lower(value) +} + + +public struct PairwiseSessionInfo { + public var sendReady: Bool + public var receiveReady: Bool + public var trackedSenderPubkeys: [String] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(sendReady: Bool, receiveReady: Bool, trackedSenderPubkeys: [String]) { + self.sendReady = sendReady + self.receiveReady = receiveReady + self.trackedSenderPubkeys = trackedSenderPubkeys + } +} + +#if compiler(>=6) +extension PairwiseSessionInfo: Sendable {} +#endif + + +extension PairwiseSessionInfo: Equatable, Hashable { + public static func ==(lhs: PairwiseSessionInfo, rhs: PairwiseSessionInfo) -> Bool { + if lhs.sendReady != rhs.sendReady { + return false + } + if lhs.receiveReady != rhs.receiveReady { + return false + } + if lhs.trackedSenderPubkeys != rhs.trackedSenderPubkeys { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(sendReady) + hasher.combine(receiveReady) + hasher.combine(trackedSenderPubkeys) + } +} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePairwiseSessionInfo: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PairwiseSessionInfo { + return + try PairwiseSessionInfo( + sendReady: FfiConverterBool.read(from: &buf), + receiveReady: FfiConverterBool.read(from: &buf), + trackedSenderPubkeys: FfiConverterSequenceString.read(from: &buf) + ) + } + + public static func write(_ value: PairwiseSessionInfo, into buf: inout [UInt8]) { + FfiConverterBool.write(value.sendReady, into: &buf) + FfiConverterBool.write(value.receiveReady, into: &buf) + FfiConverterSequenceString.write(value.trackedSenderPubkeys, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePairwiseSessionInfo_lift(_ buf: RustBuffer) throws -> PairwiseSessionInfo { + return try FfiConverterTypePairwiseSessionInfo.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePairwiseSessionInfo_lower(_ value: PairwiseSessionInfo) -> RustBuffer { + return FfiConverterTypePairwiseSessionInfo.lower(value) } @@ -1376,16 +1335,16 @@ public enum NdrError: Swift.Error { ) case InvalidEvent(String ) - case CryptoFailure(String - ) - case StateMismatch(String - ) - case Serialization(String - ) - case InviteError(String + case PeerMismatch(String ) case SessionNotReady(String ) + case QueueFull(String + ) + case Storage(String + ) + case CryptoFailure(String + ) } @@ -1408,19 +1367,19 @@ public struct FfiConverterTypeNdrError: FfiConverterRustBuffer { case 2: return .InvalidEvent( try FfiConverterString.read(from: &buf) ) - case 3: return .CryptoFailure( + case 3: return .PeerMismatch( try FfiConverterString.read(from: &buf) ) - case 4: return .StateMismatch( + case 4: return .SessionNotReady( try FfiConverterString.read(from: &buf) ) - case 5: return .Serialization( + case 5: return .QueueFull( try FfiConverterString.read(from: &buf) ) - case 6: return .InviteError( + case 6: return .Storage( try FfiConverterString.read(from: &buf) ) - case 7: return .SessionNotReady( + case 7: return .CryptoFailure( try FfiConverterString.read(from: &buf) ) @@ -1445,27 +1404,27 @@ public struct FfiConverterTypeNdrError: FfiConverterRustBuffer { FfiConverterString.write(v1, into: &buf) - case let .CryptoFailure(v1): + case let .PeerMismatch(v1): writeInt(&buf, Int32(3)) FfiConverterString.write(v1, into: &buf) - case let .StateMismatch(v1): + case let .SessionNotReady(v1): writeInt(&buf, Int32(4)) FfiConverterString.write(v1, into: &buf) - case let .Serialization(v1): + case let .QueueFull(v1): writeInt(&buf, Int32(5)) FfiConverterString.write(v1, into: &buf) - case let .InviteError(v1): + case let .Storage(v1): writeInt(&buf, Int32(6)) FfiConverterString.write(v1, into: &buf) - case let .SessionNotReady(v1): + case let .CryptoFailure(v1): writeInt(&buf, Int32(7)) FfiConverterString.write(v1, into: &buf) @@ -1551,6 +1510,30 @@ fileprivate struct FfiConverterOptionString: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypePairwiseSessionInfo: FfiConverterRustBuffer { + typealias SwiftType = PairwiseSessionInfo? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypePairwiseSessionInfo.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypePairwiseSessionInfo.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -1579,56 +1562,31 @@ fileprivate struct FfiConverterSequenceString: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -fileprivate struct FfiConverterSequenceTypeMessagePushSessionStateResult: FfiConverterRustBuffer { - typealias SwiftType = [MessagePushSessionStateResult] +fileprivate struct FfiConverterSequenceTypePairwiseAction: FfiConverterRustBuffer { + typealias SwiftType = [PairwiseAction] - public static func write(_ value: [MessagePushSessionStateResult], into buf: inout [UInt8]) { + public static func write(_ value: [PairwiseAction], into buf: inout [UInt8]) { let len = Int32(value.count) writeInt(&buf, len) for item in value { - FfiConverterTypeMessagePushSessionStateResult.write(item, into: &buf) + FfiConverterTypePairwiseAction.write(item, into: &buf) } } - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [MessagePushSessionStateResult] { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [PairwiseAction] { let len: Int32 = try readInt(&buf) - var seq = [MessagePushSessionStateResult]() + var seq = [PairwiseAction]() seq.reserveCapacity(Int(len)) for _ in 0 ..< len { - seq.append(try FfiConverterTypeMessagePushSessionStateResult.read(from: &buf)) + seq.append(try FfiConverterTypePairwiseAction.read(from: &buf)) } return seq } } - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -fileprivate struct FfiConverterSequenceTypePubSubEvent: FfiConverterRustBuffer { - typealias SwiftType = [PubSubEvent] - - public static func write(_ value: [PubSubEvent], into buf: inout [UInt8]) { - let len = Int32(value.count) - writeInt(&buf, len) - for item in value { - FfiConverterTypePubSubEvent.write(item, into: &buf) - } - } - - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [PubSubEvent] { - let len: Int32 = try readInt(&buf) - var seq = [PubSubEvent]() - seq.reserveCapacity(Int(len)) - for _ in 0 ..< len { - seq.append(try FfiConverterTypePubSubEvent.read(from: &buf)) - } - return seq - } -} -public func derivePublicKey(privkeyHex: String)throws -> String { +public func derivePublicKey(privateKeyHex: String)throws -> String { return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { uniffi_ndr_ffi_fn_func_derive_public_key( - FfiConverterString.lower(privkeyHex),$0 + FfiConverterString.lower(privateKeyHex),$0 ) }) } @@ -1660,7 +1618,7 @@ private let initializationResult: InitializationResult = { if bindings_contract_version != scaffolding_contract_version { return InitializationResult.contractVersionMismatch } - if (uniffi_ndr_ffi_checksum_func_derive_public_key() != 46297) { + if (uniffi_ndr_ffi_checksum_func_derive_public_key() != 22065) { return InitializationResult.apiChecksumMismatch } if (uniffi_ndr_ffi_checksum_func_generate_keypair() != 57537) { @@ -1669,76 +1627,64 @@ private let initializationResult: InitializationResult = { if (uniffi_ndr_ffi_checksum_func_version() != 35402) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ndr_ffi_checksum_method_invitehandle_get_inviter_pubkey_hex() != 62322) { + if (uniffi_ndr_ffi_checksum_method_pairwiseinvite_get_peer_pubkey_hex() != 25596) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ndr_ffi_checksum_method_invitehandle_get_owner_pubkey_hex() != 17484) { + if (uniffi_ndr_ffi_checksum_method_pairwiseinvite_to_url() != 1141) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ndr_ffi_checksum_method_invitehandle_to_url() != 41511) { + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_accept_invite_from_event_json() != 42574) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_accept_invite_from_event_json() != 39696) { + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_accept_invite_from_url() != 29995) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_accept_invite_from_url() != 49858) { + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_ack_actions() != 17265) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_drain_events() != 31848) { + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_current_invite_event_json() != 41966) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_active_session_state() != 4999) { + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_current_invite_url() != 50454) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_device_id() != 8402) { + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_get_our_pubkey_hex() != 24347) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_message_push_author_pubkeys() != 45983) { + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_get_total_sessions() != 5478) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_message_push_session_states() != 64635) { + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_known_peer_pubkeys() != 21367) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_our_pubkey_hex() != 19398) { + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_pending_actions() != 4469) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_owner_pubkey_hex() != 49647) { + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_pending_actions_at() != 26221) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_total_sessions() != 21120) { + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_process_event() != 51097) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_init() != 25634) { + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_process_out_of_band_response() != 48382) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_known_peer_owner_pubkeys() != 31004) { + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_retire_peer() != 12247) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_process_event() != 18483) { + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_send_text() != 20592) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_process_out_of_band_response() != 48675) { + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_session_info() != 49395) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_send_text() != 56962) { + if (uniffi_ndr_ffi_checksum_constructor_pairwiseinvite_from_event_json() != 9371) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_send_text_with_inner_id() != 55155) { + if (uniffi_ndr_ffi_checksum_constructor_pairwiseinvite_from_url() != 12100) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_setup_user() != 41291) { - return InitializationResult.apiChecksumMismatch - } - if (uniffi_ndr_ffi_checksum_constructor_invitehandle_from_event_json() != 46387) { - return InitializationResult.apiChecksumMismatch - } - if (uniffi_ndr_ffi_checksum_constructor_invitehandle_from_url() != 28197) { - return InitializationResult.apiChecksumMismatch - } - if (uniffi_ndr_ffi_checksum_constructor_sessionmanagerhandle_new() != 47765) { - return InitializationResult.apiChecksumMismatch - } - if (uniffi_ndr_ffi_checksum_constructor_sessionmanagerhandle_new_with_storage_path() != 5699) { + if (uniffi_ndr_ffi_checksum_constructor_pairwisemanager_new_with_storage_path() != 24319) { return InitializationResult.apiChecksumMismatch } diff --git a/localPackages/NdrFfi/Tests/NdrFfiTests.swift b/localPackages/NdrFfi/Tests/NdrFfiTests.swift index 2633300c..1095e342 100644 --- a/localPackages/NdrFfi/Tests/NdrFfiTests.swift +++ b/localPackages/NdrFfi/Tests/NdrFfiTests.swift @@ -1,225 +1,364 @@ +import Foundation import XCTest @testable import NdrFfi final class NdrFfiTests: XCTestCase { + func testVersionAndKeyGeneration() throws { + XCTAssertFalse(NdrFfi.version().isEmpty) - // MARK: - Version Tests - - func testVersion() { - let v = NdrFfi.version() - XCTAssertFalse(v.isEmpty, "Version should not be empty") - print("ndr-ffi version: \(v)") + let first = generateKeypair() + let second = generateKeypair() + XCTAssertEqual(first.publicKeyHex.count, 64) + XCTAssertEqual(first.privateKeyHex.count, 64) + XCTAssertNotNil(Data(hexString: first.publicKeyHex)) + XCTAssertNotNil(Data(hexString: first.privateKeyHex)) + XCTAssertNotEqual(first.publicKeyHex, second.publicKeyHex) + XCTAssertNotEqual(first.privateKeyHex, second.privateKeyHex) + XCTAssertEqual( + try derivePublicKey(privateKeyHex: first.privateKeyHex), + first.publicKeyHex + ) } - // MARK: - Keypair Tests - - func testKeypairGeneration() { - let keypair = generateKeypair() - - XCTAssertEqual(keypair.publicKeyHex.count, 64, "Public key should be 64 hex characters") - XCTAssertEqual(keypair.privateKeyHex.count, 64, "Private key should be 64 hex characters") - - // Verify they're valid hex - XCTAssertNotNil(Data(hexString: keypair.publicKeyHex), "Public key should be valid hex") - XCTAssertNotNil(Data(hexString: keypair.privateKeyHex), "Private key should be valid hex") - - print("Generated keypair - pubkey: \(keypair.publicKeyHex.prefix(16))...") - } - - func testMultipleKeypairsAreDifferent() { - let kp1 = generateKeypair() - let kp2 = generateKeypair() - - XCTAssertNotEqual(kp1.publicKeyHex, kp2.publicKeyHex, "Different keypairs should have different public keys") - XCTAssertNotEqual(kp1.privateKeyHex, kp2.privateKeyHex, "Different keypairs should have different private keys") - } - - // MARK: - SessionManager Tests - - func testSessionManagerInitEmitsInviteEvent() throws { + func testCurrentInviteIsIdentityBoundKind30078() throws { let keys = generateKeypair() - let mgr = try SessionManagerHandle( - ourPubkeyHex: keys.publicKeyHex, - ourIdentityPrivkeyHex: keys.privateKeyHex, - deviceId: "test-device", - ownerPubkeyHex: nil - ) - try mgr.`init`() + let manager = try makeManager(keys) + let inviteJSON = try manager.currentInviteEventJson() + let invite = try PairwiseInvite.fromEventJson(eventJson: inviteJSON) - let events = try mgr.drainEvents() - let inviteEventJson = try XCTUnwrap( - events.first(where: { isInvitePublish($0) })?.eventJson, - "Expected SessionManager to publish an invite on init" + XCTAssertEqual(try extractNostrKind(json: inviteJSON), 30078) + XCTAssertEqual(invite.getPeerPubkeyHex(), keys.publicKeyHex) + XCTAssertEqual( + try PairwiseInvite.fromUrl( + url: invite.toUrl(root: "https://b") + ).getPeerPubkeyHex(), + keys.publicKeyHex ) - XCTAssertEqual(try extractNostrKind(json: inviteEventJson), 30078) } - func testSessionManagerAcceptInviteFromEventJsonEstablishesSession() throws { - let alice = generateKeypair() - let bob = generateKeypair() + func testAuthenticatedHandshakeBecomesBidirectionallySendReady() throws { + let aliceKeys = generateKeypair() + let bobKeys = generateKeypair() + let alice = try makeManager(aliceKeys) + let bob = try makeManager(bobKeys) - let aliceMgr = try SessionManagerHandle( - ourPubkeyHex: alice.publicKeyHex, - ourIdentityPrivkeyHex: alice.privateKeyHex, - deviceId: "alice-device", - ownerPubkeyHex: nil + let artifacts = try establishSession( + inviter: alice, + inviterKeys: aliceKeys, + acceptor: bob, + acceptorKeys: bobKeys ) - let bobMgr = try SessionManagerHandle( - ourPubkeyHex: bob.publicKeyHex, - ourIdentityPrivkeyHex: bob.privateKeyHex, - deviceId: "bob-device", - ownerPubkeyHex: nil + + XCTAssertEqual(artifacts.response.peerPubkeyHex, aliceKeys.publicKeyHex) + XCTAssertEqual(try extractNostrKind(json: artifacts.responseJSON), 1059) + XCTAssertEqual(try extractNostrKind(json: artifacts.bootstrapJSON), 1060) + XCTAssertEqual( + try alice.sessionInfo(peerPubkeyHex: bobKeys.publicKeyHex)? + .sendReady, + true ) - try aliceMgr.`init`() - try bobMgr.`init`() - - let aliceInitEvents = try aliceMgr.drainEvents() - _ = try bobMgr.drainEvents() // discard Bob init invite - - let aliceInviteEventJson = try XCTUnwrap( - aliceInitEvents.first(where: { isInvitePublish($0) })?.eventJson, - "Expected Alice to publish an invite on init" + XCTAssertEqual( + try bob.sessionInfo(peerPubkeyHex: aliceKeys.publicKeyHex)? + .sendReady, + true ) - XCTAssertEqual(try extractNostrKind(json: aliceInviteEventJson), 30078) - - let accept = try bobMgr.acceptInviteFromEventJson(eventJson: aliceInviteEventJson, ownerPubkeyHintHex: nil) - XCTAssertTrue(accept.createdNewSession) - - let bobAfterAccept = try bobMgr.drainEvents() - let responseEventJson = try XCTUnwrap( - bobAfterAccept.first(where: { $0.kind == "publish_signed" && ((try? extractNostrKind(json: $0.eventJson ?? "")) == 1059) })?.eventJson, - "Expected Bob to publish a giftwrap response after accepting invite" - ) - XCTAssertEqual(try extractNostrKind(json: responseEventJson), 1059) - - try aliceMgr.processEvent(eventJson: responseEventJson) - _ = try aliceMgr.drainEvents() - - XCTAssertNotNil(try aliceMgr.getActiveSessionState(peerPubkeyHex: bob.publicKeyHex)) - XCTAssertNotNil(try bobMgr.getActiveSessionState(peerPubkeyHex: alice.publicKeyHex)) } - func testSessionManagerSendTextDecryptsOnOtherSide() throws { - let alice = generateKeypair() - let bob = generateKeypair() - - let aliceMgr = try SessionManagerHandle( - ourPubkeyHex: alice.publicKeyHex, - ourIdentityPrivkeyHex: alice.privateKeyHex, - deviceId: "alice-device", - ownerPubkeyHex: nil + func testSendProducesDurableUnsignedDeliveryWithExpiration() throws { + let aliceKeys = generateKeypair() + let bobKeys = generateKeypair() + let alice = try makeManager(aliceKeys) + let bob = try makeManager(bobKeys) + _ = try establishSession( + inviter: alice, + inviterKeys: aliceKeys, + acceptor: bob, + acceptorKeys: bobKeys ) - let bobMgr = try SessionManagerHandle( - ourPubkeyHex: bob.publicKeyHex, - ourIdentityPrivkeyHex: bob.privateKeyHex, - deviceId: "bob-device", - ownerPubkeyHex: nil - ) - try aliceMgr.`init`() - try bobMgr.`init`() - let aliceInvite = try XCTUnwrap( - try aliceMgr.drainEvents().first(where: { isInvitePublish($0) })?.eventJson + let expiration = UInt64(Date().timeIntervalSince1970) + 60 + let result = try bob.sendText( + peerPubkeyHex: aliceKeys.publicKeyHex, + text: "hello from bob", + expiresAtSeconds: expiration ) - _ = try bobMgr.drainEvents() // discard Bob init invite - - _ = try bobMgr.acceptInviteFromEventJson(eventJson: aliceInvite, ownerPubkeyHintHex: nil) - let bobAfterAccept = try bobMgr.drainEvents() - let bobResponse = try XCTUnwrap( - bobAfterAccept.first(where: { $0.kind == "publish_signed" && ((try? extractNostrKind(json: $0.eventJson ?? "")) == 1059) })?.eventJson + let publish = try requireAction( + in: bob, + kind: "publish", + outerEventID: result.outerEventId ) - try aliceMgr.processEvent(eventJson: bobResponse) - _ = try aliceMgr.drainEvents() + let outerJSON = try XCTUnwrap(publish.eventJson) + try alice.processEvent(eventJson: outerJSON) - _ = try bobMgr.sendText(recipientPubkeyHex: alice.publicKeyHex, text: "hello from bob", expiresAtSeconds: nil) - let bobOutbound = try bobMgr.drainEvents().compactMap { e -> String? in - guard e.kind == "publish_signed", let json = e.eventJson else { return nil } - return ((try? extractNostrKind(json: json)) == 1060) ? json : nil + let delivery = try requireAction( + in: alice, + kind: "delivery", + innerEventID: result.innerEventId + ) + let innerJSON = try XCTUnwrap(delivery.innerEventJson) + let inner = try jsonObject(innerJSON) + XCTAssertEqual(inner["kind"] as? Int, 14) + XCTAssertEqual(inner["pubkey"] as? String, bobKeys.publicKeyHex) + XCTAssertEqual(inner["content"] as? String, "hello from bob") + XCTAssertNil(inner["sig"] as? String) + XCTAssertEqual(delivery.peerPubkeyHex, bobKeys.publicKeyHex) + XCTAssertEqual(delivery.outerEventId, result.outerEventId) + XCTAssertEqual(delivery.expiresAtSeconds, expiration) + + try bob.ackActions(actionIds: [publish.actionId]) + try alice.ackActions(actionIds: [delivery.actionId]) + XCTAssertFalse( + try bob.pendingActions().contains { + $0.actionId == publish.actionId + } + ) + XCTAssertFalse( + try alice.pendingActions().contains { + $0.actionId == delivery.actionId + } + ) + } + + func testSameSecondSendsHaveDistinctIDs() throws { + let aliceKeys = generateKeypair() + let bobKeys = generateKeypair() + let alice = try makeManager(aliceKeys) + let bob = try makeManager(bobKeys) + _ = try establishSession( + inviter: alice, + inviterKeys: aliceKeys, + acceptor: bob, + acceptorKeys: bobKeys + ) + + let first = try bob.sendText( + peerPubkeyHex: aliceKeys.publicKeyHex, + text: "first", + expiresAtSeconds: nil + ) + let second = try bob.sendText( + peerPubkeyHex: aliceKeys.publicKeyHex, + text: "second", + expiresAtSeconds: nil + ) + + XCTAssertNotEqual(first.innerEventId, second.innerEventId) + XCTAssertNotEqual(first.outerEventId, second.outerEventId) + } + + func testPendingPublishAndDeliverySurviveRestart() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "ndr-ffi-restart-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let alicePath = root.appendingPathComponent("alice").path + let bobPath = root.appendingPathComponent("bob").path + let aliceKeys = generateKeypair() + let bobKeys = generateKeypair() + var outerJSON = "" + var result: PairwiseSendResult? + + do { + let alice = try makeManager(aliceKeys, storagePath: alicePath) + let bob = try makeManager(bobKeys, storagePath: bobPath) + _ = try establishSession( + inviter: alice, + inviterKeys: aliceKeys, + acceptor: bob, + acceptorKeys: bobKeys + ) + let sent = try bob.sendText( + peerPubkeyHex: aliceKeys.publicKeyHex, + text: "survives restart", + expiresAtSeconds: nil + ) + result = sent + outerJSON = try XCTUnwrap( + requireAction( + in: bob, + kind: "publish", + outerEventID: sent.outerEventId + ).eventJson + ) } - XCTAssertFalse(bobOutbound.isEmpty, "Expected at least one kind 1060 message to publish") - for eventJson in bobOutbound { - try aliceMgr.processEvent(eventJson: eventJson) + let sent = try XCTUnwrap(result) + do { + let restoredBob = try makeManager( + bobKeys, + storagePath: bobPath + ) + XCTAssertNotNil( + try restoredBob.pendingActions().first { + $0.outerEventId == sent.outerEventId + && $0.kind == "publish" + } + ) } - let aliceEvents = try aliceMgr.drainEvents() - let decryptedInner = try XCTUnwrap( - aliceEvents.first(where: { $0.kind == "decrypted_message" })?.content, - "Expected a decrypted inner event to surface" + + do { + let restoredAlice = try makeManager( + aliceKeys, + storagePath: alicePath + ) + try restoredAlice.processEvent(eventJson: outerJSON) + } + let restoredAgain = try makeManager( + aliceKeys, + storagePath: alicePath + ) + let delivery = try requireAction( + in: restoredAgain, + kind: "delivery", + innerEventID: sent.innerEventId + ) + XCTAssertEqual( + try jsonObject( + XCTUnwrap(delivery.innerEventJson) + )["content"] as? String, + "survives restart" ) - XCTAssertEqual(try innerEventContent(json: decryptedInner), "hello from bob") } - func testSessionManagerRejectsInvalidInviteEventJson() throws { - let keys = generateKeypair() - let mgr = try SessionManagerHandle( - ourPubkeyHex: keys.publicKeyHex, - ourIdentityPrivkeyHex: keys.privateKeyHex, - deviceId: "test-device", - ownerPubkeyHex: nil - ) - try mgr.`init`() + func testInvalidInviteAndAuthenticatedPeerMismatchAreRejected() throws { + let aliceKeys = generateKeypair() + let bobKeys = generateKeypair() + let unexpectedKeys = generateKeypair() + let alice = try makeManager(aliceKeys) + let bob = try makeManager(bobKeys) - let notAnInvite = """ - {"kind":1,"id":"test","pubkey":"test","created_at":0,"content":"hello","tags":[],"sig":"test"} - """ - XCTAssertThrowsError(try mgr.acceptInviteFromEventJson(eventJson: notAnInvite, ownerPubkeyHintHex: nil)) + XCTAssertThrowsError( + try bob.acceptInviteFromEventJson( + eventJson: + #"{"kind":1,"id":"bad","pubkey":"bad","created_at":0,"content":"","tags":[],"sig":"bad"}"#, + authenticatedPeerPubkeyHex: aliceKeys.publicKeyHex + ) + ) + XCTAssertThrowsError( + try bob.acceptInviteFromEventJson( + eventJson: alice.currentInviteEventJson(), + authenticatedPeerPubkeyHex: unexpectedKeys.publicKeyHex + ) + ) + } + + private func makeManager( + _ keys: FfiKeyPair, + storagePath: String? = nil + ) throws -> PairwiseManager { + let path: String + if let storagePath { + path = storagePath + } else { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "ndr-ffi-test-\(UUID().uuidString)", + isDirectory: true + ) + path = directory.path + addTeardownBlock { + try? FileManager.default.removeItem(at: directory) + } + } + return try PairwiseManager.newWithStoragePath( + ourPubkeyHex: keys.publicKeyHex, + ourIdentityPrivateKeyHex: keys.privateKeyHex, + storagePath: path + ) } } -// MARK: - Helper Extensions +private struct HandshakeArtifacts { + let response: PairwiseAction + let responseJSON: String + let bootstrapJSON: String +} -extension Data { +private func establishSession( + inviter: PairwiseManager, + inviterKeys: FfiKeyPair, + acceptor: PairwiseManager, + acceptorKeys: FfiKeyPair +) throws -> HandshakeArtifacts { + let inviteJSON = try inviter.currentInviteEventJson() + let accepted = try acceptor.acceptInviteFromEventJson( + eventJson: inviteJSON, + authenticatedPeerPubkeyHex: inviterKeys.publicKeyHex + ) + XCTAssertTrue(accepted.createdNewSession) + + let response = try requireAction(in: acceptor, kind: "out_of_band") + let responseJSON = try XCTUnwrap(response.eventJson) + let bootstrap = try requireAction(in: acceptor, kind: "publish") + let bootstrapJSON = try XCTUnwrap(bootstrap.eventJson) + try inviter.processOutOfBandResponse( + eventJson: responseJSON, + authenticatedPeerPubkeyHex: acceptorKeys.publicKeyHex + ) + XCTAssertEqual( + try inviter.sessionInfo( + peerPubkeyHex: acceptorKeys.publicKeyHex + )?.sendReady, + false + ) + try inviter.processEvent(eventJson: bootstrapJSON) + try acceptor.ackActions( + actionIds: [response.actionId, bootstrap.actionId] + ) + return HandshakeArtifacts( + response: response, + responseJSON: responseJSON, + bootstrapJSON: bootstrapJSON + ) +} + +private func requireAction( + in manager: PairwiseManager, + kind: String, + innerEventID: String? = nil, + outerEventID: String? = nil +) throws -> PairwiseAction { + try XCTUnwrap( + try manager.pendingActions().first { action in + action.kind == kind + && (innerEventID == nil || action.innerEventId == innerEventID) + && (outerEventID == nil || action.outerEventId == outerEventID) + }, + "Expected pending \(kind) action" + ) +} + +private func extractNostrKind(json: String) throws -> Int { + try XCTUnwrap( + jsonObject(json)["kind"] as? Int, + "Event should have an integer kind" + ) +} + +private func jsonObject(_ json: String) throws -> [String: Any] { + try XCTUnwrap( + JSONSerialization.jsonObject( + with: Data(json.utf8), + options: [] + ) as? [String: Any], + "Expected a JSON object" + ) +} + +private extension Data { init?(hexString: String) { - let len = hexString.count / 2 - var data = Data(capacity: len) - var i = hexString.startIndex - for _ in 0.. Int { - let data = Data(json.utf8) - let obj = try JSONSerialization.jsonObject(with: data, options: []) - guard let dict = obj as? [String: Any] else { throw NSError(domain: "NdrFfiTests", code: 1) } - guard let kind = dict["kind"] as? Int else { throw NSError(domain: "NdrFfiTests", code: 2) } - return kind -} - -private func isInvitePublish(_ event: PubSubEvent) -> Bool { - guard event.kind == "publish_signed", - let json = event.eventJson, - (try? extractNostrKind(json: json)) == 30078, - let tags = try? extractNostrTags(json: json) else { - return false - } - return tags.contains { tag in - tag.count >= 2 && tag[0] == "l" && tag[1] == "double-ratchet/invites" - } || tags.contains { tag in - tag.count >= 2 && tag[0] == "d" && tag[1].hasPrefix("double-ratchet/invites/") - } -} - -private func extractNostrTags(json: String) throws -> [[String]] { - let data = Data(json.utf8) - let obj = try JSONSerialization.jsonObject(with: data, options: []) - guard let dict = obj as? [String: Any] else { throw NSError(domain: "NdrFfiTests", code: 5) } - return dict["tags"] as? [[String]] ?? [] -} - -private func innerEventContent(json: String) throws -> String { - let data = Data(json.utf8) - let obj = try JSONSerialization.jsonObject(with: data, options: []) - guard let dict = obj as? [String: Any] else { throw NSError(domain: "NdrFfiTests", code: 3) } - guard let content = dict["content"] as? String else { throw NSError(domain: "NdrFfiTests", code: 4) } - return content -} diff --git a/localPackages/NdrFfi/VENDORED_FROM.md b/localPackages/NdrFfi/VENDORED_FROM.md index 803661fb..c0268f06 100644 --- a/localPackages/NdrFfi/VENDORED_FROM.md +++ b/localPackages/NdrFfi/VENDORED_FROM.md @@ -2,22 +2,20 @@ `NdrFfi` is built from source using: -- Upstream repository: `https://github.com/irislib/iris-chat-rs` -- Upstream crate: `protocol-ffi` (`iris-chat-protocol-ffi`, library `ndr_ffi`) -- Upstream base: `main` at `33f7732bbd300ed62fdf5bcf9da0a176efa7ff8c` -- Pinned source commit: `095e70489345df4d92dded686902f3dccb54cc45` -- Source branch: `codex/bitchat-ffi-hardening` -- Latest double-ratchet crates: `nostr-double-ratchet` `0.0.164` and - `nostr-double-ratchet-pairwise-codec` `0.0.164`, checksum-pinned by - `protocol-ffi/Cargo.lock` +- Upstream repository: `https://github.com/irislib/nostr-double-ratchet` +- Upstream base: `master` at `c93f76a2b947f4288d2c7bcbecabe70ce197da5f` +- Pinned source commit: `0fe8caf2d4e24e2030ffae195597a2764613a659` +- Crate: `ndr-pairwise-ffi` (library `ndr_ffi`) +- Runtime: durable single-identity pairwise sessions only; no AppKeys, + linked-device, sibling-sync, or group runtime - Rebuild script: `build-apple.sh` - Rust compiler: `1.95.0` (pinned by `RUST_TOOLCHAIN`) - Release Rust flags: `-C panic=abort -C strip=debuginfo` - Packaging: embedded dynamic XCFramework, isolating its Rust runtime from Arti's independent static Rust runtime - Cargo builds use `--locked`. -- The pinned source commit transactionally binds invite responses to an - expected account owner and preserves account/device attribution in UniFFI. +- The pinned source commit provides durable pairwise state/action ordering, + targeted peer retirement, and portable exclusive storage locking. Generated/build outputs: diff --git a/localPackages/NdrFfi/build-apple.sh b/localPackages/NdrFfi/build-apple.sh index 19f380d8..95ee24f3 100755 --- a/localPackages/NdrFfi/build-apple.sh +++ b/localPackages/NdrFfi/build-apple.sh @@ -4,12 +4,15 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PACKAGE_DIR="$SCRIPT_DIR" REPOSITORY_DIR="$(cd "$PACKAGE_DIR/../.." && pwd)" -SOURCE_DIR="${1:-${IRIS_CHAT_RS_DIR:-$REPOSITORY_DIR/vendor/iris-chat-rs}}" -CRATE_DIR="$SOURCE_DIR/protocol-ffi" +SOURCE_DIR="${1:-${NOSTR_DOUBLE_RATCHET_DIR:-$REPOSITORY_DIR/vendor/nostr-double-ratchet}}" +CRATE_DIR="$SOURCE_DIR/rust/crates/ndr-pairwise-ffi" CRATE_MANIFEST="$CRATE_DIR/Cargo.toml" -BINDGEN_MANIFEST="$SOURCE_DIR/core/uniffi-bindgen/Cargo.toml" +BINDGEN_MANIFEST="$CRATE_MANIFEST" EXPECTED_REVISION="$(tr -d '[:space:]' < "$PACKAGE_DIR/SOURCE_REVISION")" EXPECTED_RUST="$(tr -d '[:space:]' < "$PACKAGE_DIR/RUST_TOOLCHAIN")" +# A user-level Cargo config may point at a compiler cache unavailable to CI or +# the current sandbox, so this reproducible build does not use an ambient wrapper. +export RUSTC_WRAPPER="" # These are reproducibility inputs, not ambient build-machine preferences. # Keep them aligned with the application's documented minimum OS versions. @@ -19,12 +22,12 @@ FRAMEWORK_NAME="ndr_ffiFFI" INSTALL_NAME="@rpath/$FRAMEWORK_NAME.framework/$FRAMEWORK_NAME" if [[ ! -f "$CRATE_MANIFEST" ]]; then - echo "error: expected iris-chat-rs protocol-ffi crate at $CRATE_MANIFEST" >&2 + echo "error: expected nostr-double-ratchet pairwise FFI crate at $CRATE_MANIFEST" >&2 exit 1 fi if [[ ! -f "$BINDGEN_MANIFEST" ]]; then - echo "error: expected UniFFI bindgen helper at $BINDGEN_MANIFEST" >&2 + echo "error: expected UniFFI bindgen manifest at $BINDGEN_MANIFEST" >&2 exit 1 fi @@ -35,13 +38,25 @@ if [[ "$ACTUAL_RUST" != "$EXPECTED_RUST" ]]; then exit 1 fi -if [[ -d "$SOURCE_DIR/.git" ]] || git -C "$SOURCE_DIR" rev-parse --git-dir >/dev/null 2>&1; then - ACTUAL_REVISION="$(git -C "$SOURCE_DIR" rev-parse HEAD)" - if [[ "$ACTUAL_REVISION" != "$EXPECTED_REVISION" ]]; then - echo "error: iris-chat-rs is at $ACTUAL_REVISION; expected $EXPECTED_REVISION" >&2 - echo "run: git submodule update --init --checkout vendor/iris-chat-rs" >&2 - exit 1 - fi +if ! git -C "$SOURCE_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "error: nostr-double-ratchet source must be the pinned Git submodule at $SOURCE_DIR" >&2 + exit 1 +fi +SOURCE_WORKTREE="$(cd "$SOURCE_DIR" && pwd -P)" +SOURCE_GIT_ROOT="$(git -C "$SOURCE_DIR" rev-parse --show-toplevel)" +if [[ "$SOURCE_GIT_ROOT" != "$SOURCE_WORKTREE" ]]; then + echo "error: nostr-double-ratchet Git root is $SOURCE_GIT_ROOT; expected $SOURCE_WORKTREE" >&2 + exit 1 +fi +ACTUAL_REVISION="$(git -C "$SOURCE_DIR" rev-parse HEAD)" +if [[ "$ACTUAL_REVISION" != "$EXPECTED_REVISION" ]]; then + echo "error: nostr-double-ratchet is at $ACTUAL_REVISION; expected $EXPECTED_REVISION" >&2 + echo "run: git submodule update --init --checkout vendor/nostr-double-ratchet" >&2 + exit 1 +fi +if [[ -n "$(git -C "$SOURCE_DIR" status --porcelain --untracked-files=all)" ]]; then + echo "error: nostr-double-ratchet source has local changes; refusing an unreproducible build" >&2 + exit 1 fi WORK_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/ndrffi-apple.XXXXXX")" @@ -57,7 +72,7 @@ trap cleanup EXIT mkdir -p "$TARGET_DIR" "$BINDINGS_DIR" "$HEADERS_DIR" -echo "==> Building ndr_ffi compatibility artifacts from $CRATE_DIR" +echo "==> Building the pairwise ndr_ffi artifacts from $CRATE_DIR" echo " macOS minimum: $MACOS_MIN" echo " iOS minimum: $IOS_MIN" @@ -70,7 +85,7 @@ env \ env \ CARGO_TARGET_DIR="$TARGET_DIR" \ - cargo run --locked --manifest-path "$BINDGEN_MANIFEST" -- \ + cargo run --locked --manifest-path "$BINDGEN_MANIFEST" --features bindgen --bin uniffi-bindgen -- \ generate \ --library "$TARGET_DIR/debug/libndr_ffi.dylib" \ --language swift \ diff --git a/vendor/iris-chat-rs b/vendor/iris-chat-rs deleted file mode 160000 index 095e7048..00000000 --- a/vendor/iris-chat-rs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 095e70489345df4d92dded686902f3dccb54cc45 diff --git a/vendor/nostr-double-ratchet b/vendor/nostr-double-ratchet new file mode 160000 index 00000000..0fe8caf2 --- /dev/null +++ b/vendor/nostr-double-ratchet @@ -0,0 +1 @@ +Subproject commit 0fe8caf2d4e24e2030ffae195597a2764613a659