diff --git a/.github/workflows/periphery.yml b/.github/workflows/periphery.yml index e33fc7c2..717e4e20 100644 --- a/.github/workflows/periphery.yml +++ b/.github/workflows/periphery.yml @@ -18,6 +18,21 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v5 + with: + submodules: true + + - name: Build NdrFfi from pinned source + run: | + rust_version="$(tr -d '[:space:]' < localPackages/NdrFfi/RUST_TOOLCHAIN)" + rustup toolchain install "$rust_version" --profile minimal + rustup default "$rust_version" + rustup target add \ + aarch64-apple-darwin \ + x86_64-apple-darwin \ + aarch64-apple-ios \ + aarch64-apple-ios-sim \ + x86_64-apple-ios + ./localPackages/NdrFfi/build-apple.sh - name: Install Periphery # homebrew-core formula; the peripheryapp tap lags years behind. diff --git a/.github/workflows/source-manifest.yml b/.github/workflows/source-manifest.yml index e05ae34f..19501ce8 100644 --- a/.github/workflows/source-manifest.yml +++ b/.github/workflows/source-manifest.yml @@ -37,6 +37,7 @@ jobs: ref: ${{ github.event.inputs.ref || github.ref }} # Full history so the commit the tag names can be recorded exactly. fetch-depth: 0 + submodules: recursive - name: Build manifest id: build @@ -46,13 +47,34 @@ jobs: commit="$(git rev-parse HEAD)" tree="$(git rev-parse HEAD^{tree})" - # Hash every tracked file, in a stable order, with NUL separation so - # paths containing spaces or newlines cannot shift the columns. - git ls-files -z \ + # Hash every regular tracked file in a stable order. Gitlinks name a + # directory rather than a hashable file, so record and verify their + # exact commits separately. + git ls-files -s -z \ + | while IFS= read -r -d '' entry; do + metadata="${entry%%$'\t'*}" + path="${entry#*$'\t'}" + mode="${metadata%% *}" + if [ "$mode" != "160000" ]; then + printf '%s\0' "$path" + fi + done \ | sort -z \ | xargs -0 sha256sum \ > files.sha256 + : > gitlinks.tsv + git ls-files -s -z \ + | while IFS= read -r -d '' entry; do + metadata="${entry%%$'\t'*}" + path="${entry#*$'\t'}" + read -r mode object stage <<< "$metadata" + if [ "$mode" = "160000" ]; then + printf '%s\t%s\n' "$path" "$object" + fi + done \ + | sort > gitlinks.tsv + { echo "# bitchat source manifest" echo "#" @@ -60,9 +82,13 @@ jobs: echo "# commit: ${commit}" echo "# tree: ${tree}" echo "# files: $(wc -l < files.sha256 | tr -d ' ')" + while IFS=$'\t' read -r path object; do + [ -n "$path" ] && echo "# gitlink: ${path} ${object}" + done < gitlinks.tsv echo "#" echo "# Verify a checkout of this ref with:" echo "# shasum -a 256 -c files.sha256" + echo "# git submodule status --recursive" echo "# Hash checking alone ignores files this manifest does not list, and" echo "# the Xcode project compiles any source file present in the tree. So" echo "# also confirm nothing extra is present:" @@ -85,6 +111,11 @@ jobs: # is worse than none, so fail loudly rather than publishing it. grep -v '^#' SOURCE-MANIFEST.txt > check.sha256 sha256sum -c check.sha256 > /dev/null + while IFS=$'\t' read -r path expected; do + [ -z "$path" ] && continue + test "$(git rev-parse ":${path}")" = "$expected" + test "$(git -C "$path" rev-parse HEAD)" = "$expected" + done < gitlinks.tsv echo "manifest validates against this checkout" - name: Attest the manifest diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml index 81c42eb2..e8aee5ea 100644 --- a/.github/workflows/swift-tests.yml +++ b/.github/workflows/swift-tests.yml @@ -7,9 +7,56 @@ on: pull_request: jobs: + ndr-ffi-apple: + name: Build NdrFfi from pinned source + runs-on: macos-latest + timeout-minutes: 20 + + steps: + - name: Checkout code and pinned Rust source + uses: actions/checkout@v5 + with: + submodules: true + + - name: Install pinned Rust toolchain and Apple targets + run: | + rust_version="$(tr -d '[:space:]' < localPackages/NdrFfi/RUST_TOOLCHAIN)" + rustup toolchain install "$rust_version" --profile minimal + rustup default "$rust_version" + rustup target add \ + aarch64-apple-darwin \ + x86_64-apple-darwin \ + aarch64-apple-ios \ + aarch64-apple-ios-sim \ + x86_64-apple-ios + + - name: Cache Rust build inputs + uses: actions/cache@v4 + with: + path: | + ~/.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/nostr-double-ratchet/rust/Cargo.lock') }} + + - name: Build Apple FFI from source + run: ./localPackages/NdrFfi/build-apple.sh + + - name: Verify generated Swift bindings + run: git diff --exit-code -- localPackages/NdrFfi/Sources/NdrFfi/NdrFfi.swift + + - name: Upload generated XCFramework + uses: actions/upload-artifact@v4 + with: + name: ndr-ffi-xcframework + path: localPackages/NdrFfi/Frameworks/NdrFfi.xcframework + if-no-files-found: error + retention-days: 1 + test: name: Run Swift Tests (${{ matrix.name }}) runs-on: macos-latest + needs: ndr-ffi-apple # A hung test must fail fast, not hold a runner for GitHub's 360-minute # default (observed: intermittent app-suite hangs starving the queue). # The long steps carry tighter individual bounds (5-minute test watchdog, @@ -27,11 +74,20 @@ jobs: path: localPackages/BitLogger - name: BitFoundation path: localPackages/BitFoundation + - name: NdrFfi + path: localPackages/NdrFfi steps: - name: Checkout code uses: actions/checkout@v5 + - name: Download source-built NdrFfi + if: matrix.name == 'app' || matrix.name == 'NdrFfi' + uses: actions/download-artifact@v5 + with: + name: ndr-ffi-xcframework + path: localPackages/NdrFfi/Frameworks/NdrFfi.xcframework + # Use the Xcode-bundled Swift toolchain: it always matches the SDK on # the runner image. A standalone swift.org toolchain (setup-swift) broke # whenever the image's Xcode moved ahead of it ("this SDK is not @@ -138,12 +194,19 @@ jobs: ios-build: name: Build Release apps (universal) runs-on: macos-latest + needs: ndr-ffi-apple timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@v5 + - name: Download source-built NdrFfi + uses: actions/download-artifact@v5 + with: + name: ndr-ffi-xcframework + path: localPackages/NdrFfi/Frameworks/NdrFfi.xcframework + - name: Check clean recipe safety run: bash scripts/check-just-clean-safety.sh @@ -184,12 +247,19 @@ jobs: ios-tests: name: Run iOS simulator tests runs-on: macos-latest + needs: ndr-ffi-apple timeout-minutes: 20 steps: - name: Checkout code uses: actions/checkout@v5 + - name: Download source-built NdrFfi + uses: actions/download-artifact@v5 + with: + name: ndr-ffi-xcframework + path: localPackages/NdrFfi/Frameworks/NdrFfi.xcframework + # Some runner images list only placeholder destinations (rows carrying # "error:" or "unavailable") or ship the selected Xcode without a # matching iOS simulator runtime, so this walks three paths in order: diff --git a/.gitignore b/.gitignore index ac9e4d89..b4e59fc6 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,6 @@ build.log # Local configs Local.xcconfig *.profraw + +# Built from the pinned nostr-double-ratchet submodule; never commit native crypto. +localPackages/NdrFfi/Frameworks/NdrFfi.xcframework/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..743725be --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "vendor/nostr-double-ratchet"] + path = vendor/nostr-double-ratchet + url = https://github.com/irislib/nostr-double-ratchet.git + shallow = true diff --git a/Justfile b/Justfile index a3d6f6b6..92b3470f 100644 --- a/Justfile +++ b/Justfile @@ -14,6 +14,7 @@ default: @echo " just build Build the macOS app without signing" @echo " just test Run the SwiftPM test suite" @echo " just test-ios Run tests on the iPhone 17 simulator" + @echo " just ndr-ffi Build NdrFfi from the pinned Rust source" @echo " just clean Remove repo-local build artifacts only" @echo " just nuke Also remove nested package build caches" @echo " just check Validate the development environment" @@ -30,7 +31,11 @@ check: check-clean-safety @xcodebuild -version @echo "✅ Development environment ready (a signing identity is not required for just build)" -build: check +ndr-ffi: + @command -v cargo >/dev/null 2>&1 || (echo "❌ cargo not found. Install Rust with rustup." && exit 1) + @./localPackages/NdrFfi/build-apple.sh + +build: check ndr-ffi @echo "Building BitChat for macOS..." @xcodebuild -project "{{project}}" -scheme "{{macos_scheme}}" -configuration Debug -derivedDataPath "{{derived_data}}" CODE_SIGNING_ALLOWED=NO build @@ -40,10 +45,10 @@ run: build # Backward-compatible alias for the old quick-run recipe. dev-run: run -test: +test: ndr-ffi @swift test -test-ios: check +test-ios: check ndr-ffi @xcodebuild -project "{{project}}" -scheme "{{ios_scheme}}" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -derivedDataPath "{{derived_data}}" test # Artifact-only cleanup. In particular, this recipe never invokes Git and diff --git a/Package.swift b/Package.swift index 2bb83521..a33fec50 100644 --- a/Package.swift +++ b/Package.swift @@ -19,6 +19,7 @@ let package = Package( .package(path: "localPackages/Arti"), .package(path: "localPackages/BitFoundation"), .package(path: "localPackages/BitLogger"), + .package(path: "localPackages/NdrFfi"), .package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1") ], targets: [ @@ -28,7 +29,8 @@ let package = Package( .product(name: "P256K", package: "swift-secp256k1"), .product(name: "BitFoundation", package: "BitFoundation"), .product(name: "BitLogger", package: "BitLogger"), - .product(name: "Tor", package: "Arti") + .product(name: "Tor", package: "Arti"), + .product(name: "NdrFfi", package: "NdrFfi") ], path: "bitchat", exclude: [ @@ -48,7 +50,8 @@ let package = Package( name: "bitchatTests", dependencies: [ "bitchat", - .product(name: "BitFoundation", package: "BitFoundation") + .product(name: "BitFoundation", package: "BitFoundation"), + .product(name: "NdrFfi", package: "NdrFfi") ], path: "bitchatTests", exclude: [ diff --git a/README.md b/README.md index 36f50be2..50655ea9 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,26 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m ## Setup +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/nostr-double-ratchet +rustup toolchain install "$(cat localPackages/NdrFfi/RUST_TOOLCHAIN)" --profile minimal +rustup target add \ + aarch64-apple-darwin \ + x86_64-apple-darwin \ + aarch64-apple-ios \ + aarch64-apple-ios-sim \ + x86_64-apple-ios +./localPackages/NdrFfi/build-apple.sh +``` + +The generated XCFramework is ignored; every checkout builds it from the exact +`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 ```bash diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index d9239d66..f96c08d3 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -229,6 +229,7 @@ A6E3E5712E7703760032EA8A /* BitLogger */, A6E3EA802E7706A80032EA8A /* Tor */, A6BCF9492F809550001CF9B9 /* BitFoundation */, + NDRF0002000000000000000000 /* NdrFfi */, ); productName = bitchat_macOS; productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */; @@ -314,6 +315,7 @@ A6E3E56F2E77036A0032EA8A /* BitLogger */, A6E3EA7E2E7706720032EA8A /* Tor */, A6BCF9472F80953E001CF9B9 /* BitFoundation */, + NDRF0003000000000000000000 /* NdrFfi */, ); productName = bitchat_iOS; productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */; @@ -357,6 +359,7 @@ A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */, A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */, A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */, + NDRF0001000000000000000000 /* XCLocalSwiftPackageReference "localPackages/NdrFfi" */, ); preferredProjectObjectVersion = 90; projectDirPath = ""; @@ -927,6 +930,10 @@ isa = XCLocalSwiftPackageReference; relativePath = localPackages/Arti; }; + NDRF0001000000000000000000 /* XCLocalSwiftPackageReference "localPackages/NdrFfi" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = localPackages/NdrFfi; + }; /* End XCLocalSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -976,6 +983,14 @@ package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */; productName = P256K; }; + NDRF0002000000000000000000 /* NdrFfi */ = { + isa = XCSwiftPackageProductDependency; + productName = NdrFfi; + }; + NDRF0003000000000000000000 /* NdrFfi */ = { + isa = XCSwiftPackageProductDependency; + productName = NdrFfi; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 475D96681D0EA0AE57A4E06E /* Project object */; diff --git a/bitchat/Nostr/NostrIdentityBridge.swift b/bitchat/Nostr/NostrIdentityBridge.swift index 2e18a235..f69d54ba 100644 --- a/bitchat/Nostr/NostrIdentityBridge.swift +++ b/bitchat/Nostr/NostrIdentityBridge.swift @@ -2,11 +2,22 @@ import BitFoundation import Foundation import CryptoKit +enum NostrIdentityBridgeError: Error, Equatable { + case identityReadUnavailable + case invalidStoredIdentity + case identityPersistenceFailed +} + /// Bridge between Noise and Nostr identities final class NostrIdentityBridge { private let keychainService = "chat.bitchat.nostr" private let currentIdentityKey = "nostr-current-identity" private let deviceSeedKey = "nostr-device-seed" + // Multiple production owners construct their own bridge. Creation and + // panic deletion of the shared current-identity keychain item must still + // be one process-wide lifecycle, or concurrent bridges can return + // different keys or an in-flight save can resurrect a wiped identity. + private static let identityLifecycleLock = NSLock() // In-memory cache to avoid transient keychain access issues private var deviceSeedCache: Data? // Cache derived identities to avoid repeated crypto during view rendering @@ -21,10 +32,26 @@ final class NostrIdentityBridge { /// Get or create the current Nostr identity func getCurrentNostrIdentity() throws -> NostrIdentity? { - // Check if we already have a Nostr identity - if let existingData = keychain.load(key: currentIdentityKey, service: keychainService), - let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) { + Self.identityLifecycleLock.lock() + defer { Self.identityLifecycleLock.unlock() } + + switch keychain.loadWithResult( + key: currentIdentityKey, + service: keychainService + ) { + case .success(let existingData): + guard let identity = try? JSONDecoder().decode( + NostrIdentity.self, + from: existingData + ) else { + throw NostrIdentityBridgeError.invalidStoredIdentity + } return identity + case .itemNotFound: + break + case .accessDenied, .deviceLocked, .authenticationFailed, + .otherError: + throw NostrIdentityBridgeError.identityReadUnavailable } // Generate new Nostr identity @@ -33,8 +60,21 @@ final class NostrIdentityBridge { // Store it let data = try JSONEncoder().encode(nostrIdentity) keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil) - - return nostrIdentity + guard case .success(let persistedData) = keychain.loadWithResult( + key: currentIdentityKey, + service: keychainService + ), + persistedData == data, + let persistedIdentity = try? JSONDecoder().decode( + NostrIdentity.self, + from: persistedData + ), + persistedIdentity.publicKeyHex == nostrIdentity.publicKeyHex + else { + throw NostrIdentityBridgeError.identityPersistenceFailed + } + + return persistedIdentity } /// Get Nostr public key associated with a Noise public key @@ -49,6 +89,9 @@ final class NostrIdentityBridge { /// Clear all Nostr identity associations and current identity func clearAllAssociations() { + Self.identityLifecycleLock.lock() + defer { Self.identityLifecycleLock.unlock() } + // Must go through the injected keychain, not raw SecItem calls: // under test that keychain is in-memory, and a direct delete here // would wipe the developer's real Nostr identity on every test run. diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index bfef85e2..ea8b972b 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -868,6 +868,14 @@ struct NostrEvent: Codable { let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData) return xonly.isValid(signature, for: &messageBytes) } + + /// Validate the deterministic Nostr event ID without requiring a + /// signature. Ratchet-decrypted kind-14 rumors are intentionally unsigned, + /// but their IDs must still commit to their complete contents. + func hasValidEventID() -> Bool { + guard let (expectedId, _) = try? calculateEventId() else { return false } + return expectedId == id + } private func calculateEventId() throws -> (String, Data) { let serialized = [ diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 6601b863..d0346c20 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -71,6 +71,7 @@ struct NostrRelayManagerDependencies { var torIsForeground: () -> Bool var awaitTorReady: (@escaping (Bool) -> Void) -> Void var makeSession: () -> NostrRelaySessionProtocol + var verifyEventSignature: @Sendable (NostrEvent) -> Bool var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void var now: () -> Date /// Uniform random value in [0, 1) used to jitter reconnect backoff. @@ -112,6 +113,7 @@ private extension NostrRelayManagerDependencies { } }, makeSession: { URLSessionAdapter(base: TorURLSession.shared.session) }, + verifyEventSignature: { $0.isValidSignature() }, scheduleAfter: { delay, action in DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) }, @@ -198,7 +200,10 @@ final class NostrRelayManager: ObservableObject { private var hasMutualFavorites: Bool = false private var hasLocationPermission: Bool = false private var connections: [String: NostrRelayConnectionProtocol] = [:] - private var subscriptions: [String: Set] = [:] // relay URL -> active subscription IDs + // Relay URL -> subscription ID -> logical request generation currently + // installed on that socket. A same-ID REQ with a newer generation + // atomically replaces its filter under NIP-01. + private var subscriptions: [String: [String: UInt64]] = [:] // Not-yet-flushed REQs per relay, bounded by a per-relay cap (oldest by // insertion order evicted) and an age sweep on connect attempts. Dicts are // unordered, so each entry carries an insertion sequence and queue time. @@ -210,6 +215,12 @@ final class NostrRelayManager: ObservableObject { private var pendingSubscriptions: [String: [String: PendingSubscription]] = [:] // relay URL -> (subscription id -> pending REQ) private var pendingSubscriptionSequence: UInt64 = 0 private var messageHandlers: [String: (NostrEvent) -> Void] = [:] + // An EVENT is admitted before off-main signature verification and + // delivered later. The generation binds those two phases to the same + // logical subscription so unsubscribe/re-subscribe cannot retarget stale + // work to a replacement handler or poison its dedup state. + private var subscriptionGenerations: [String: UInt64] = [:] + private var nextSubscriptionGeneration: UInt64 = 0 private struct InboundEventKey: Hashable { let subscriptionID: String let eventID: String @@ -221,9 +232,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 @@ -391,6 +399,7 @@ final class NostrRelayManager: ObservableObject { /// verification means a forged-signature copy can never poison the /// dedup cache and suppress the genuine event. private func ensureRelayInboundPipeline(for relayUrl: String) { + let verifyEventSignature = dependencies.verifyEventSignature let started = inboundRouter.startPipeline(for: relayUrl) { [weak self] stream in Task.detached(priority: .userInitiated) { for await frame in stream { @@ -398,21 +407,24 @@ final class NostrRelayManager: ObservableObject { guard let self else { return } switch parsed { case .event(let subId, let event): - guard await self.precheckInboundEvent( + guard let generation = await self.precheckInboundEvent( subscriptionID: subId, eventID: event.id, relayUrl: relayUrl - ) else { - continue - } - guard event.isValidSignature() else { + ) else { continue } + guard verifyEventSignature(event) else { SecureLogger.warning( "⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)", category: .session ) continue } - await self.deliverVerifiedInboundEvent(subscriptionID: subId, event: event, from: relayUrl) + await self.deliverVerifiedInboundEvent( + subscriptionID: subId, + subscriptionGeneration: generation, + event: event, + from: relayUrl + ) case .eose, .ok, .notice: await self.handleParsedMessage(parsed, from: relayUrl) } @@ -488,8 +500,8 @@ final class NostrRelayManager: ObservableObject { subscriptions.removeAll() pendingSubscriptions.removeAll() messageHandlers.removeAll() + subscriptionGenerations.removeAll() subscriptionRequestState.removeAll() - subscribeCoalesce.removeAll() eoseTrackers.removeAll() pendingEOSECallbacks.removeAll() pendingTorConnectionURLs.removeAll() @@ -735,23 +747,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 +767,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 +775,37 @@ 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)) + let previousRequestState = subscriptionRequestState[id] + if subscriptionGenerations[id] == nil + || previousRequestState != requestState + { + nextSubscriptionGeneration &+= 1 + subscriptionGenerations[id] = nextSubscriptionGeneration + removeRecentInboundEvents(forSubscriptionID: id) + duplicateInboundEventDropCountBySubscription.removeValue( + forKey: id + ) + } + if let previousRequestState { + let removedRelayURLs = previousRequestState.relayURLs + .subtracting(requestState.relayURLs) + closeSubscription( + id: id, + on: removedRelayURLs + ) + } + messageHandlers[id] = handler + if previousRequestState == requestState, + subscriptionStateExists(id: id, requestState: requestState) + { + return true + } + subscriptionRequestState[id] = requestState // Always queue subscriptions; sending happens when a relay reports connected @@ -801,8 +836,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 } } @@ -870,30 +910,45 @@ final class NostrRelayManager: ObservableObject { /// Unsubscribe from a subscription func unsubscribe(id: String) { + var relayURLs = subscriptionRequestState[id]?.relayURLs ?? [] + for (relayURL, active) in subscriptions where active[id] != nil { + relayURLs.insert(relayURL) + } + for (relayURL, pending) in pendingSubscriptions + where pending[id] != nil { + relayURLs.insert(relayURL) + } + messageHandlers.removeValue(forKey: id) + subscriptionGenerations.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) - for url in Array(pendingSubscriptions.keys) { - pendingSubscriptions[url]?.removeValue(forKey: id) + closeSubscription(id: id, on: relayURLs) + } + + private func closeSubscription( + id: String, + on relayURLs: Set + ) { + guard !relayURLs.isEmpty else { return } + for relayURL in relayURLs { + subscriptions[relayURL]?.removeValue(forKey: id) + pendingSubscriptions[relayURL]?.removeValue(forKey: id) } - + let req = NostrRequest.close(id: id) let message = try? encoder.encode(req) - guard let messageData = message, let messageString = String(data: messageData, encoding: .utf8) else { return } - - // Send unsubscribe to all relays - for (relayUrl, connection) in connections { - if subscriptions[relayUrl]?.contains(id) == true { - subscriptions[relayUrl]?.remove(id) + + for relayURL in relayURLs { + if let connection = connections[relayURL] { connection.send(.string(messageString)) { _ in - // Local state is cleared before sending so callers can re-subscribe immediately. + // Local state is cleared first so a later same-ID REQ can + // register immediately and stale callbacks remain inert. } } } @@ -1008,10 +1063,14 @@ final class NostrRelayManager: ObservableObject { } private func subscriptionStateExists(id: String, requestState: SubscriptionRequestState) -> Bool { - guard !requestState.relayURLs.isEmpty else { return true } + guard !requestState.relayURLs.isEmpty, + let generation = subscriptionGenerations[id] + else { + return requestState.relayURLs.isEmpty + } return requestState.relayURLs.allSatisfy { url in pendingSubscriptions[url]?[id]?.messageString == requestState.messageString || - subscriptions[url]?.contains(id) == true + subscriptions[url]?[id] == generation } } @@ -1211,12 +1270,32 @@ final class NostrRelayManager: ObservableObject { /// active subscription targeting this relay must be re-sent. private func flushPendingSubscriptions(for relayUrl: String) { guard let connection = connections[relayUrl] else { return } - var toSend = (pendingSubscriptions[relayUrl] ?? [:]).mapValues(\.messageString) - for (id, state) in subscriptionRequestState where state.relayURLs.contains(relayUrl) && toSend[id] == nil { - toSend[id] = state.messageString + var toSend: [ + String: (messageString: String, generation: UInt64) + ] = [:] + for (id, pending) in pendingSubscriptions[relayUrl] ?? [:] { + guard let state = subscriptionRequestState[id], + state.relayURLs.contains(relayUrl), + state.messageString == pending.messageString, + let generation = subscriptionGenerations[id] + else { + pendingSubscriptions[relayUrl]?.removeValue(forKey: id) + continue + } + toSend[id] = (pending.messageString, generation) } - for (id, messageString) in toSend { - if self.subscriptions[relayUrl]?.contains(id) == true { + for (id, state) in subscriptionRequestState + where state.relayURLs.contains(relayUrl) + { + guard let generation = subscriptionGenerations[id] else { + continue + } + toSend[id] = (state.messageString, generation) + } + for (id, request) in toSend { + let messageString = request.messageString + let generation = request.generation + if subscriptions[relayUrl]?[id] == generation { // Already subscribed on this relay (e.g. a tracker promoted // after an earlier flush): its EOSE is coming, count it. markEOSESubscribed(id: id, relayUrl: relayUrl) @@ -1236,12 +1315,26 @@ final class NostrRelayManager: ObservableObject { // Keep the pending entry; the next (re)connect retries it. SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session) } else { - // A stale completion from a socket that has since been - // replaced must not mark the subscription active, or - // the next connection would skip replaying it. - guard let connection, self.connections[relayUrl] === connection else { return } - self.subscriptions[relayUrl, default: []].insert(id) - self.pendingSubscriptions[relayUrl]?.removeValue(forKey: id) + // Old sockets and old same-ID filters can complete + // after a replacement has already become canonical. + guard let connection, + self.connections[relayUrl] === connection, + self.subscriptionGenerations[id] == generation, + let desired = + self.subscriptionRequestState[id], + desired.relayURLs.contains(relayUrl), + desired.messageString == messageString + else { + return + } + self.subscriptions[relayUrl, default: [:]][id] = + generation + if self.pendingSubscriptions[relayUrl]?[id]? + .messageString == messageString + { + self.pendingSubscriptions[relayUrl]? + .removeValue(forKey: id) + } } } } @@ -1286,24 +1379,49 @@ final class NostrRelayManager: ObservableObject { /// after the signature verifies (`deliverVerifiedInboundEvent`), so a /// forged-signature copy can never poison the dedup cache and suppress /// the genuine event. - private func precheckInboundEvent(subscriptionID: String, eventID: String, relayUrl: String) -> Bool { + private func precheckInboundEvent( + subscriptionID: String, + eventID: String, + relayUrl: String + ) -> UInt64? { if let index = relays.firstIndex(where: { $0.url == relayUrl }) { relays[index].messagesReceived += 1 } - guard !eventID.isEmpty else { return true } + guard messageHandlers[subscriptionID] != nil, + let generation = subscriptionGenerations[subscriptionID] + else { + // Relays are untrusted and can invent subscription IDs. Do not + // spend signature work or mutate dedup state for unsolicited + // events. + return nil + } + guard !eventID.isEmpty else { return generation } let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID) if recentInboundEventKeys.contains(key) { recordDuplicateInboundEventDrop(subscriptionID: subscriptionID) - return false + return nil } - return true + return generation } /// Second main-actor hop, after off-main signature verification: /// authoritative check-and-record (the serial pipeline means the same /// event is never in flight twice, but the record must stay atomic with /// delivery) and handler dispatch. - private func deliverVerifiedInboundEvent(subscriptionID subId: String, event: NostrEvent, from relayUrl: String) { + private func deliverVerifiedInboundEvent( + subscriptionID subId: String, + subscriptionGeneration: UInt64, + event: NostrEvent, + from relayUrl: String + ) { + guard subscriptionGenerations[subId] == subscriptionGeneration, + let handler = messageHandlers[subId] + else { + // The event was admitted by a subscription that has since been + // retired. A replacement reusing the same wire ID is a different + // lifecycle and must neither receive nor dedup against this event. + return + } guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else { return } @@ -1314,11 +1432,7 @@ final class NostrRelayManager: ObservableObject { SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session) } } - if let handler = self.messageHandlers[subId] { - handler(event) - } else { - SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session) - } + handler(event) } // Handle parsed non-EVENT messages on MainActor (state updates and handlers) @@ -1344,10 +1458,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 { @@ -1822,7 +1946,7 @@ enum NostrRequest: Encodable { } } -struct NostrFilter: Encodable { +struct NostrFilter: Codable { var ids: [String]? var authors: [String]? var kinds: [Int]? @@ -1831,7 +1955,7 @@ struct NostrFilter: Encodable { var limit: Int? // Tag filters - stored internally but encoded specially - fileprivate var tagFilters: [String: [String]]? + var tagFilters: [String: [String]]? init() { // Default initializer @@ -1841,6 +1965,29 @@ struct NostrFilter: Encodable { enum CodingKeys: String, CodingKey { case ids, authors, kinds, since, until, limit } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKey.self) + + self.ids = try container.decodeIfPresent([String].self, forKey: DynamicCodingKey(stringValue: "ids")) + self.authors = try container.decodeIfPresent([String].self, forKey: DynamicCodingKey(stringValue: "authors")) + self.kinds = try container.decodeIfPresent([Int].self, forKey: DynamicCodingKey(stringValue: "kinds")) + self.since = try container.decodeIfPresent(Int.self, forKey: DynamicCodingKey(stringValue: "since")) + self.until = try container.decodeIfPresent(Int.self, forKey: DynamicCodingKey(stringValue: "until")) + self.limit = try container.decodeIfPresent(Int.self, forKey: DynamicCodingKey(stringValue: "limit")) + + // Decode tag filters (#p, #d, etc) into internal storage without the leading '#'. + var decodedTagFilters: [String: [String]] = [:] + for key in container.allKeys { + let name = key.stringValue + guard name.hasPrefix("#") else { continue } + let tag = String(name.dropFirst()) + if let values = try container.decodeIfPresent([String].self, forKey: key) { + decodedTagFilters[tag] = values + } + } + self.tagFilters = decodedTagFilters.isEmpty ? nil : decodedTagFilters + } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: DynamicCodingKey.self) diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 608e85c2..465805ab 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -97,6 +97,10 @@ enum NoisePayloadType: UInt8 { case verifyResponse = 0x11 // Verification response // Transitive verification (web of trust) case vouch = 0x12 // Batch of vouch attestations + // Double-ratchet invite/response events exchanged only inside an + // authenticated BLE Noise session. 0x12 is already vouch on current + // clients, so the unreleased prototype value must not be reused. + case ndrEvent = 0x22 // UTF-8 Nostr event JSON or compact invite URL /// #1434 briefly used 0x09 before release. Accept it while prerelease /// builds age out, but never emit it. Decoders canonicalize both values to @@ -125,6 +129,7 @@ enum NoisePayloadType: UInt8 { case .verifyChallenge: return "verifyChallenge" case .verifyResponse: return "verifyResponse" case .vouch: return "vouch" + case .ndrEvent: return "ndrEvent" } } } diff --git a/bitchat/Services/BLE/BLENoisePacketHandler.swift b/bitchat/Services/BLE/BLENoisePacketHandler.swift index 9314f666..6e4e66d8 100644 --- a/bitchat/Services/BLE/BLENoisePacketHandler.swift +++ b/bitchat/Services/BLE/BLENoisePacketHandler.swift @@ -53,12 +53,20 @@ struct BLENoisePacketHandlerEnvironment { _ payload: Data, _ sessionGeneration: UUID ) -> Void + /// Authorizes generation-sensitive application payloads against the + /// exact session generation that decrypted them. + let authorizeNoisePayload: ( + _ peerID: PeerID, + _ type: NoisePayloadType, + _ sessionGeneration: UUID + ) -> Bool /// Delivers `.noisePayloadReceived` to the UI as one main-actor hop. let deliverNoisePayload: ( _ peerID: PeerID, _ type: NoisePayloadType, _ payload: Data, - _ timestamp: Date + _ timestamp: Date, + _ sessionGeneration: UUID ) -> Void } @@ -238,8 +246,26 @@ final class BLENoisePacketHandler { return } + guard env.authorizeNoisePayload( + peerID, + noisePayloadType, + decryption.sessionGeneration + ) else { + SecureLogger.warning( + "Dropping Noise payload without capability proof for its decrypting generation", + category: .security + ) + return + } + let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts) + env.deliverNoisePayload( + peerID, + noisePayloadType, + Data(payloadData), + ts, + decryption.sessionGeneration + ) } catch NoiseEncryptionError.transportGenerationNotReady { if isDeferredRetry { SecureLogger.warning( diff --git a/bitchat/Services/BLE/BLEOutboundFragmentPlanner.swift b/bitchat/Services/BLE/BLEOutboundFragmentPlanner.swift index 09a65616..316a232f 100644 --- a/bitchat/Services/BLE/BLEOutboundFragmentPlanner.swift +++ b/bitchat/Services/BLE/BLEOutboundFragmentPlanner.swift @@ -94,9 +94,14 @@ enum BLEOutboundFragmentPlanner { calculatedChunk = max(minimumChunkSize, bleMaxMTU - overhead) } + let linkBoundedChunk = min( + requestedMaxChunk ?? calculatedChunk, + calculatedChunk + ) + return ( fragmentVersion: fragmentVersion, - chunkSize: max(minimumChunkSize, requestedMaxChunk ?? calculatedChunk) + chunkSize: max(minimumChunkSize, linkBoundedChunk) ) } diff --git a/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift b/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift index 5176149a..da14e7ff 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/BLEOutboundPacketPolicy.swift b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift index fa0853d6..f95f7f72 100644 --- a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift +++ b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift @@ -2,7 +2,11 @@ import BitFoundation import Foundation enum BLEOutboundPacketPolicy { - private static let fragmentFrameOverhead = 13 + 8 + 8 + 13 + private static let fragmentFrameOverhead = + BinaryProtocol.v1HeaderSize + + BinaryProtocol.senderIDSize + + BinaryProtocol.recipientIDSize + + 13 static func messageID(for packet: BitchatPacket) -> String { BLEIngressLinkRegistry.messageID(for: packet) diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 0c4ecfb1..c0be6c09 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -287,6 +287,7 @@ final class BLEService: NSObject { /// Fired (off-main) when a signature-verified announce is processed — /// the bridge courier watch refreshes its tag set on new arrivals. var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)? + private let doubleRatchetEnabled: Bool #if DEBUG // Test-only tap on the outbound pipeline so multi-node tests can ferry @@ -355,8 +356,9 @@ final class BLEService: NSObject { // MARK: - Identity private var noiseService: NoiseEncryptionService - /// Injected so tests can compress the quarantine/rollback window; - /// production always passes the security-constant default. + /// Injected so tests can isolate bounded handshake windows under load; + /// production uses the security-constant defaults. + private let noiseHandshakeTimeout: TimeInterval private let noiseResponderHandshakeTimeout: TimeInterval private let identityManager: SecureIdentityStateManagerProtocol private let keychain: KeychainManagerProtocol @@ -507,6 +509,9 @@ final class BLEService: NSObject { initializeBluetoothManagers: Bool = true, incomingFileStore: BLEIncomingFileStore = BLEIncomingFileStore(), startSuspendedForPanicRecovery: Bool = false, + doubleRatchetEnabled: Bool = DoubleRatchetFeature.isEnabled, + noiseHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryHandshakeTimeout, noiseResponderHandshakeTimeout: TimeInterval = NoiseSecurityConstants.ordinaryResponderHandshakeTimeout, engineScheduler: BLEEngineScheduling = BLEEngineDispatchScheduler() @@ -517,13 +522,21 @@ final class BLEService: NSObject { self.incomingFileStore = incomingFileStore self.shouldInitializeBluetoothManagers = initializeBluetoothManagers self._isPanicSuspended = startSuspendedForPanicRecovery + self.doubleRatchetEnabled = doubleRatchetEnabled + self.noiseHandshakeTimeout = noiseHandshakeTimeout self.noiseResponderHandshakeTimeout = noiseResponderHandshakeTimeout noiseService = NoiseEncryptionService( keychain: keychain, + ordinaryHandshakeTimeout: noiseHandshakeTimeout, ordinaryResponderHandshakeTimeout: noiseResponderHandshakeTimeout ) self.identityManager = identityManager super.init() + + localIdentityState.setCapability( + .doubleRatchet, + enabled: doubleRatchetEnabled + ) configureNoiseServiceCallbacks(for: noiseService) refreshPeerIdentity() @@ -819,6 +832,7 @@ final class BLEService: NSObject { let newNoise = NoiseEncryptionService( keychain: keychain, + ordinaryHandshakeTimeout: noiseHandshakeTimeout, ordinaryResponderHandshakeTimeout: noiseResponderHandshakeTimeout ) noiseService = newNoise @@ -1139,6 +1153,51 @@ final class BLEService: NSObject { peerRegistry.capabilities(for: peerID) } + func authenticatedPeerTransportState( + _ peerID: PeerID + ) -> AuthenticatedPeerTransportState? { + let normalizedPeerID = peerID.toShort() + guard let generation = noiseService.sessionGeneration(for: normalizedPeerID), + let publicKey = noiseService.getPeerPublicKeyData(normalizedPeerID), + let fingerprint = noiseService.getPeerFingerprint(normalizedPeerID), + publicKey.sha256Fingerprint().caseInsensitiveCompare(fingerprint) == .orderedSame + else { + return nil + } + let session = privateMediaSessions.policyInputs(for: normalizedPeerID) + guard session.sessionGeneration == generation, + let observation = session.authenticatedState, + observation.sessionGeneration == generation, + observation.fingerprint.caseInsensitiveCompare(fingerprint) + == .orderedSame + else { + return nil + } + guard noiseService.sessionGeneration(for: normalizedPeerID) == generation else { + return nil + } + return AuthenticatedPeerTransportState( + capabilities: observation.capabilities, + sessionGeneration: generation, + noisePublicKey: publicKey + ) + } + + private func isNoisePayloadAuthorized( + _ type: NoisePayloadType, + from peerID: PeerID, + sessionGeneration: UUID + ) -> Bool { + guard type == .ndrEvent else { return true } + guard doubleRatchetEnabled, + let authenticated = authenticatedPeerTransportState(peerID) + else { + return false + } + return authenticated.sessionGeneration == sessionGeneration + && authenticated.capabilities.contains(.doubleRatchet) + } + func authenticatedPrivateMediaReceiptSessionGeneration( to peerID: PeerID ) -> UUID? { @@ -1968,12 +2027,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 { @@ -1982,12 +2046,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 @@ -2043,6 +2128,7 @@ final class BLEService: NSObject { if requiresPrivateMediaAdmission { privateMediaTransferAdmissions.finish(transferId) } + admissionCompletion?(false) return } } @@ -2056,6 +2142,7 @@ final class BLEService: NSObject { if let transferId { privateMediaTransferAdmissions.finish(transferId) } + admissionCompletion?(false) return } } @@ -2073,6 +2160,7 @@ final class BLEService: NSObject { transferId: transferId, requiresPrivateMediaAdmission: requiresPrivateMediaAdmission ) + admissionCompletion?(true) return } // App-initiated private media is already one opaque Noise ciphertext. @@ -2089,6 +2177,7 @@ final class BLEService: NSObject { transferId: transferId, requiresPrivateMediaAdmission: requiresPrivateMediaAdmission ) + admissionCompletion?(true) return } if requiresPrivateMediaAdmission { @@ -2099,24 +2188,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) @@ -2226,10 +2340,20 @@ final class BLEService: NSObject { centrals: [CBCentral], characteristic: CBMutableCharacteristic, context: String, - requiredAuthenticatedPeer: PeerID? + requiredAuthenticatedPeer: PeerID?, + requiredAuthenticatedTransportState: + AuthenticatedPeerTransportState? ) -> Bool { 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 linkAuth.isAuthenticated(link, for: peerID) @@ -2265,9 +2389,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 = ingressLinks.record(for: packet) var excludedPeerLinks = links(to: ingressRecord?.peerID) if requireNoiseAuthenticatedPeerLink { @@ -2277,7 +2416,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 @@ -2320,12 +2472,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) } @@ -2343,7 +2499,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) @@ -2360,7 +2518,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 @@ -2383,7 +2543,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) @@ -2395,7 +2557,9 @@ final class BLEService: NSObject { pad: false, directedOnlyPeer: peerID, requireDirectPeerLink: requireDirectPeerLink, - requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink + requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink, + requiredAuthenticatedTransportState: + requiredAuthenticatedTransportState ) } @@ -2865,6 +3029,63 @@ final class BLEService: NSObject { sendNoisePayload(payload, to: peerID) } + func sendNdrEvent( + to peerID: PeerID, + eventJson: String, + 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 else { + Task { @MainActor in completion(false) } + return + } + guard self.doubleRatchetEnabled, + let authenticated = self.authenticatedPeerTransportState(normalizedPeerID), + authenticated == expectedTransportState, + authenticated.capabilities.contains(.doubleRatchet) + else { + SecureLogger.warning( + "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() + do { + let packet = try self.makeEncryptedNoisePacket( + typedPayload, + to: normalizedPeerID, + expectedSessionGeneration: authenticated.sessionGeneration + ) + 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) } + } + } + } + // MARK: Vouching over Noise func sendVouchAttestations(_ payload: Data, to peerID: PeerID) { @@ -4280,10 +4501,19 @@ extension BLEService { for: normalizedPeerID, expected: generation, { - () -> (accepted: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) in - guard privateMediaSessions.currentGeneration(for: normalizedPeerID) == generation else { - return (false, []) + () -> ( + accepted: Bool, + completions: [@MainActor (PrivateMediaSendPolicy) -> Void], + observationChanged: Bool + ) in + guard privateMediaSessions.currentGeneration(for: normalizedPeerID) + == generation + else { + return (false, [], false) } + let previousObservation = privateMediaSessions + .policyInputs(for: normalizedPeerID) + .authenticatedState // The generation lease (plus the engine slot this section // holds) prevents rekey/session promotion from interleaving @@ -4314,9 +4544,19 @@ extension BLEService { generation: generation, capabilities: state.capabilities ) else { - return (false, []) + return (false, [], false) } - return (true, completions) + let observationChanged = previousObservation.map { + $0.fingerprint.caseInsensitiveCompare(fingerprint) + != .orderedSame + || $0.sessionGeneration != generation + || $0.capabilities != state.capabilities + } ?? true + return ( + true, + completions, + observationChanged + ) } ), application.accepted else { return } @@ -4326,6 +4566,13 @@ extension BLEService { let policy = privateMediaSendPolicy(to: normalizedPeerID) sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID) completePrivateMediaPolicyResolution(application.completions, with: policy) + if application.observationChanged { + notifyUI { [weak self] in + self?.deliverTransportEvent( + .authenticatedPeerTransportStateUpdated(normalizedPeerID) + ) + } + } } private func noteNoiseSessionCleared(for peerID: PeerID) { @@ -4396,7 +4643,8 @@ extension BLEService { private func makeEncryptedNoisePacket( _ typedPayload: Data, to peerID: PeerID, - requiresAuthenticatedPrivateMediaReceipts: Bool = false + requiresAuthenticatedPrivateMediaReceipts: Bool = false, + expectedSessionGeneration: UUID? = nil ) throws -> BitchatPacket { let encrypted: Data let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first) @@ -4413,6 +4661,12 @@ extension BLEService { for: peerID, sessionGeneration: provenGeneration ) + } else if let expectedSessionGeneration { + encrypted = try noiseService.encrypt( + typedPayload, + for: peerID, + sessionGeneration: expectedSessionGeneration + ) } else { encrypted = try noiseService.encrypt(typedPayload, for: peerID) } @@ -5109,10 +5363,20 @@ extension BLEService { to peripheral: CBPeripheral, characteristic: CBCharacteristic, priority: BLEOutboundWritePriority, - requiredAuthenticatedPeer: PeerID? + requiredAuthenticatedPeer: PeerID?, + requiredAuthenticatedTransportState: + AuthenticatedPeerTransportState? ) -> Bool { let uuid = peripheral.identifier.uuidString if let peerID = requiredAuthenticatedPeer { + if let requiredAuthenticatedTransportState { + guard BLEAuthenticatedTransportAdmission.isCurrent( + expected: requiredAuthenticatedTransportState, + current: authenticatedPeerTransportState(peerID) + ) else { + return false + } + } let link = BLEIngressLinkID.peripheral(uuid) guard linkBindings.peer(forPeripheralID: uuid) == peerID, linkAuth.isAuthenticated(link, for: peerID) else { @@ -5426,6 +5690,8 @@ extension BLEService { transferId: String? = nil, requireDirectPeerLink: Bool = false, requireNoiseAuthenticatedPeerLink: Bool = false, + requiredAuthenticatedTransportState: + AuthenticatedPeerTransportState? = nil, requiresPrivateMediaAdmission: Bool = false ) -> Bool { let request = BLEOutboundFragmentTransferRequest( @@ -5435,7 +5701,9 @@ extension BLEService { directedPeer: directedOnlyPeer, transferId: transferId, requireDirectPeerLink: requireDirectPeerLink, - requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink + requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink, + requiredAuthenticatedTransportState: + requiredAuthenticatedTransportState ) let result: BLEOutboundFragmentTransferScheduler.SubmitResult? = onEngine { @@ -5562,12 +5830,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) @@ -6865,7 +7151,14 @@ extension BLEService { sessionGeneration: generation ) }, - deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in + authorizeNoisePayload: { [weak self] peerID, type, generation in + self?.isNoisePayloadAuthorized( + type, + from: peerID, + sessionGeneration: generation + ) ?? false + }, + deliverNoisePayload: { [weak self] peerID, type, payload, timestamp, generation in if type == .privateFile { self?.fileTransferHandler.handlePrivatePayload( payload, @@ -6874,8 +7167,22 @@ extension BLEService { ) return } + guard self?.isNoisePayloadAuthorized( + type, + from: peerID, + sessionGeneration: generation + ) == true else { + return + } // Single main-actor hop delivering `.noisePayloadReceived`. self?.notifyUI { [weak self] in + guard self?.isNoisePayloadAuthorized( + type, + from: peerID, + sessionGeneration: generation + ) == true else { + return + } self?.deliverTransportEvent(.noisePayloadReceived( peerID: peerID, type: type, diff --git a/bitchat/Services/DoubleRatchetFeature.swift b/bitchat/Services/DoubleRatchetFeature.swift new file mode 100644 index 00000000..72a6039a --- /dev/null +++ b/bitchat/Services/DoubleRatchetFeature.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Rollout gate for the cross-platform double-ratchet transport. +/// +/// 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 + #else + static let isEnabled = false + #endif +} 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/MeshTransportCapabilities.swift b/bitchat/Services/MeshTransportCapabilities.swift index fd2203ea..2c8da45d 100644 --- a/bitchat/Services/MeshTransportCapabilities.swift +++ b/bitchat/Services/MeshTransportCapabilities.swift @@ -56,6 +56,22 @@ protocol MeshFileTransferring: AnyObject { ) } +/// Pairwise double-ratchet bootstrap over one exact authenticated Noise +/// generation. The public announce bit is only a discovery hint; callers use +/// this surface to obtain the generation-bound proof and to send bootstrap +/// events on the matching authenticated direct link. +protocol MeshDoubleRatchetTransporting: AnyObject { + func authenticatedPeerTransportState( + _ peerID: PeerID + ) -> AuthenticatedPeerTransportState? + func sendNdrEvent( + to peerID: PeerID, + eventJson: String, + expectedTransportState: AuthenticatedPeerTransportState, + completion: @escaping @MainActor (Bool) -> Void + ) +} + /// Live voice / push-to-talk: one encoded `VoiceBurstPacket`, /// fire-and-forget inside the Noise session (private) or as a signed /// ephemeral broadcast (public). Frames are only useful now — the diff --git a/bitchat/Services/NdrNostrService.swift b/bitchat/Services/NdrNostrService.swift new file mode 100644 index 00000000..d912d74e --- /dev/null +++ b/bitchat/Services/NdrNostrService.swift @@ -0,0 +1,1436 @@ +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 sendEventImmediately( + _ event: NostrEvent, + to relayUrls: [String]?, + completion: @escaping (Bool) -> Void + ) +} + +extension NostrRelayManager: NostrRelayManaging {} + +enum NdrDeliveryDisposition: Equatable { + case consumed + case retry +} + +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) + } + + func mark(identityPubkeyHex: String) throws { + identities.insert(identityPubkeyHex) + } + + func clear() throws { + identities.removeAll() + } +} + +@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. +/// +/// 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 storageVersionDirectory = "pairwise-v1" + private static let maximumActionsPerRetry = 128 + private static let transientRetryDelays: [TimeInterval] = [ + 0.25, 0.5, 1, 2 + ] + + var onDecryptedMessage: NdrDecryptedMessageHandler? { + didSet { + // A retired lifecycle owner can leave a delivery deferred after + // returning `.retry`. Replacing that non-nil handler is the host's + // recovery boundary just as much as installing the first handler. + if 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 manager: PairwiseManager? + private var managerEpoch: UInt64 = 0 + private var configuredForPubkeyHex: String? + 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() { + relayManager = NostrRelayManager.shared + rolloutEnabled = DoubleRatchetFeature.isEnabled + sessionMarkerStore = KeychainNdrSessionMarkerStore() + storageDirectoryProvider = { + try Self.ndrStorageDirectory() + } + retryScheduler = Self.scheduleLiveRetry + nativeOutOfBandMutationObserver = nil + } + + /// Dependency-injected initializer used by the app-target integration tests. + init( + relayManager: NostrRelayManaging, + rolloutEnabled: Bool, + storageDirectoryProvider: @escaping @MainActor () throws -> URL, + sessionMarkerStore: NdrSessionMarkerStoring? = nil, + retryScheduler: @escaping @MainActor ( + TimeInterval, + @escaping @MainActor () -> Void + ) -> Void = NdrNostrService.scheduleLiveRetry, + nativeOutOfBandMutationObserver: + (@MainActor () -> Void)? = nil + ) { + self.relayManager = relayManager + self.rolloutEnabled = rolloutEnabled + self.storageDirectoryProvider = storageDirectoryProvider + self.sessionMarkerStore = + sessionMarkerStore ?? InMemoryNdrSessionMarkerStore() + self.retryScheduler = retryScheduler + self.nativeOutOfBandMutationObserver = + nativeOutOfBandMutationObserver + } + + var isConfigured: Bool { rolloutEnabled && manager != nil } + var isRolloutEnabled: Bool { rolloutEnabled } + var configuredPubkeyHex: String? { configuredForPubkeyHex } + + func currentInviteEventJson() -> String? { + currentInviteAction()?.eventJson + } + + 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 + } + 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 { + let storageURL = try storageDirectoryProvider() + .appendingPathComponent( + Self.storageVersionDirectory, + isDirectory: true + ) + .appendingPathComponent(pubkey, isDirectory: true) + if try sessionMarkerStore.contains( + identityPubkeyHex: pubkey + ), + !Self.hasDurablePairwiseState(at: storageURL) + { + throw NdrSessionStateError.missingEstablishedState + } + let newManager = try PairwiseManager.newWithStoragePath( + ourPubkeyHex: pubkey, + ourIdentityPrivateKeyHex: + identity.privateKey.hexEncodedString(), + storagePath: storageURL.path + ) + 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 + ) + replaceManager(with: nil, configuredPubkeyHex: pubkey) + failedConfigurationPubkeyHex = pubkey + return false + } + } + + func hasActiveSession(with peerPubkeyHex: String) -> Bool { + guard rolloutEnabled, + let manager, + let peer = Self.normalizedPubkeyHex(peerPubkeyHex), + let info = try? manager.sessionInfo(peerPubkeyHex: peer) + else { + return false + } + return info.sendReady && info.receiveReady + } + + /// 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 + } + + /// 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 { + 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.error( + "NdrNostrService: failed to retire pairwise peer: \(error)", + category: .session + ) + return false + } + } + + /// 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, + persistEstablishedBinding: () -> Bool + ) -> [NdrOutOfBandAction] { + guard rolloutEnabled, + let manager, + authorization?() != false, + let expectedPeer = + Self.normalizedPubkeyHex(expectedPeerPubkeyHex) + else { + return [] + } + 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 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 + } + + // 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 { + guard let identityPubkeyHex = configuredForPubkeyHex else { + throw NdrSessionStateError + .establishedSessionMarkerWriteFailed + } + 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 { + SecureLogger.debug( + "NdrNostrService: OOB payload ignored/rejected: \(error)", + category: .session + ) + processAvailableActions() + return [] + } + + 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 + } + + func processInboundRelayEvent(_ event: NostrEvent) { + guard rolloutEnabled, let manager else { return } + processInboundNostrEvent( + event, + manager: manager, + epoch: managerEpoch + ) + } + + func pendingOutOfBandActions( + forAuthenticatedPeerPubkeyHex peerPubkeyHex: String, + releaseDeferred: Bool = false + ) -> [NdrOutOfBandAction] { + guard rolloutEnabled, + let peer = Self.normalizedPubkeyHex(peerPubkeyHex) + else { + return [] + } + 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 + + let storageDirectory = try storageDirectoryProvider() + if FileManager.default.fileExists(atPath: storageDirectory.path) { + try FileManager.default.removeItem(at: storageDirectory) + } + try sessionMarkerStore.clear() + } + + // MARK: - Durable action processing + + @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 + } + // The native runtime deliberately reuses its account-scoped + // subscription ID while rotating ephemeral sender authors. + // Register every durable replacement before acknowledging it; + // NIP-01 replaces the relay's live REQ atomically by ID. + 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 + else { + SecureLogger.warning( + "NdrNostrService: rejected disallowed or malformed relay-origin event", + category: .security + ) + return + } + + do { + try manager.processEvent(eventJson: json) + } catch { + SecureLogger.debug( + "NdrNostrService: relay event ignored/rejected: \(error)", + category: .session + ) + } + guard isCurrent(manager, epoch: epoch) else { return } + processAvailableActions() + } + + 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 + } + } + + private func releaseDeferredActions( + where shouldRelease: (PairwiseAction) -> Bool + ) { + guard let manager, + let actions = try? manager.pendingActions() + else { + return + } + for action in actions where shouldRelease(action) { + deferredActionIDs.remove(action.actionId) + transientRetryAttempts.removeValue(forKey: action.actionId) + scheduledTransientRetryTokens.removeValue( + forKey: action.actionId + ) + } + } + + // 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 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 { + case eventJSON + case url + } + + private enum ValidatedOutOfBandMutation { + case invite(OutOfBandInviteTransport) + case response + } + + private struct ParsedOutOfBandInvite { + let peerPubkeyHex: String + let transport: OutOfBandInviteTransport + } + + private func parseOutOfBandInvite( + _ payload: String + ) -> ParsedOutOfBandInvite? { + if payload.first == "{" { + 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( + peerPubkeyHex: peer, + transport: .eventJSON + ) + } + + guard let invite = try? PairwiseInvite.fromUrl(url: payload), + let peer = + Self.normalizedPubkeyHex(invite.getPeerPubkeyHex()) + else { + return nil + } + return ParsedOutOfBandInvite( + peerPubkeyHex: peer, + transport: .url + ) + } + + private static func isAllowedNdrSubscription( + _ filter: NostrFilter + ) -> Bool { + guard filter.kinds == [1060], + let authors = filter.authors, + !authors.isEmpty + else { + return false + } + return authors.allSatisfy { normalizedPubkeyHex($0) != nil } + } + + 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 false + } + return event.kind == 1059 + && event.isValidSignature() + && NostrEvent.isWithinInboundTagLimits(event.tags) + } + + private static func normalizedPubkeyHex(_ value: String) -> String? { + let lowered = value.lowercased() + guard lowered.count == 64, + lowered.allSatisfy(\.isHexDigit), + Data(hexString: lowered)?.count == 32 + else { + return nil + } + return lowered + } + + // 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) + } + activeSubIDs.removeAll() + inFlightActionIDs.removeAll() + deferredActionIDs.removeAll() + transientRetryAttempts.removeAll() + scheduledTransientRetryTokens.removeAll() + continuationScheduled = false + manager = replacement + configuredForPubkeyHex = configuredPubkeyHex + } + + private func markEstablishedSessionIfNeeded( + manager: PairwiseManager, + identityPubkeyHex: String + ) throws { + guard !(try manager.knownPeerPubkeys()).isEmpty else { return } + try sessionMarkerStore.mark( + identityPubkeyHex: identityPubkeyHex.lowercased() + ) + } + + 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 = directory + try protectedDirectory.setResourceValues(resourceValues) +#if os(iOS) + try fileManager.setAttributes( + [ + .protectionKey: + FileProtectionType + .completeUntilFirstUserAuthentication + ], + ofItemAtPath: directory.path + ) +#endif + 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/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 5ee7608d..f4bcec2a 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -888,6 +888,27 @@ final class NoiseEncryptionService { return try sessionManager.encrypt(data, for: peerID) } + /// Encrypts only under the exact authenticated session generation that + /// authorized the caller's capability decision. Rekey/removal between + /// proof lookup and encryption fails closed. + func encrypt( + _ data: Data, + for peerID: PeerID, + sessionGeneration: UUID + ) throws -> Data { + guard NoiseSecurityValidator.validateMessageSize(data) else { + throw NoiseSecurityError.messageTooLarge + } + guard rateLimiter.allowMessage(from: peerID) else { + throw NoiseSecurityError.rateLimitExceeded + } + return try sessionManager.encrypt( + data, + for: peerID, + expectedSessionGeneration: sessionGeneration + ) + } + /// Encrypts a finalized private-media packet. Ordinary Noise application /// messages retain the 64 KiB ceiling; this purpose-specific path permits /// the bounded `BitchatFilePacket` envelope and refuses every other typed diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index 2d1c380b..7885b280 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -5,11 +5,54 @@ import Combine // Minimal Nostr transport conforming to Transport for offline sending final class NostrTransport: Transport, @unchecked Sendable { + enum OutboundPrivateMessageTransport: String, Codable { + case ndr + case legacy1059 + } + + enum OutboundPrivateMessageError: LocalizedError { + case missingRecipientNpub(String) + case invalidRecipientNpub(String) + case missingSenderIdentity + case failedToEncodePacket + case failedToBuildFallbackEvent + case ndrSessionFailure + case expiringMessageRequiresNdrSession + + var errorDescription: String? { + switch self { + case .missingRecipientNpub(let peerID): + return "Missing recipient Nostr public key for peer \(peerID)" + case .invalidRecipientNpub(let npub): + return "Recipient Nostr public key is invalid: \(npub)" + case .missingSenderIdentity: + return "Local Nostr identity is unavailable" + case .failedToEncodePacket: + 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 @@ -27,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, @@ -38,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 @@ -55,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) }, @@ -83,7 +140,7 @@ final class NostrTransport: Transport, @unchecked Sendable { /// Ack pacing shared across transport instances. Geohash acks are sent /// through short-lived transports created per ack - /// (`makeGeohashNostrTransport()`), so a per-instance queue would only + /// (`makeNostrTransport()`), so a per-instance queue would only /// ever hold one item and never pace a burst (flagged by Codex on /// #1398). Production wires `sharedAckPacer` via `Dependencies.live`; /// tests get an isolated instance per `Dependencies` by default. @@ -129,6 +186,7 @@ final class NostrTransport: Transport, @unchecked Sendable { } static let sharedAckPacer = AckPacer() private let dependencies: Dependencies + private let ndrService: NdrNostrService private var favoriteStatusObserver: NSObjectProtocol? // Reachability Cache (thread-safe) @@ -143,16 +201,23 @@ final class NostrTransport: Transport, @unchecked Sendable { init( keychain _: KeychainManagerProtocol, idBridge: NostrIdentityBridge, + ndrService: NdrNostrService? = nil, dependencies: Dependencies? = nil ) { self.dependencies = dependencies ?? .live(idBridge: idBridge) + self.ndrService = ndrService ?? .shared setupObservers() // 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) { @@ -186,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 @@ -253,15 +323,97 @@ final class NostrTransport: Transport, @unchecked Sendable { func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { Task { @MainActor in - guard let recipientNpub = resolveRecipientNpub(for: peerID), - let recipientHex = npubToHex(recipientNpub), - let senderIdentity = try? dependencies.currentIdentity() else { return } - SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session) - guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { - SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session) - return + do { + _ = try sendPrivateMessageAndReturnTransport( + content, + to: peerID, + recipientNickname: recipientNickname, + messageID: messageID + ) + } catch { + SecureLogger.error("NostrTransport: failed to send PM: \(error)", category: .session) } - sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity) + } + } + + 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, + 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) + } + guard let recipientHex = npubToHex(recipientNpub) else { + throw OutboundPrivateMessageError.invalidRecipientNpub(recipientNpub) + } + guard let senderIdentity = try dependencies.currentIdentity() else { + throw OutboundPrivateMessageError.missingSenderIdentity + } + SecureLogger.debug( + "NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", + category: .session + ) + guard let embedded = NostrEmbeddedBitChat.encodePMForNostr( + content: content, + messageID: messageID, + recipientPeerID: peerID, + senderPeerID: senderPeerID + ) else { + throw OutboundPrivateMessageError.failedToEncodePacket + } + switch sendWrappedMessage( + content: embedded, + recipientHex: recipientHex, + 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 } } @@ -287,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) + ) } } @@ -319,7 +477,13 @@ extension NostrTransport { SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session) return } - sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) + sendWrappedMessage( + content: embedded, + recipientHex: recipientHex, + senderIdentity: identity, + registerPending: true, + allowNdr: false + ) } } } @@ -342,15 +506,57 @@ extension NostrTransport { /// Creates and sends a gift-wrapped private message event @MainActor - private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) { + @discardableResult + private func sendWrappedMessage( + content: String, + recipientHex: String, + senderIdentity: NostrIdentity, + registerPending: Bool = false, + 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) + 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 + return .fallbackBuildFailed } if registerPending { dependencies.registerPendingGiftWrap(event.id) } dependencies.sendEvent(event) + return .sent(.legacy1059) } @@ -367,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), @@ -378,23 +590,44 @@ 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) guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return } - sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) + sendWrappedMessage( + content: embedded, + recipientHex: recipientHex, + senderIdentity: identity, + registerPending: true, + allowNdr: false + ) case .readGeohash(let messageID, let recipientHex, let identity): SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session) guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return } - sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) + sendWrappedMessage( + content: embedded, + recipientHex: recipientHex, + senderIdentity: identity, + registerPending: true, + allowNdr: false + ) } } } @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 3bb6db11..d78b78ce 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -65,6 +65,15 @@ struct MeshTopologySnapshot: Equatable { let edges: [MeshTopologyEdge] } +/// Capability proof carried inside one exact authenticated Noise generation. +/// Public announce capabilities are discovery hints and must never authorize +/// generation-sensitive payloads such as double-ratchet bootstrap. +struct AuthenticatedPeerTransportState: Equatable { + let capabilities: PeerCapabilities + let sessionGeneration: UUID + let noisePublicKey: Data +} + enum TransportEvent: @unchecked Sendable { case messageReceived(BitchatMessage) case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) @@ -81,6 +90,9 @@ enum TransportEvent: @unchecked Sendable { case peerSnapshotsUpdated([TransportPeerSnapshot]) case messageDeliveryStatusUpdated(messageID: String, status: DeliveryStatus) case bluetoothStateUpdated(CBManagerState) + /// A new authenticated peer-state proof was accepted for the current + /// Noise generation. Identical echo packets do not re-emit this event. + case authenticatedPeerTransportStateUpdated(PeerID) } /// Downgrade-safe decision for a private-media recipient. Callers ask before @@ -300,12 +312,15 @@ extension BitchatDelegate { didUpdateMessageDeliveryStatus(messageID, status: status) case .bluetoothStateUpdated(let state): didUpdateBluetoothState(state) + case .authenticatedPeerTransportStateUpdated: + break } } } extension BLEService: Transport {} extension BLEService: MeshFileTransferring {} +extension BLEService: MeshDoubleRatchetTransporting {} extension BLEService: MeshVoiceStreaming {} extension BLEService: MeshCourierTransporting {} extension BLEService: MeshGroupMessaging {} diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index e011c763..36fccbec 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -94,6 +94,8 @@ protocol ChatPrivateConversationContext: AnyObject { func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) + func sendAccountNostrDeliveryAck(for messageID: String, to peerID: PeerID) + func sendAccountNostrReadReceipt(for messageID: String, to peerID: PeerID) // MARK: System messages func addMeshOnlySystemMessage(_ content: String) @@ -185,7 +187,7 @@ extension ChatViewModel: ChatPrivateConversationContext { } func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) { - makeGeohashNostrTransport().sendPrivateMessageGeohash( + makeNostrTransport().sendPrivateMessageGeohash( content: content, toRecipientHex: recipientHex, from: identity, @@ -194,11 +196,24 @@ extension ChatViewModel: ChatPrivateConversationContext { } func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { - makeGeohashNostrTransport().sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity) + makeNostrTransport().sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity) } func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { - makeGeohashNostrTransport().sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity) + makeNostrTransport().sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity) + } + + func sendAccountNostrDeliveryAck(for messageID: String, to peerID: PeerID) { + makeNostrTransport().sendDeliveryAck(for: messageID, to: peerID) + } + + func sendAccountNostrReadReceipt(for messageID: String, to peerID: PeerID) { + let receipt = ReadReceipt( + originalMessageID: messageID, + readerID: myPeerID, + readerNickname: nickname + ) + makeNostrTransport().sendReadReceipt(receipt, to: peerID) } func addSystemMessage(_ content: String) { @@ -226,7 +241,7 @@ extension ChatViewModel: ChatPrivateConversationContext { NotificationService.shared.sendPrivateMessageNotification(from: senderName, message: message, peerID: peerID) } - private func makeGeohashNostrTransport() -> NostrTransport { + private func makeNostrTransport() -> NostrTransport { let transport = NostrTransport(keychain: keychain, idBridge: idBridge) transport.senderPeerID = meshService.myPeerID return transport @@ -486,7 +501,8 @@ final class ChatPrivateConversationCoordinator { senderPubkey: String, convKey: PeerID, id: NostrIdentity, - messageTimestamp: Date + messageTimestamp: Date, + source: NostrPrivateMessageSource = .legacy1059 ) { guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } let messageId = pm.messageID @@ -494,7 +510,13 @@ final class ChatPrivateConversationCoordinator { // Ack before the dedup guard: a re-sent copy means the sender may not // have our DELIVERED yet, and markGeoDeliveryAckSent dedups the // actual sends. - sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id) + sendDeliveryAckIfNeeded( + to: messageId, + senderPubKey: senderPubkey, + conversationPeerID: convKey, + from: id, + source: source + ) guard markInboundGeoDMSeen(messageId) else { return } @@ -555,7 +577,13 @@ final class ChatPrivateConversationCoordinator { } if isViewing { - sendReadReceiptIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id) + sendReadReceiptIfNeeded( + to: messageId, + senderPubKey: senderPubkey, + conversationPeerID: convKey, + from: id, + source: source + ) } if !isViewing && shouldMarkUnread { @@ -633,14 +661,50 @@ final class ChatPrivateConversationCoordinator { } } - func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { + func sendDeliveryAckIfNeeded( + to messageId: String, + senderPubKey: String, + conversationPeerID: PeerID, + from id: NostrIdentity, + source: NostrPrivateMessageSource + ) { guard context.markGeoDeliveryAckSent(messageId) else { return } - context.sendGeohashDeliveryAck(for: messageId, toRecipientHex: senderPubKey, from: id) + switch source { + case .ndr: + context.sendAccountNostrDeliveryAck( + for: messageId, + to: conversationPeerID + ) + case .legacy1059: + context.sendGeohashDeliveryAck( + for: messageId, + toRecipientHex: senderPubKey, + from: id + ) + } } - func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { + func sendReadReceiptIfNeeded( + to messageId: String, + senderPubKey: String, + conversationPeerID: PeerID, + from id: NostrIdentity, + source: NostrPrivateMessageSource + ) { guard context.markReadReceiptSent(messageId) else { return } - context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id) + switch source { + case .ndr: + context.sendAccountNostrReadReceipt( + for: messageId, + to: conversationPeerID + ) + case .legacy1059: + context.sendGeohashReadReceipt( + messageId, + toRecipientHex: senderPubKey, + from: id + ) + } } func handlePrivateMessage(_ message: BitchatMessage) { diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index ee2d8b6c..349a8dd0 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -84,6 +84,15 @@ protocol ChatTransportEventContext: AnyObject { func handleGroupInvitePayload(from peerID: PeerID, payload: Data) func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) func handleVouchPayload(from peerID: PeerID, payload: Data) + + // MARK: Double-ratchet bootstrap + func bootstrapDoubleRatchetIfNeeded(for peerID: PeerID) + func handleNdrEventPayload(from peerID: PeerID, payload: Data) +} + +extension ChatTransportEventContext { + func bootstrapDoubleRatchetIfNeeded(for _: PeerID) {} + func handleNdrEventPayload(from _: PeerID, payload _: Data) {} } extension ChatViewModel: ChatTransportEventContext { @@ -166,6 +175,494 @@ extension ChatViewModel: ChatTransportEventContext { func handleVouchPayload(from peerID: PeerID, payload: Data) { vouchCoordinator.handleVouchPayload(from: peerID, payload: payload) } + + private var ndrTransport: MeshDoubleRatchetTransporting? { + meshService as? MeshDoubleRatchetTransporting + } + + func bootstrapDoubleRatchetIfNeeded(for peerID: PeerID) { + guard ndrService.isRolloutEnabled, + let ndrTransport, + let authenticated = + ndrTransport.authenticatedPeerTransportState(peerID), + authenticated.capabilities.contains(.doubleRatchet), + 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, + 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 + ) + sendNdrInvite( + invite, + to: peerID, + peerPubkeyHex: peerPubkeyHex, + expectedTransportState: authenticated, + inviteAttemptToken: inviteAttemptToken + ) + } + + func handleNdrEventPayload(from peerID: PeerID, payload: Data) { + guard ndrService.isRolloutEnabled, + let ndrTransport, + let eventJson = String(data: payload, encoding: .utf8), + !eventJson.isEmpty, + let authenticated = + ndrTransport.authenticatedPeerTransportState(peerID), + authenticated.capabilities.contains(.doubleRatchet), + 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 + } + + let isExpectedBindingCurrent: () -> Bool = { [weak self] in + self?.isCurrentDoubleRatchetBinding( + peerID: peerID, + expectedTransportState: authenticated, + expectedPeerPubkeyHex: peerPubkeyHex + ) == true + } + ndrService.configureIfNeeded( + identity: currentIdentity, + processPendingActions: false + ) + guard prepareDoubleRatchetPeerBinding( + peerID: peerID, + noisePublicKey: authenticated.noisePublicKey, + peerPubkeyHex: peerPubkeyHex, + currentIdentityPubkeyHex: currentIdentity.publicKeyHex + ) else { + return + } + let actions = ndrService.processOutOfBandEventJson( + eventJson, + expectedPeerPubkeyHex: peerPubkeyHex, + authorization: isExpectedBindingCurrent, + 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 let ndrTransport, + 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 + } + + ndrTransport.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 let ndrTransport, + action.peerPubkeyHex == peerPubkeyHex, + isCurrentDoubleRatchetBinding( + peerID: peerID, + expectedTransportState: expectedTransportState, + expectedPeerPubkeyHex: peerPubkeyHex + ) + else { + ndrService.completeOutOfBandAction( + action, + succeeded: false + ) + return + } + + let service = ndrService + ndrTransport.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, + expectedPeerPubkeyHex: String + ) -> Bool { + guard let ndrTransport, + ndrTransport.authenticatedPeerTransportState(peerID) + == expectedTransportState, + expectedTransportState.capabilities.contains(.doubleRatchet), + let relationship = favoritesService.getFavoriteStatus( + for: expectedTransportState.noisePublicKey + ), + relationship.isMutual, + let peerNostrKey = relationship.peerNostrPublicKey, + Self.ndrNostrPubkeyHex(from: peerNostrKey) + == expectedPeerPubkeyHex, + favoritesService.canUseNdrBinding( + peerNoisePublicKey: + expectedTransportState.noisePublicKey, + peerNostrPublicKey: peerNostrKey + ) + else { + return false + } + return true + } + + 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 { + ndrTransport?.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 { + return nil + } + return data.hexEncodedString() + } + + guard lowered.count == 64, + lowered.allSatisfy(\.isHexDigit) + else { + return nil + } + return lowered + } } final class ChatTransportEventCoordinator { @@ -281,6 +778,7 @@ final class ChatTransportEventCoordinator { context.flushRouterOutbox(for: peerID) context.retryCourierDeposits(via: peerID) + context.bootstrapDoubleRatchetIfNeeded(for: peerID) } func didDisconnectFromPeer(_ peerID: PeerID) { @@ -502,6 +1000,9 @@ private extension ChatTransportEventCoordinator { case .vouch: context.handleVouchPayload(from: peerID, payload: payload) + case .ndrEvent: + context.handleNdrEventPayload(from: peerID, payload: payload) + case .voiceFrame: context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 12adfda9..6d422b0c 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -318,6 +318,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage let meshService: Transport 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 +1117,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage conversations: ConversationStore? = nil, peerIdentityStore: PeerIdentityStore? = nil, locationPresenceStore: LocationPresenceStore? = nil, - locationManager: LocationChannelManager = .shared + locationManager: LocationChannelManager = .shared, + ndrService: NdrNostrService? = nil, + favoritesService: FavoritesPersistenceService? = nil ) { let livePanicRecoveryOperations = PanicRecoveryOperations.live() let startSuspendedForRecovery: Bool @@ -1140,6 +1155,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage idBridge: idBridge, identityManager: identityManager, transport: meshService, + ndrService: ndrService, + favoritesService: favoritesService, conversations: conversations, peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(), locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(), @@ -1159,6 +1176,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, transport: Transport, + ndrService: NdrNostrService? = nil, + favoritesService: FavoritesPersistenceService? = nil, conversations: ConversationStore? = nil, peerIdentityStore: PeerIdentityStore? = nil, locationPresenceStore: LocationPresenceStore? = nil, @@ -1173,11 +1192,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage let conversations = conversations ?? ConversationStore() let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore() let locationPresenceStore = locationPresenceStore ?? LocationPresenceStore() + let resolvedNdrService = ndrService ?? .shared + let resolvedFavoritesService = + favoritesService ?? FavoritesPersistenceService.shared let services = ChatViewModelServiceBundle( keychain: keychain, idBridge: idBridge, identityManager: identityManager, meshService: transport, + ndrService: resolvedNdrService, outboxStore: outboxStore, sfMetrics: sfMetrics ) @@ -1189,6 +1212,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage self.groupStore = GroupStore(keychain: keychain) self.idBridge = idBridge self.identityManager = identityManager + self.ndrService = resolvedNdrService + self.favoritesService = resolvedFavoritesService self.conversations = conversations self.peerIdentityStore = peerIdentityStore self.locationPresenceStore = locationPresenceStore @@ -1243,6 +1268,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() } @@ -1251,6 +1302,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 } @@ -1512,6 +1569,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage @objc func handleFavoriteStatusChanged(_ notification: Notification) { peerIdentityCoordinator.handleFavoriteStatusChanged(notification) + guard let peerPublicKey = notification.userInfo?["peerPublicKey"] as? Data else { return } + Task { @MainActor [weak self] in + guard let self, + let peer = unifiedPeerService.peers.first(where: { + $0.isConnected && $0.noisePublicKey == peerPublicKey + }) + else { + return + } + bootstrapDoubleRatchetIfNeeded(for: peer.peerID) + } } // MARK: - App Lifecycle @@ -1580,6 +1648,22 @@ 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() + ndrWipeCompleted = true + } catch { + ndrWipeCompleted = false + SecureLogger.error( + "Panic double-ratchet storage cleanup incomplete; recovery remains pending: \(error)", + category: .security + ) + } + // Deny and release any clear-media confirmations before identities, // message state, and local files are wiped. cancelAllLegacyPrivateMediaConsents() @@ -1620,7 +1704,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 @@ -1717,7 +1801,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage let panicCompleted: Bool do { try panicRecoveryOperations.wipeMedia(recoveryIntent) - if keychainWipeCompleted { + if keychainWipeCompleted && ndrWipeCompleted { try panicRecoveryOperations.complete() panicCompleted = true SecureLogger.info( @@ -2101,6 +2185,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage case .bluetoothStateUpdated(let state): updateBluetoothState(state) + + case .authenticatedPeerTransportStateUpdated(let peerID): + bootstrapDoubleRatchetIfNeeded(for: peerID) } } diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 3ccb7d12..f01ad93e 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -18,6 +18,7 @@ struct ChatViewModelServiceBundle { idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, meshService: Transport, + ndrService: NdrNostrService, outboxStore: MessageOutboxStore? = nil, sfMetrics: StoreAndForwardMetrics? = nil ) { @@ -28,7 +29,11 @@ struct ChatViewModelServiceBundle { idBridge: idBridge, identityManager: identityManager ) - let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge) + let nostrTransport = NostrTransport( + keychain: keychain, + idBridge: idBridge, + ndrService: ndrService + ) nostrTransport.senderPeerID = meshService.myPeerID let messageRouter = MessageRouter( transports: [meshService, nostrTransport], @@ -79,6 +84,7 @@ final class ChatViewModelBootstrapper { configureGateway() configureBridge() configureBridgeCourier() + bindNdrRelayRetry() bindTeleportState() requestNotifications() registerObservers() @@ -634,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 8205e13b..2aa2eb62 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift @@ -62,6 +62,56 @@ extension ChatViewModel { @MainActor func setupNostrMessageHandling() { + 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 9dad8030..bb11571d 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift @@ -34,14 +34,16 @@ extension ChatViewModel { senderPubkey: String, convKey: PeerID, id: NostrIdentity, - messageTimestamp: Date + messageTimestamp: Date, + source: NostrPrivateMessageSource = .legacy1059 ) { privateConversationCoordinator.handlePrivateMessage( payload, senderPubkey: senderPubkey, convKey: convKey, id: id, - messageTimestamp: messageTimestamp + messageTimestamp: messageTimestamp, + source: source ) } diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index 85024436..f4f0d057 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -2,6 +2,11 @@ import BitFoundation import BitLogger import Foundation +enum NostrPrivateMessageSource: Equatable { + case legacy1059 + case ndr +} + /// The narrow surface `NostrInboundPipeline` needs from its owner. /// /// Split out of `ChatNostrContext`: member names are shared with the sibling @@ -24,6 +29,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) @@ -46,12 +56,26 @@ protocol NostrInboundPipelineContext: AnyObject { senderPubkey: String, convKey: PeerID, id: NostrIdentity, - messageTimestamp: Date + messageTimestamp: Date, + source: NostrPrivateMessageSource ) func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) } +extension NostrInboundPipelineContext { + func canUseNdrBinding( + peerNoisePublicKey _: Data, + peerNostrPublicKey _: String + ) -> Bool { + true + } + + func canAcceptLegacyNostrDM(from _: String) -> Bool { + true + } +} + extension ChatViewModel: NostrInboundPipelineContext { // `currentGeohash`, the identity/blocking members, key mapping, and the // inbound message handlers already have witnesses on `ChatViewModel`. @@ -62,7 +86,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) { @@ -92,6 +132,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 @@ -110,9 +151,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 @@ -387,7 +433,8 @@ final class NostrInboundPipeline { senderPubkey: senderPubkey, convKey: convKey, id: id, - messageTimestamp: messageTimestamp + messageTimestamp: messageTimestamp, + source: .legacy1059 ) case .delivered: context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) @@ -397,7 +444,9 @@ final class NostrInboundPipeline { // claiming to be group traffic over Nostr is ignored. // Live voice is mesh-only: latency and relay cost make it // meaningless over Nostr. - case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState: + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, + .vouch, .ndrEvent, .voiceFrame, .privateFile, + .authenticatedPeerState: break } } @@ -419,6 +468,51 @@ final class NostrInboundPipeline { } } + @MainActor + func handleNdrDecryptedMessage( + _ message: NdrDecryptedMessage, + completion: @escaping NdrDeliveryCompletion + ) { + guard let context else { + completion(.retry) + return + } + let innerEvent = message.event + guard !context.hasProcessedNostrEvent(innerEvent.id) else { + completion(.consumed) + return + } + + let wipeGeneration = self.wipeGeneration + guard let currentIdentity = context.currentNostrIdentity() else { + completion(.retry) + return + } + Task { [weak self] in + guard let self else { + completion(.retry) + return + } + let disposition = await self.processDecryptedNostrDMContent( + innerEvent.content, + senderPubkey: message.senderPubkeyHex, + rumorTimestamp: innerEvent.created_at, + currentIdentity: currentIdentity, + wipeGeneration: wipeGeneration, + expiresAtSeconds: message.expiresAtSeconds, + source: .ndr + ) + guard self.wipeGeneration == wipeGeneration else { + completion(.retry) + return + } + if disposition == .consumed { + context.recordProcessedNostrEvent(innerEvent.id) + } + completion(disposition) + } + } + func processNostrMessage(_ giftWrap: NostrEvent) async { guard let context else { return } // Authoritative check-and-record, atomic on the main actor so two @@ -444,64 +538,128 @@ final class NostrInboundPipeline { giftWrap: giftWrap, recipientIdentity: currentIdentity ) - - if content.hasPrefix("verify:") { + 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 } - - if content.hasPrefix("bitchat1:") { - let packet: BitchatPacket? = await MainActor.run { - Self.decodeEmbeddedBitChatPacket(from: content) - } - guard let packet else { - SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session) - return - } - - let actualSenderNoiseKey: Data? = await MainActor.run { - self.findNoiseKey(for: senderPubkey) - } - let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey) - - if packet.type == MessageType.noiseEncrypted.rawValue, - let payload = NoisePayload.decode(packet.payload) { - let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) - await MainActor.run { - // Drop pre-wipe plaintext if a panic wipe landed - // during the off-main decrypt (see above). - guard self.wipeGeneration == wipeGeneration else { return } - context.registerNostrKeyMapping(senderPubkey, for: targetPeerID) - - switch payload.type { - case .privateMessage: - context.handlePrivateMessage( - payload, - senderPubkey: senderPubkey, - convKey: targetPeerID, - id: currentIdentity, - messageTimestamp: messageTimestamp - ) - case .delivered: - context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID) - case .readReceipt: - context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) - // Group state travels only over mesh Noise sessions - // in v1; group traffic over Nostr is ignored. - // Live voice is mesh-only: latency and relay cost make it - // meaningless over Nostr. - case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState: - break - } - } - } - } else { - SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session) - } + await processDecryptedNostrDMContent( + content, + senderPubkey: senderPubkey, + rumorTimestamp: rumorTimestamp, + currentIdentity: currentIdentity, + wipeGeneration: wipeGeneration, + source: .legacy1059 + ) } catch { SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session) } } + @discardableResult + private func processDecryptedNostrDMContent( + _ content: String, + senderPubkey: String, + rumorTimestamp: Int, + currentIdentity: NostrIdentity, + wipeGeneration: UInt64, + expiresAtSeconds: UInt64? = nil, + source: NostrPrivateMessageSource + ) async -> NdrDeliveryDisposition { + guard let context else { return .retry } + if content.hasPrefix("verify:") { + return .consumed + } + + guard content.hasPrefix("bitchat1:") else { + SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session) + return .consumed + } + + let packet: BitchatPacket? = await MainActor.run { + Self.decodeEmbeddedBitChatPacket(from: content) + } + guard let packet else { + SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session) + return .consumed + } + + let routingPubkey = senderPubkey + let actualSenderNoiseKey: Data? = await MainActor.run { + self.findNoiseKey(for: routingPubkey) + } + if source == .ndr, 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 .consumed + } + + let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) + 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: + context.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: targetPeerID, + id: currentIdentity, + messageTimestamp: messageTimestamp, + source: source + ) + case .delivered: + context.handleDelivered( + payload, + senderPubkey: senderPubkey, + convKey: targetPeerID + ) + case .readReceipt: + 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, + .vouch, .ndrEvent, .voiceFrame, .privateFile, + .authenticatedPeerState: + break + } + return .consumed + } + } + /// Resolves the Noise static key behind a Nostr pubkey via the favorites /// store. Lives here because the inbound DM path needs it per message. @MainActor @@ -524,6 +682,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/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 2fdb94df..bc1f239e 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -81,6 +81,16 @@ private func waitUntil( } } +@MainActor +private func drainArchitecturePublicationQueue() async { + for _ in 0..<5 { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + await Task.yield() + } +} + @Suite("App Architecture Tests", .serialized) struct AppArchitectureTests { @@ -671,7 +681,8 @@ struct AppArchitectureTests { @Test("PeerListModel publishes mesh and geohash directory state") @MainActor func peerListModelPublishesDirectoryState() async { - let viewModel = makeArchitectureViewModel() + let locationManager = makeArchitectureLocationManager() + let viewModel = makeArchitectureViewModel(locationManager: locationManager) guard let transport = viewModel.meshService as? MockTransport else { Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests") return @@ -681,7 +692,6 @@ struct AppArchitectureTests { let otherPeerID = PeerID(str: "0011223344556677") let geohash = "9q8yy" let remoteGeoID = String(repeating: "b", count: 64) - let locationManager = makeArchitectureLocationManager() let locationChannelsModel = LocationChannelsModel(manager: locationManager) let otherNoiseKey = Data((0..<32).map(UInt8.init)) let verifiedFingerprint = otherNoiseKey.sha256Fingerprint() @@ -711,17 +721,19 @@ struct AppArchitectureTests { locationManager.select(.location(GeohashChannel(level: .city, geohash: geohash))) await waitUntil { if case .location(let channel) = locationManager.selectedChannel { - return channel.geohash == geohash && !viewModel.allPeers.isEmpty + return channel.geohash == geohash && + viewModel.currentGeohash == geohash && + !viewModel.allPeers.isEmpty } return false } - viewModel.participantTracker.setActiveGeohash(geohash) - viewModel.teleportedGeo = Set([remoteGeoID]) viewModel.participantTracker.recordParticipant(pubkeyHex: remoteGeoID, geohash: geohash) if let myGeoID = try? viewModel.idBridge.deriveIdentity(forGeohash: geohash).publicKeyHex.lowercased() { viewModel.participantTracker.recordParticipant(pubkeyHex: myGeoID, geohash: geohash) } + await drainArchitecturePublicationQueue() + viewModel.teleportedGeo = Set([remoteGeoID]) let peerListModel = PeerListModel( chatViewModel: viewModel, diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index 13f2bb25..67a6f0b4 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -855,10 +855,17 @@ struct BLEServiceCoreTests { /// has to wait for the convergence handshake and use its new session. @Test func timeoutRestoredSessionDefersQueueDrainUntilConvergence() async throws { - let ble = makeService(noiseResponderHandshakeTimeout: 0.3) + let ble = makeService( + noiseHandshakeTimeout: TestConstants.settleTimeout * 2, + noiseResponderHandshakeTimeout: 0.3 + ) let alice = NoiseEncryptionService(keychain: MockKeychain()) let mallory = NoiseEncryptionService(keychain: MockKeychain()) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let reconciled = SessionReconcileCounter() + ble._test_onPrivateMediaSessionReconciled = reconciled.record + let outbound = OutboundPacketTap() + ble._test_onOutboundPacket = outbound.record // Establish BLE as responder so the inbound reconnect below is not // coalesced by the initiator-completion grace path. @@ -881,20 +888,33 @@ struct BLEServiceCoreTests { ) await ble._test_drainNoiseMessagePipeline() #expect(ble.canDeliverSecurely(to: alicePeerID)) + let initialEncryptedFrameSent = await TestHelpers.waitUntil( + { outbound.count(ofType: .noiseEncrypted) >= 1 }, + timeout: TestConstants.settleTimeout + ) + try #require(initialEncryptedFrameSent) + #expect(outbound.count(ofType: .noiseEncrypted) == 1) + let initialReconcileRan = await TestHelpers.waitUntil( + { reconciled.count(for: alicePeerID) == 1 }, + timeout: TestConstants.settleTimeout + ) + try #require(initialReconcileRan) + await ble._test_drainNoiseMessagePipeline() + // Keep one tap installed for the service's whole lifetime. After the + // explicit initial-frame observation and queue fence, start a fresh, + // lock-protected capture epoch so every assertion below measures only + // rollback/convergence output under a saturated concurrent suite. + outbound.removeAll() // The convergence retry only prepares for reachable peers. ble._test_seedConnectedPeer(alicePeerID, nickname: "Alice") - let reconciled = SessionReconcileCounter() - ble._test_onPrivateMediaSessionReconciled = reconciled.record // Park the convergence-recovery callback on its global-queue thread // before it can enqueue onto messageQueue: the restore handler // deterministically wins the dispatch race this test exercises. let recoveryGate = HandshakeRecoveryEnqueueGate() defer { recoveryGate.release() } ble._test_beforeHandshakeRecoveryEnqueued = { _ in recoveryGate.pause() } - let outbound = OutboundPacketTap() - ble._test_onOutboundPacket = outbound.record // Park the traffic the race would lose directly in the pending // queues — the same place live sends land during quarantine — so no @@ -935,7 +955,7 @@ struct BLEServiceCoreTests { // convergence retry's message 1 out of the tap until released.) let responderReady = await TestHelpers.waitUntil( { outbound.count(ofType: .noiseHandshake) >= 1 }, - timeout: TestConstants.longTimeout + timeout: TestConstants.settleTimeout ) try #require(responderReady) #expect(outbound.count(ofType: .noiseEncrypted) == 0) @@ -943,8 +963,8 @@ struct BLEServiceCoreTests { // The responder timeout restores the quarantined generation; the // gate guarantees its handler runs before the convergence retry. let restoreRan = await TestHelpers.waitUntil( - { reconciled.count(for: alicePeerID) == 1 }, - timeout: TestConstants.longTimeout + { reconciled.count(for: alicePeerID) == 2 }, + timeout: TestConstants.settleTimeout ) try #require(restoreRan) #expect(ble.canDeliverSecurely(to: alicePeerID)) @@ -976,7 +996,7 @@ struct BLEServiceCoreTests { == NoiseSecurityConstants.xxInitialMessageSize } }, - timeout: TestConstants.longTimeout + timeout: TestConstants.settleTimeout ) try #require(retryStarted) #expect(outbound.count(ofType: .noiseEncrypted) == 0) @@ -1012,7 +1032,7 @@ struct BLEServiceCoreTests { ble._test_handlePacket(retryPacket, fromPeerID: alicePeerID) let drained = await TestHelpers.waitUntil( { outbound.count(ofType: .noiseEncrypted) >= 3 }, - timeout: TestConstants.longTimeout + timeout: TestConstants.settleTimeout ) try #require(drained) await ble._test_drainNoiseMessagePipeline() @@ -1421,6 +1441,10 @@ private final class OutboundPacketTap { lock.lock(); defer { lock.unlock() } return packets } + + func removeAll() { + lock.lock(); packets.removeAll(); lock.unlock() + } } /// Blocks the convergence-recovery callback on its global-queue thread so a @@ -1525,6 +1549,8 @@ private final class PanicIngressObserver: @unchecked Sendable { } private func makeService( + noiseHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryHandshakeTimeout, noiseResponderHandshakeTimeout: TimeInterval = NoiseSecurityConstants.ordinaryResponderHandshakeTimeout, engineScheduler: BLEEngineScheduling = BLEEngineDispatchScheduler() @@ -1537,6 +1563,7 @@ private func makeService( idBridge: idBridge, identityManager: identityManager, initializeBluetoothManagers: false, + noiseHandshakeTimeout: noiseHandshakeTimeout, noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout, engineScheduler: engineScheduler ) diff --git a/bitchatTests/ChatNostrCoordinatorContextTests.swift b/bitchatTests/ChatNostrCoordinatorContextTests.swift index 80d249c4..c43246cd 100644 --- a/bitchatTests/ChatNostrCoordinatorContextTests.swift +++ b/bitchatTests/ChatNostrCoordinatorContextTests.swift @@ -79,7 +79,15 @@ private final class MockChatNostrContext: ChatNostrContext { var selectedPrivateChatPeer: PeerID? var nostrKeyMapping: [PeerID: String] = [:] func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey } - private(set) var handledPrivateMessages: [(payload: NoisePayload, senderPubkey: String, convKey: PeerID, timestamp: Date)] = [] + private(set) var handledPrivateMessages: [ + ( + payload: NoisePayload, + senderPubkey: String, + convKey: PeerID, + timestamp: Date, + source: NostrPrivateMessageSource + ) + ] = [] private(set) var handledDelivered: [(senderPubkey: String, convKey: PeerID)] = [] private(set) var handledReadReceipts: [(senderPubkey: String, convKey: PeerID)] = [] private(set) var startedPrivateChats: [PeerID] = [] @@ -89,9 +97,16 @@ private final class MockChatNostrContext: ChatNostrContext { senderPubkey: String, convKey: PeerID, id: NostrIdentity, - messageTimestamp: Date + messageTimestamp: Date, + source: NostrPrivateMessageSource ) { - handledPrivateMessages.append((payload, senderPubkey, convKey, messageTimestamp)) + handledPrivateMessages.append(( + payload, + senderPubkey, + convKey, + messageTimestamp, + source + )) } func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { @@ -213,6 +228,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 +239,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)) } @@ -362,6 +382,7 @@ struct ChatNostrCoordinatorContextTests { #expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex) #expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex) #expect(context.handledPrivateMessages.first?.convKey == convKey) + #expect(context.handledPrivateMessages.first?.source == .legacy1059) // The embedded Noise payload survives the round trip intact. let payload = try #require(context.handledPrivateMessages.first?.payload) @@ -446,6 +467,46 @@ 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) + #expect(context.handledPrivateMessages.first?.source == .legacy1059) + } + @Test @MainActor func switchLocationChannel_toMesh_tearsDownGeohashState() async { let context = MockChatNostrContext() @@ -681,4 +742,131 @@ 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 senderNoiseKey = Data(repeating: 0xA8, count: 32) + context.favoriteRelationshipsByNoiseKey[senderNoiseKey] = + FavoritesPersistenceService.FavoriteRelationship( + peerNoisePublicKey: senderNoiseKey, + peerNostrPublicKey: sender.npub, + peerNickname: "expired-peer", + isFavorite: true, + theyFavoritedUs: true, + favoritedAt: Date(timeIntervalSince1970: 0), + lastUpdated: Date(timeIntervalSince1970: 0) + ) + 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]) + } + + @Test @MainActor + func ndrDelivery_marksPrivateMessageAsPairwiseOnly() async throws { + let context = MockChatNostrContext() + let recipient = try NostrIdentity.generate() + let sender = try NostrIdentity.generate() + let noiseKey = Data(repeating: 0xA9, count: 32) + context.nostrIdentity = recipient + context.favoriteRelationshipsByNoiseKey[noiseKey] = + FavoritesPersistenceService.FavoriteRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: sender.npub, + peerNickname: "pairwise-peer", + isFavorite: true, + theyFavoritedUs: true, + favoritedAt: Date(timeIntervalSince1970: 0), + lastUpdated: Date(timeIntervalSince1970: 0) + ) + let embedded = try #require( + NostrEmbeddedBitChat.encodePMForNostr( + content: "pairwise inbound", + messageID: "ndr-inbound-1", + recipientPeerID: PeerID(hexData: noiseKey), + senderPeerID: PeerID(str: "0011223344556677") + ) + ) + var rumor = try NostrEvent( + pubkey: sender.publicKeyHex, + createdAt: Date(timeIntervalSince1970: 99), + kind: .dm, + tags: [], + content: embedded + ).sign(with: sender.schnorrSigningKey()) + rumor.sig = nil + let pipeline = NostrInboundPipeline( + context: context, + presence: GeoPresenceTracker(context: context) + ) + var disposition: NdrDeliveryDisposition? + + pipeline.handleNdrDecryptedMessage( + NdrDecryptedMessage( + event: rumor, + senderPubkeyHex: sender.publicKeyHex, + outerEventID: String(repeating: "c", count: 64), + expiresAtSeconds: nil + ), + completion: { disposition = $0 } + ) + let completed = await TestHelpers.waitUntil( + { disposition != nil }, + timeout: TestConstants.settleTimeout + ) + + #expect(completed) + #expect(disposition == .consumed) + #expect(context.handledPrivateMessages.count == 1) + #expect(context.handledPrivateMessages.first?.convKey == PeerID(hexData: noiseKey)) + #expect(context.handledPrivateMessages.first?.source == .ndr) + } + } diff --git a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift index 54cb341e..ba3c3d29 100644 --- a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift @@ -176,6 +176,8 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = [] private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = [] private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = [] + private(set) var accountNostrDeliveryAcks: [(messageID: String, peerID: PeerID)] = [] + private(set) var accountNostrReadReceipts: [(messageID: String, peerID: PeerID)] = [] var queuedMessageIDsByPeerID: [PeerID: Set] = [:] private(set) var deliveryAckAttempts: [(messageID: String, peerIDs: [PeerID])] = [] private(set) var deliveredMessageIDs: [String] = [] @@ -222,6 +224,14 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC geoReadReceipts.append((messageID, recipientHex)) } + func sendAccountNostrDeliveryAck(for messageID: String, to peerID: PeerID) { + accountNostrDeliveryAcks.append((messageID, peerID)) + } + + func sendAccountNostrReadReceipt(for messageID: String, to peerID: PeerID) { + accountNostrReadReceipts.append((messageID, peerID)) + } + // Favorites & notifications var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:] private(set) var peerFavoritedUsUpdates: [(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)] = [] @@ -474,6 +484,8 @@ struct ChatPrivateConversationCoordinatorContextTests { #expect(context.geoDeliveryAcks.map(\.messageID) == ["geo-1"]) #expect(context.geoDeliveryAcks.first?.recipientHex == senderPubkey) #expect(context.sentGeoDeliveryAcks == ["geo-1"]) + #expect(context.accountNostrDeliveryAcks.isEmpty) + #expect(context.accountNostrReadReceipts.isEmpty) #expect(context.privateChats[convKey]?.map(\.id) == ["geo-1"]) #expect(context.privateChats[convKey]?.first?.sender == "bob#5678") #expect(context.unreadPrivateMessages.isEmpty) @@ -491,6 +503,91 @@ struct ChatPrivateConversationCoordinatorContextTests { #expect(context.privateChats[convKey]?.count == 1) } + @Test @MainActor + func ndrPrivateMessage_sendsOnlyPairwiseDeliveryAndReadAcks() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let stablePeerID = PeerID( + hexData: Data(repeating: 0xA6, count: 32) + ) + let senderPubkey = String(repeating: "b", count: 64) + context.selectedPrivateChatPeer = stablePeerID + context.displayNamesByPubkey[senderPubkey] = "bob" + let payloadData = PrivateMessagePacket( + messageID: "ndr-ack-1", + content: "pairwise" + ).encode()! + let payload = NoisePayload( + type: .privateMessage, + data: payloadData + ) + + coordinator.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: stablePeerID, + id: MockChatPrivateConversationContext.dummyIdentity, + messageTimestamp: Date(), + source: .ndr + ) + + #expect(context.accountNostrDeliveryAcks.count == 1) + #expect(context.accountNostrDeliveryAcks.first?.messageID == "ndr-ack-1") + #expect(context.accountNostrDeliveryAcks.first?.peerID == stablePeerID) + #expect(context.accountNostrReadReceipts.count == 1) + #expect(context.accountNostrReadReceipts.first?.messageID == "ndr-ack-1") + #expect(context.accountNostrReadReceipts.first?.peerID == stablePeerID) + #expect(context.geoDeliveryAcks.isEmpty) + #expect(context.geoReadReceipts.isEmpty) + + coordinator.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: stablePeerID, + id: MockChatPrivateConversationContext.dummyIdentity, + messageTimestamp: Date(), + source: .ndr + ) + + #expect(context.accountNostrDeliveryAcks.count == 1) + #expect(context.accountNostrReadReceipts.count == 1) + #expect(context.privateChats[stablePeerID]?.count == 1) + } + + @Test @MainActor + func ndrPrivateMessage_notViewingSendsOnlyPairwiseDeliveryAck() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let stablePeerID = PeerID( + hexData: Data(repeating: 0xA5, count: 32) + ) + let senderPubkey = String(repeating: "c", count: 64) + context.displayNamesByPubkey[senderPubkey] = "carol" + let payload = NoisePayload( + type: .privateMessage, + data: PrivateMessagePacket( + messageID: "ndr-background-1", + content: "background" + ).encode()! + ) + + coordinator.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: stablePeerID, + id: MockChatPrivateConversationContext.dummyIdentity, + messageTimestamp: Date(), + source: .ndr + ) + + #expect(context.accountNostrDeliveryAcks.count == 1) + #expect(context.accountNostrDeliveryAcks.first?.peerID == stablePeerID) + #expect(context.accountNostrReadReceipts.isEmpty) + #expect(context.geoDeliveryAcks.isEmpty) + #expect(context.geoReadReceipts.isEmpty) + #expect(context.unreadPrivateMessages == Set([stablePeerID])) + } + @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 new file mode 100644 index 00000000..0e32bade --- /dev/null +++ b/bitchatTests/DoubleRatchet/NdrOutOfBandTransportTests.swift @@ -0,0 +1,3064 @@ +// +// NdrOutOfBandTransportTests.swift +// bitchatTests +// + +import BitFoundation +import Foundation +import NdrFfi +import Testing +@testable import bitchat + +@MainActor +final class FakeRelayManager: NostrRelayManaging { + struct Subscription { + let id: String + let filter: NostrFilter + 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] { + activeSubscriptionIDs.compactMap { id in + subscriptions.last(where: { $0.id == id }) + } + } + + func resetSentEvents() { + sentEvents.removeAll() + } + + func subscribe( + filter: NostrFilter, + id: String, + 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) { + unsubscribedIDs.append(id) + activeSubscriptionIDs.remove(id) + } + + 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 + }) + else { + return + } + subscription.handler(event) + } + + @discardableResult + func deliverMatching(_ event: NostrEvent) -> Bool { + guard let subscription = activeSubscriptions.first(where: { + ($0.filter.kinds?.contains(event.kind) ?? true) + && ($0.filter.authors?.contains(event.pubkey) ?? true) + }) else { + return false + } + subscription.handler(event) + return true + } +} + +@MainActor +private final class FakeNdrRetryScheduler { + struct Scheduled { + let delay: TimeInterval + let operation: @MainActor () -> Void + } + + 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() + } +} + +private final class NdrDeliveryLifecycleOwner {} + +@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 coordination completes") + @MainActor + func rolloutGateIsDisabledByDefaultAndFailsClosed() throws { + #expect(!DoubleRatchetFeature.isEnabled) + #expect(!PeerCapabilities.localSupported.contains(.doubleRatchet)) + let identity = try NostrIdentity.generate() + var requestedStorage = false + let service = NdrNostrService( + relayManager: FakeRelayManager(), + rolloutEnabled: false, + storageDirectoryProvider: { + requestedStorage = true + return try makeTempDir(label: "ndr-disabled-rollout") + } + ) + + service.configureIfNeeded(identity: identity) + + #expect(!service.isConfigured) + #expect(service.currentInviteEventJson() == nil) + #expect(!requestedStorage) + } + + @Test("A failed runtime open stays fail-closed until identity change") + @MainActor + 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 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 invite = try #require(aliceService.currentInviteEventJson()) + let responses = bobService.processOutOfBandEventJson( + invite, + expectedPeerPubkeyHex: unexpected.publicKeyHex, + persistEstablishedBinding: { true } + ) + + #expect(responses.isEmpty) + #expect(!bobService.hasActiveSession(with: alice.publicKeyHex)) + } + + @Test("A response is accepted only on its authenticated Noise route") + @MainActor + func responseRejectsUnexpectedPeerAndRelayInjection() throws { + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() + 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 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( + response.eventJson, + expectedPeerPubkeyHex: unexpected.publicKeyHex, + persistEstablishedBinding: { true } + ) + #expect(rejected.isEmpty) + #expect(!aliceService.hasActiveSession(with: bob.publicKeyHex)) + + let relayInjected = try JSONDecoder().decode( + NostrEvent.self, + from: Data(response.eventJson.utf8) + ) + aliceService.processInboundRelayEvent(relayInjected) + #expect(!aliceService.hasActiveSession(with: bob.publicKeyHex)) + } + + @Test("Decrypted events require exact pairwise sender attribution") + @MainActor + func decryptedEventRejectsClaimedSenderMismatch() throws { + let authenticated = try NostrIdentity.generate() + let claimed = try NostrIdentity.generate() + let inner = try makeInnerMessageEvent( + identity: authenticated, + content: "bitchat1:matching" + ) + let forged = try makeInnerMessageEvent( + identity: claimed, + 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: makeDeliveryAction( + forged, + authenticatedSender: authenticated.publicKeyHex + ) + ) == nil + ) + var invalidID = inner + invalidID.id = String(repeating: "c", count: 64) + #expect( + NdrNostrService.validatedDecryptedMessage( + 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( + NdrNostrService.validatedDecryptedMessage( + from: makeDeliveryAction( + inner, + authenticatedSender: authenticated.publicKeyHex, + innerEventID: String(repeating: "b", count: 64) + ) + ) == nil + ) + } + + @Test("A failed BLE handoff leaves its response durable for retry") + @MainActor + 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 + ) + 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 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: { storage } + ) + 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 + ) + + 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( + !favoritesService.canUseNdrBinding(for: peerID) + ) + #expect( + 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("Every failed OOB retry revalidates Noise generation and favorite") + @MainActor + func outOfBandRetryRejectsStaleBindingsAndRemainsDurable() async throws { + let relay = FakeRelayManager() + let retryScheduler = FakeNdrRetryScheduler() + let storage = try makeTempDir(label: "oob-host-retry") + let service = NdrNostrService( + relayManager: relay, + rolloutEnabled: true, + storageDirectoryProvider: { storage }, + retryScheduler: retryScheduler.schedule + ) + 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] + + let invite = try #require( + remoteService.currentInviteEventJson() + ) + 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( + transport.sentNdrEvents.count == 1, + "the old Noise generation must not be retried" + ) + + 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" + ) + + 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 == [1060] + } + ) + } + + @Test("A restart drains mixed durable actions beyond one host batch") + @MainActor + 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 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 relay = FakeRelayManager() + let restored = NdrNostrService( + relayManager: relay, + rolloutEnabled: true, + storageDirectoryProvider: { storage } + ) + var deliveredCount = 0 + restored.onDecryptedMessage = { _, completion in + deliveredCount += 1 + completion(.consumed) + } + restored.configureIfNeeded(identity: aliceIdentity) + + let drained = await TestHelpers.waitUntil( + { + relay.sentEvents.filter { $0.kind == 1060 }.count == 127 + && deliveredCount == 128 + }, + timeout: TestConstants.settleTimeout + ) + #expect(drained) + restored.retryRelayActions() + #expect(relay.sentEvents.filter { $0.kind == 1060 }.count == 127) + #expect(deliveredCount == 128) + } + + @Test("The FFI remains the durable delivery queue until explicit ack") + @MainActor + 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 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("Replacing a retired delivery owner drains once across restart") + @MainActor + func replacingDeliveryOwnerWakesDeferredActionExactlyOnce() throws { + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() + let aliceRelay = FakeRelayManager() + let aliceService = try makeService( + label: "delivery-owner-alice", + relay: aliceRelay + ) + let bobStorage = try makeTempDir(label: "delivery-owner-bob") + defer { try? FileManager.default.removeItem(at: bobStorage) } + var outer: NostrEvent? + + do { + let bobRelay = FakeRelayManager() + let bobService = NdrNostrService( + relayManager: bobRelay, + rolloutEnabled: true, + storageDirectoryProvider: { bobStorage } + ) + aliceService.configureIfNeeded(identity: alice) + bobService.configureIfNeeded(identity: bob) + try establishPairwiseSessions( + aliceService, + bobService, + firstIdentity: alice, + secondIdentity: bob, + firstRelay: aliceRelay, + secondRelay: bobRelay + ) + + var retiredOwner: NdrDeliveryLifecycleOwner? = + NdrDeliveryLifecycleOwner() + var retiredHandlerCalls = 0 + bobService.onDecryptedMessage = { [weak retiredOwner] _, completion in + retiredHandlerCalls += 1 + completion(retiredOwner == nil ? .retry : .consumed) + } + retiredOwner = nil + + aliceRelay.resetSentEvents() + guard case .sent = aliceService.send( + "bitchat1:lifecycle-owner", + to: bob.publicKeyHex + ) else { + Issue.record("Expected a pairwise send") + return + } + let sent = try #require( + aliceRelay.sentEvents.first(where: { $0.kind == 1060 }) + ) + outer = sent + bobService.processInboundRelayEvent(sent) + #expect(retiredHandlerCalls == 1) + + var replacementDeliveries = 0 + bobService.onDecryptedMessage = { message, completion in + #expect( + message.event.content == "bitchat1:lifecycle-owner" + ) + replacementDeliveries += 1 + completion(.consumed) + } + #expect(replacementDeliveries == 1) + } + + let restartedRelay = FakeRelayManager() + let restarted = NdrNostrService( + relayManager: restartedRelay, + rolloutEnabled: true, + storageDirectoryProvider: { bobStorage } + ) + var deliveriesAfterRestart = 0 + restarted.onDecryptedMessage = { _, completion in + deliveriesAfterRestart += 1 + completion(.consumed) + } + restarted.configureIfNeeded(identity: bob) + if let outer { + restarted.processInboundRelayEvent(outer) + } + #expect(deliveriesAfterRestart == 0) + } + + @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, + rolloutEnabled: true, + storageDirectoryProvider: { localStorage }, + sessionMarkerStore: markerStore + ) + initialService.configureIfNeeded(identity: local) + try establishPairwiseSessions( + initialService, + peerService, + firstIdentity: local, + secondIdentity: peer, + firstRelay: initialRelay, + secondRelay: peerRelay + ) + #expect( + initialService.hasActiveSession( + with: peer.publicKeyHex + ) + ) + } + + let nostrKeychain = MockKeychain() + nostrKeychain.save( + key: "nostr-current-identity", + data: try JSONEncoder().encode(local), + service: "chat.bitchat.nostr", + accessible: nil + ) + let favoritesService = FavoritesPersistenceService( + keychain: MockKeychain() + ) + let noiseKey = Data(repeating: 0xd4, count: 32) + installMutualFavorite( + in: favoritesService, + noiseKey: noiseKey, + nostrIdentity: peer + ) + #expect(!favoritesService.isNdrRequired(for: noiseKey)) + + let restoredService = NdrNostrService( + relayManager: FakeRelayManager(), + rolloutEnabled: true, + storageDirectoryProvider: { localStorage }, + sessionMarkerStore: markerStore + ) + let (viewModel, _, _) = makeViewModel( + ndrService: restoredService, + nostrKeychain: nostrKeychain, + favoritesService: favoritesService + ) + + viewModel.setupNostrMessageHandling() + + #expect(favoritesService.isNdrRequired(for: noiseKey)) + #expect( + !favoritesService.canAcceptLegacyNostrDM( + from: peer.publicKeyHex + ) + ) + #expect( + restoredService.hasActiveSession( + with: peer.publicKeyHex + ) + ) + } + + @Test("A failed binding pin makes zero native OOB mutation") + @MainActor + func bindingPinFailurePrecedesNativeInviteAcceptance() throws { + let local = try NostrIdentity.generate() + let peer = try NostrIdentity.generate() + let markerStore = InMemoryNdrSessionMarkerStore() + var nativeMutationCalls = 0 + let localService = NdrNostrService( + relayManager: FakeRelayManager(), + rolloutEnabled: true, + 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() + ) + + let rejected = localService.processOutOfBandEventJson( + invite, + expectedPeerPubkeyHex: peer.publicKeyHex, + persistEstablishedBinding: { false } + ) + + #expect(rejected.isEmpty) + #expect(nativeMutationCalls == 0) + #expect( + !localService.hasPairwiseSession(with: peer.publicKeyHex) + ) + #expect( + try !markerStore.contains( + identityPubkeyHex: local.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("A failed marker precommit makes zero native OOB mutation") + @MainActor + 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: "panic") + let identity = try NostrIdentity.generate() + let service = NdrNostrService( + relayManager: relay, + rolloutEnabled: true, + storageDirectoryProvider: { storage } + ) + service.configureIfNeeded(identity: identity) + #expect(service.isConfigured) + #expect(FileManager.default.fileExists(atPath: storage.path)) + + try service.resetForPanic() + + #expect(!service.isConfigured) + #expect(service.configuredPubkeyHex == nil) + #expect(!FileManager.default.fileExists(atPath: storage.path)) + } + + @Test("Only kind 1060 is exposed to Nostr relays") + @MainActor + func relaySurfaceIsOnlyKind1060() throws { + let relay = FakeRelayManager() + let service = try makeService(label: "relay-surface", relay: relay) + let identity = try NostrIdentity.generate() + 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: [] + ) + + #expect( + NdrNostrService.validatedPublishAction( + makePublishAction(tagged) + ) == nil + ) + #expect( + NdrNostrService.validatedPublishAction( + makePublishAction(tagless) + )?.id == tagless.id + ) + } + + @Test("Two native managers handshake, send, decrypt, and restart") + @MainActor + func nativePairwiseFlowWorksEndToEnd() throws { + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() + let aliceRelay = FakeRelayManager() + let bobRelay = FakeRelayManager() + let aliceStorage = try makeTempDir(label: "e2e-alice") + let bobStorage = try makeTempDir(label: "e2e-bob") + let aliceService = NdrNostrService( + relayManager: aliceRelay, + rolloutEnabled: true, + storageDirectoryProvider: { aliceStorage } + ) + let bobService = NdrNostrService( + relayManager: bobRelay, + rolloutEnabled: true, + storageDirectoryProvider: { bobStorage } + ) + aliceService.configureIfNeeded(identity: alice) + bobService.configureIfNeeded(identity: bob) + + 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 }) + + aliceRelay.resetSentEvents() + 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) + + #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("Rotated NDR filters stay live across bidirectional relay turns") + @MainActor + func rotatedSubscriptionFiltersReplaceTheLiveRelayRequest() throws { + let alice = try NostrIdentity.generate() + let bob = try NostrIdentity.generate() + let aliceRelay = FakeRelayManager() + let bobRelay = FakeRelayManager() + let aliceService = try makeService( + label: "subscription-rotation-alice", + relay: aliceRelay + ) + let bobService = try makeService( + label: "subscription-rotation-bob", + relay: bobRelay + ) + aliceService.configureIfNeeded(identity: alice) + bobService.configureIfNeeded(identity: bob) + try establishPairwiseSessions( + aliceService, + bobService, + firstIdentity: alice, + secondIdentity: bob, + firstRelay: aliceRelay, + secondRelay: bobRelay + ) + + var receivedByAlice: [String] = [] + var receivedByBob: [String] = [] + aliceService.onDecryptedMessage = { message, completion in + receivedByAlice.append(message.event.content) + completion(.consumed) + } + bobService.onDecryptedMessage = { message, completion in + receivedByBob.append(message.event.content) + completion(.consumed) + } + aliceRelay.resetSentEvents() + bobRelay.resetSentEvents() + + guard case let .sent(_, firstOuterID) = aliceService.send( + "bitchat1:first-turn", + to: bob.publicKeyHex + ), + let firstOuter = aliceRelay.sentEvents.first(where: { + $0.id == firstOuterID + }) + else { + Issue.record("Expected Alice's first pairwise send") + return + } + #expect(bobRelay.deliverMatching(firstOuter)) + #expect(receivedByBob == ["bitchat1:first-turn"]) + + guard case let .sent(_, replyOuterID) = bobService.send( + "bitchat1:reply-turn", + to: alice.publicKeyHex + ), + let replyOuter = bobRelay.sentEvents.first(where: { + $0.id == replyOuterID + }) + else { + Issue.record("Expected Bob's pairwise reply") + return + } + #expect(aliceRelay.deliverMatching(replyOuter)) + #expect(receivedByAlice == ["bitchat1:reply-turn"]) + + guard case let .sent(_, secondOuterID) = aliceService.send( + "bitchat1:second-turn", + to: bob.publicKeyHex + ), + let secondOuter = aliceRelay.sentEvents.first(where: { + $0.id == secondOuterID + }) + else { + Issue.record("Expected Alice's second pairwise send") + return + } + #expect( + bobRelay.deliverMatching(secondOuter), + "the live relay filter must follow the rotated sender key" + ) + #expect( + receivedByBob + == ["bitchat1:first-turn", "bitchat1:second-turn"] + ) + #expect(aliceRelay.unsubscribedIDs.isEmpty) + #expect(bobRelay.unsubscribedIDs.isEmpty) + } + + @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) + } + + 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 } + ) + } + + 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 directory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "bitchat-tests-\(label)-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil + ) + return directory + } + + private func makeInnerMessageEvent( + identity: NostrIdentity, + content: String + ) throws -> NostrEvent { + let event = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: Date(), + kind: .dm, + tags: [], + content: content + ) + var rumor = try event.sign(with: identity.schnorrSigningKey()) + rumor.sig = nil + return rumor + } + + private func makeDeliveryAction( + _ event: NostrEvent, + authenticatedSender: String, + innerEventID: String? = nil, + expiresAtSeconds: UInt64? = nil + ) -> PairwiseAction { + PairwiseAction( + actionId: "delivery-test", + kind: "delivery", + sessionId: nil, + subscriptionId: nil, + filterJson: nil, + eventJson: nil, + peerPubkeyHex: authenticatedSender, + innerEventJson: try? event.jsonString(), + innerEventId: innerEventID ?? event.id, + outerEventId: String(repeating: "a", count: 64), + expiresAtSeconds: expiresAtSeconds + ) + } + + private func signedKind1060Event( + identity: NostrIdentity, + tags: [[String]] + ) throws -> NostrEvent { + let unsigned = try NostrEvent( + from: [ + "pubkey": identity.publicKeyHex, + "created_at": 1_750_000_000, + "kind": 1060, + "tags": tags, + "content": "opaque-ratchet-envelope" + ] + ) + 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 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 30f071f5..0b1262b7 100644 --- a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift +++ b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift @@ -347,6 +347,319 @@ struct PrivateMediaEndToEndTests { _ = cancellable } + @Test + func ndrPublicAnnouncementCannotAuthorizeDarkAuthenticatedPeer() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ndr-public-only-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let alice = makeService( + baseDirectory: root.appendingPathComponent("alice", isDirectory: true), + doubleRatchetEnabled: true + ) + // Bob models a production-dark build: even if a public announce claims + // the bit, his exact authenticated proof cannot contain it. + let bob = makeService( + baseDirectory: root.appendingPathComponent("bob", isDirectory: true) + ) + let publicClaim = try signedAnnounce( + from: bob, + capabilities: + PeerCapabilities.localSupported.union(.doubleRatchet) + ) + alice._test_handlePacket( + publicClaim, + fromPeerID: bob.myPeerID, + preseedPeer: false + ) + let publicClaimObserved = await TestHelpers.waitUntil( + { + alice.peerCapabilities(bob.myPeerID).contains(.doubleRatchet) + }, + timeout: TestConstants.longTimeout + ) + try #require(publicClaimObserved) + + let proofs = try await establishSessionCapturingPeerState( + alice: alice, + bob: bob + ) + alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID) + let authenticated = await TestHelpers.waitUntil( + { + alice.authenticatedPeerTransportState(bob.myPeerID) != nil + }, + timeout: TestConstants.longTimeout + ) + try #require(authenticated) + await alice._test_drainNoiseMessagePipeline() + let authenticatedState = try #require( + alice.authenticatedPeerTransportState(bob.myPeerID) + ) + #expect(!authenticatedState.capabilities.contains(.doubleRatchet)) + #expect(alice.peerCapabilities(bob.myPeerID).contains(.doubleRatchet)) + #expect( + alice.authenticatedPeerTransportState(bob.myPeerID) + == authenticatedState + ) + + 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, + 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 + } + ) + alice._test_onOutboundPacket = nil + bob._test_onOutboundPacket = nil + } + + @Test + func ndrAuthenticatedCurrentCapabilityAcceptsSend() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ndr-authenticated-send-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let alice = makeService( + baseDirectory: root.appendingPathComponent("alice", isDirectory: true), + doubleRatchetEnabled: true + ) + let bob = makeService( + baseDirectory: root.appendingPathComponent("bob", isDirectory: true), + doubleRatchetEnabled: true + ) + let proofs = try await establishSessionCapturingPeerState( + alice: alice, + bob: bob + ) + alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID) + let proofAccepted = await TestHelpers.waitUntil( + { + alice.authenticatedPeerTransportState(bob.myPeerID)? + .capabilities.contains(.doubleRatchet) == true + }, + timeout: TestConstants.longTimeout + ) + try #require(proofAccepted) + await alice._test_drainNoiseMessagePipeline() + let authenticatedState = try #require( + alice.authenticatedPeerTransportState(bob.myPeerID) + ) + #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, + completion: admission.record + ) + let sent = await TestHelpers.waitUntil( + { + tap.snapshot().contains { + $0.type == MessageType.noiseEncrypted.rawValue + && PeerID(hexData: $0.recipientID) == bob.myPeerID + } + }, + timeout: TestConstants.longTimeout + ) + + #expect(sent) + #expect( + await TestHelpers.waitUntil( + { admission.snapshot().count == 1 }, + timeout: TestConstants.longTimeout + ) + ) + #expect( + tap.snapshot().filter { + $0.type == MessageType.noiseEncrypted.rawValue + }.count == 1 + ) + alice._test_onOutboundPacket = nil + bob._test_onOutboundPacket = nil + } + + @Test + func ndrExpectedOldAuthenticatedStateRejectsAfterReplacement() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ndr-stale-generation-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let bobKeychain = MockKeychain() + let alice = makeService( + baseDirectory: root.appendingPathComponent("alice", isDirectory: true), + doubleRatchetEnabled: true + ) + let bob = makeService( + baseDirectory: root.appendingPathComponent("bob", isDirectory: true), + keychain: bobKeychain, + doubleRatchetEnabled: true + ) + // Alice must be the original responder so Bob's restarted message one + // enters the ordinary replacement path instead of the initiator grace + // coalescing path. + try await establishSession(alice: bob, bob: alice) + let oldState = try #require( + alice.authenticatedPeerTransportState(bob.myPeerID) + ) + + // A second engine with Bob's persisted static identity models Bob + // restarting and completing an ordinary XX replacement generation. + let restartedBob = NoiseEncryptionService(keychain: bobKeychain) + #expect( + PeerID(publicKey: restartedBob.getStaticPublicKeyData()) + == bob.myPeerID + ) + let first = try restartedBob.initiateHandshake(with: alice.myPeerID) + let second = try #require( + try alice._test_noiseProcessHandshakeMessage( + from: bob.myPeerID, + message: first + ) + ) + let third = try #require( + try restartedBob.processHandshakeMessage( + from: alice.myPeerID, + message: second + ) + ) + _ = try alice._test_noiseProcessHandshakeMessage( + from: bob.myPeerID, + message: third + ) + await alice._test_drainNoiseMessagePipeline() + + let replacementState = AuthenticatedPeerStatePacket( + capabilities: PeerCapabilities.localSupported.union(.doubleRatchet), + signingPublicKey: restartedBob.getSigningPublicKeyData() + ) + let replacementPlaintext = try #require( + BLENoisePayloadFactory.authenticatedPeerState(replacementState) + ) + let replacementCiphertext = try restartedBob.encrypt( + replacementPlaintext, + for: alice.myPeerID + ) + let replacementProof = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: Data(hexString: bob.myPeerID.id) ?? Data(), + recipientID: Data(hexString: alice.myPeerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1_000), + payload: replacementCiphertext, + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + alice._test_handlePacket( + replacementProof, + fromPeerID: bob.myPeerID + ) + let replacementAccepted = await TestHelpers.waitUntil( + { + guard let current = + alice.authenticatedPeerTransportState(bob.myPeerID) + else { + return false + } + return current.sessionGeneration != oldState.sessionGeneration + && current.capabilities.contains(.doubleRatchet) + }, + timeout: TestConstants.longTimeout + ) + try #require(replacementAccepted) + 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, + 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 + } + ) + alice._test_onOutboundPacket = nil + bob._test_onOutboundPacket = nil + } + + @Test + func identicalAuthenticatedNdrProofEmitsOneStateUpdatedEvent() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ndr-proof-idempotence-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let alice = makeService( + baseDirectory: root.appendingPathComponent("alice", isDirectory: true), + doubleRatchetEnabled: true + ) + let bob = makeService( + baseDirectory: root.appendingPathComponent("bob", isDirectory: true), + doubleRatchetEnabled: true + ) + let recorder = AuthenticatedTransportStateEventRecorder() + alice.eventDelegate = recorder + let proofs = try await establishSessionCapturingPeerState( + alice: alice, + bob: bob + ) + + alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID) + let firstUpdate = await TestHelpers.waitUntil( + { recorder.count(for: bob.myPeerID) == 1 }, + timeout: TestConstants.longTimeout + ) + try #require(firstUpdate) + + let capabilities = + PeerCapabilities.localSupported.union(.doubleRatchet) + let firstDuplicate = try authenticatedPeerStatePacket( + from: bob, + to: alice, + capabilities: capabilities + ) + let secondDuplicate = try authenticatedPeerStatePacket( + from: bob, + to: alice, + capabilities: capabilities + ) + alice._test_handlePacket(firstDuplicate, fromPeerID: bob.myPeerID) + alice._test_handlePacket(secondDuplicate, fromPeerID: bob.myPeerID) + await alice._test_drainNoiseMessagePipeline() + await MainActor.run {} + + #expect(recorder.count(for: bob.myPeerID) == 1) + alice.eventDelegate = nil + alice._test_onOutboundPacket = nil + bob._test_onOutboundPacket = nil + } + @Test func unpinnedExplicitCapabilitiesWithoutPrivateMediaRequireConsent() { let root = FileManager.default.temporaryDirectory @@ -1384,15 +1697,18 @@ struct PrivateMediaEndToEndTests { private func makeService( baseDirectory: URL, - identityManager: SecureIdentityStateManagerProtocol? = nil + identityManager: SecureIdentityStateManagerProtocol? = nil, + keychain providedKeychain: MockKeychain? = nil, + doubleRatchetEnabled: Bool = false ) -> BLEService { - let keychain = MockKeychain() + let keychain = providedKeychain ?? MockKeychain() return BLEService( keychain: keychain, idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()), identityManager: identityManager ?? MockIdentityManager(keychain), initializeBluetoothManagers: false, - incomingFileStore: BLEIncomingFileStore(baseDirectory: baseDirectory) + incomingFileStore: BLEIncomingFileStore(baseDirectory: baseDirectory), + doubleRatchetEnabled: doubleRatchetEnabled ) } @@ -1556,6 +1872,30 @@ private final class PacketTap: @unchecked Sendable { } } +private final class AuthenticatedTransportStateEventRecorder: + TransportEventDelegate, + @unchecked Sendable +{ + private let lock = NSLock() + private var updatedPeerIDs: [PeerID] = [] + + @MainActor + func didReceiveTransportEvent(_ event: TransportEvent) { + guard case .authenticatedPeerTransportStateUpdated(let peerID) = event else { + return + } + lock.lock() + updatedPeerIDs.append(peerID) + lock.unlock() + } + + func count(for peerID: PeerID) -> Int { + lock.lock() + defer { lock.unlock() } + return updatedPeerIDs.filter { $0 == peerID }.count + } +} + private final class PrivateMediaDeferredSendGate: @unchecked Sendable { private let condition = NSCondition() private var paused = false 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 9587e86f..ebcc2094 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -15,7 +15,8 @@ import BitFoundation /// Mock Transport implementation for testing ChatViewModel in isolation. /// Records all method calls and allows test code to verify interactions. final class MockTransport: Transport, PrivateMediaDeletionPersisting, - MeshFileTransferring, MeshVerifying, MeshCourierTransporting, + MeshFileTransferring, MeshDoubleRatchetTransporting, MeshVerifying, + MeshCourierTransporting, MeshDiagnosing, MeshPublicArchiving, MeshVoiceStreaming, MeshGroupMessaging, MeshBoardBroadcasting { @@ -48,6 +49,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 @@ -69,6 +77,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: [ @@ -135,6 +147,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/Nostr/NostrIdentityBridgeLifecycleTests.swift b/bitchatTests/Nostr/NostrIdentityBridgeLifecycleTests.swift new file mode 100644 index 00000000..b6d9a3b1 --- /dev/null +++ b/bitchatTests/Nostr/NostrIdentityBridgeLifecycleTests.swift @@ -0,0 +1,280 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("Nostr identity lifecycle", .serialized) +struct NostrIdentityBridgeLifecycleTests { + private static let identityKey = "nostr-current-identity" + private static let service = "chat.bitchat.nostr" + + @Test("A truly absent identity is created durably and reused") + func firstRunCreatesDurableIdentity() throws { + let keychain = MockKeychain() + let bridge = NostrIdentityBridge(keychain: keychain) + + let firstRead = try bridge.getCurrentNostrIdentity() + let secondRead = try bridge.getCurrentNostrIdentity() + let created = try #require(firstRead) + let restored = try #require(secondRead) + + #expect(restored.publicKeyHex == created.publicKeyHex) + guard case .success(let stored) = keychain.loadWithResult( + key: Self.identityKey, + service: Self.service + ) else { + Issue.record("Expected a durable identity after first-run creation") + return + } + #expect( + try JSONDecoder().decode( + NostrIdentity.self, + from: stored + ).publicKeyHex == created.publicKeyHex + ) + } + + @Test("Protected-data read failures never mint a replacement identity") + func protectedDataFailureFailsClosed() { + let keychain = MockKeychain() + keychain.simulatedGenericReadError = .deviceLocked + let bridge = NostrIdentityBridge(keychain: keychain) + + #expect(throws: (any Error).self) { + _ = try bridge.getCurrentNostrIdentity() + } + } + + @Test("Transient keychain read failures never mint a replacement identity") + func transientReadFailureFailsClosed() { + let keychain = MockKeychain() + keychain.simulatedGenericReadError = .otherError(-1) + let bridge = NostrIdentityBridge(keychain: keychain) + + #expect(throws: (any Error).self) { + _ = try bridge.getCurrentNostrIdentity() + } + } + + @Test("An undurable first-run identity is never returned") + func saveFailureFailsClosed() { + let keychain = MockKeychain() + keychain.simulatedGenericSaveFailureKeys.insert(Self.identityKey) + let bridge = NostrIdentityBridge(keychain: keychain) + + #expect(throws: (any Error).self) { + _ = try bridge.getCurrentNostrIdentity() + } + } + + @Test("Concurrent bridge instances return one durable identity") + func concurrentBridgeInstancesReturnSameIdentity() async throws { + let keychain = BlockingIdentityLifecycleKeychain() + let firstBridge = NostrIdentityBridge(keychain: keychain) + let secondBridge = NostrIdentityBridge(keychain: keychain) + + let firstTask = Task.detached { + try firstBridge.getCurrentNostrIdentity() + } + guard keychain.waitForFirstSave() else { + keychain.releaseFirstSave() + Issue.record("First identity creation never reached persistence") + return + } + + let secondTask = Task.detached { + try secondBridge.getCurrentNostrIdentity() + } + // With instance-local locking the second bridge can create and read a + // different identity while the first save is paused. With the shared + // lifecycle lock it remains outside the keychain until the first + // identity is durable. + _ = keychain.waitForSecondCreation() + keychain.releaseFirstSave() + + let first = try #require(try await firstTask.value) + let second = try #require(try await secondTask.value) + #expect(first.publicKeyHex == second.publicKeyHex) + #expect(keychain.identitySaveCount == 1) + } + + @Test("Panic clear cannot be overtaken by an in-flight identity save") + func panicClearSerializesWithInFlightCreate() async throws { + let keychain = BlockingIdentityLifecycleKeychain() + let creatingBridge = NostrIdentityBridge(keychain: keychain) + let clearingBridge = NostrIdentityBridge(keychain: keychain) + + let createTask = Task.detached { + try creatingBridge.getCurrentNostrIdentity() + } + guard keychain.waitForFirstSave() else { + keychain.releaseFirstSave() + Issue.record("Identity creation never reached persistence") + return + } + + let clearTask = Task.detached { + clearingBridge.clearAllAssociations() + } + // Before lifecycle serialization, panic deletion completes while the + // pre-panic save is paused and that save can resurrect the identity. + _ = keychain.waitForDeleteAll() + keychain.releaseFirstSave() + + _ = try await createTask.value + await clearTask.value + + guard case .itemNotFound = keychain.loadWithResult( + key: Self.identityKey, + service: Self.service + ) else { + Issue.record("A pre-panic identity was saved after panic clear") + return + } + } +} + +private final class BlockingIdentityLifecycleKeychain: + KeychainManagerProtocol, + @unchecked Sendable +{ + private let lock = NSLock() + private let firstSaveEntered = DispatchSemaphore(value: 0) + private let firstSaveRelease = DispatchSemaphore(value: 0) + private let secondCreationRead = DispatchSemaphore(value: 0) + private let deleteAllCompleted = DispatchSemaphore(value: 0) + private var serviceStorage: [String: [String: Data]] = [:] + private var saveCount = 0 + private var didSignalSecondCreation = false + + var identitySaveCount: Int { + lock.withLock { saveCount } + } + + func waitForFirstSave() -> Bool { + firstSaveEntered.wait(timeout: .now() + 1) == .success + } + + func waitForSecondCreation() -> Bool { + secondCreationRead.wait(timeout: .now() + 1) == .success + } + + func waitForDeleteAll() -> Bool { + deleteAllCompleted.wait(timeout: .now() + 1) == .success + } + + func releaseFirstSave() { + firstSaveRelease.signal() + } + + func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { + save(key: key, data: keyData, service: "identity", accessible: nil) + return true + } + + func getIdentityKey(forKey key: String) -> Data? { + load(key: key, service: "identity") + } + + func deleteIdentityKey(forKey key: String) -> Bool { + delete(key: key, service: "identity") + return true + } + + func deleteAllKeychainData() -> Bool { + lock.withLock { + serviceStorage.removeAll() + } + return true + } + + func secureClear(_ data: inout Data) { + data = Data() + } + + func secureClear(_ string: inout String) { + string = "" + } + + func verifyIdentityKeyExists() -> Bool { + getIdentityKey(forKey: "identity_noiseStaticKey") != nil + } + + func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { + guard let data = getIdentityKey(forKey: key) else { + return .itemNotFound + } + return .success(data) + } + + func saveIdentityKeyWithResult( + _ keyData: Data, + forKey key: String + ) -> KeychainSaveResult { + saveIdentityKey(keyData, forKey: key) ? .success : .otherError(-1) + } + + func save( + key: String, + data: Data, + service: String, + accessible _: CFString? + ) { + let isFirstSave = lock.withLock { + saveCount += 1 + return saveCount == 1 + } + if isFirstSave { + firstSaveEntered.signal() + firstSaveRelease.wait() + } + lock.withLock { + serviceStorage[service, default: [:]][key] = data + } + } + + func load(key: String, service: String) -> Data? { + guard case .success(let data) = loadWithResult( + key: key, + service: service + ) else { + return nil + } + return data + } + + func loadWithResult( + key: String, + service: String + ) -> KeychainReadResult { + let result: KeychainReadResult + let shouldSignalSecondCreation: Bool + (result, shouldSignalSecondCreation) = lock.withLock { + guard let data = serviceStorage[service]?[key] else { + return (.itemNotFound, false) + } + let shouldSignal = saveCount >= 2 && !didSignalSecondCreation + if shouldSignal { + didSignalSecondCreation = true + } + return (.success(data), shouldSignal) + } + if shouldSignalSecondCreation { + secondCreationRead.signal() + } + return result + } + + func delete(key: String, service: String) { + _ = lock.withLock { + serviceStorage[service]?.removeValue(forKey: key) + } + } + + func deleteAll(service: String) { + _ = lock.withLock { + serviceStorage.removeValue(forKey: service) + } + deleteAllCompleted.signal() + } +} diff --git a/bitchatTests/Nostr/NostrRelaySettingsTests.swift b/bitchatTests/Nostr/NostrRelaySettingsTests.swift index 30c647ae..1fe977db 100644 --- a/bitchatTests/Nostr/NostrRelaySettingsTests.swift +++ b/bitchatTests/Nostr/NostrRelaySettingsTests.swift @@ -118,7 +118,8 @@ struct NostrRelaySettingsTests { #expect(NostrRelaySettings.customRelays(in: defaults) == ["wss://good.example.com"]) } - @Test func builtInRelaysAreExposedNormalizedForDeduplication() { + @Test @MainActor + func builtInRelaysAreExposedNormalizedForDeduplication() { // The UI rejects re-adding a built-in by comparing against this set, so // it has to hold normalized URLs. let builtIn = NostrRelayManager.builtInRelayURLs diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index 453a4a9a..870b8532 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -684,7 +684,14 @@ private final class PerfNostrContext: ChatNostrContext { var selectedPrivateChatPeer: PeerID? var nostrKeyMapping: [PeerID: String] = [:] func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey } - func handlePrivateMessage(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID, id: NostrIdentity, messageTimestamp: Date) {} + func handlePrivateMessage( + _ payload: NoisePayload, + senderPubkey: String, + convKey: PeerID, + id: NostrIdentity, + messageTimestamp: Date, + source: NostrPrivateMessageSource + ) {} func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {} func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {} func startPrivateChat(with peerID: PeerID) {} diff --git a/bitchatTests/ProtocolContractTests.swift b/bitchatTests/ProtocolContractTests.swift index 881dc3aa..99e41271 100644 --- a/bitchatTests/ProtocolContractTests.swift +++ b/bitchatTests/ProtocolContractTests.swift @@ -94,6 +94,7 @@ struct ProtocolContractTests { // defaults: a core-only transport simply doesn't have them. #expect(!(probe as AnyObject is MeshFileTransferring)) #expect(!(probe as AnyObject is MeshDiagnosing)) + #expect(!(probe as AnyObject is MeshDoubleRatchetTransporting)) #expect(probe.peerCapabilities(peerID).isEmpty) // Secure delivery defaults to prompt delivery (itself defaulting to // reachability) for transports without a forgeable link layer. diff --git a/bitchatTests/Services/BLENoisePacketHandlerTests.swift b/bitchatTests/Services/BLENoisePacketHandlerTests.swift index cdfa6f81..c60b64b2 100644 --- a/bitchatTests/Services/BLENoisePacketHandlerTests.swift +++ b/bitchatTests/Services/BLENoisePacketHandlerTests.swift @@ -16,6 +16,7 @@ struct BLENoisePacketHandlerTests { var decryptResult: Result = .success(Data()) var currentDate = Date(timeIntervalSince1970: 1_000) var transportGenerationReady = false + var authorizeNoisePayload = true var forcedServiceDecryptError: Error? var processedHandshakes: [(peerID: PeerID, message: Data)] = [] @@ -26,6 +27,7 @@ struct BLENoisePacketHandlerTests { var decryptCalls: [(payload: Data, peerID: PeerID)] = [] var clearedSessions: [PeerID] = [] var authenticatedPeerStates: [(peerID: PeerID, payload: Data, generation: UUID)] = [] + var authorizationChecks: [(peerID: PeerID, type: NoisePayloadType, generation: UUID)] = [] var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = [] /// Ordered side-effect log to assert recovery sequencing. var events: [String] = [] @@ -84,7 +86,11 @@ struct BLENoisePacketHandlerTests { handleAuthenticatedPeerState: { peerID, payload, generation in recorder.authenticatedPeerStates.append((peerID, payload, generation)) }, - deliverNoisePayload: { peerID, type, payload, timestamp in + authorizeNoisePayload: { peerID, type, generation in + recorder.authorizationChecks.append((peerID, type, generation)) + return recorder.authorizeNoisePayload + }, + deliverNoisePayload: { peerID, type, payload, timestamp, _ in recorder.deliveries.append((peerID, type, payload, timestamp)) } ) @@ -156,8 +162,14 @@ struct BLENoisePacketHandlerTests { (peerID, payload, generation) ) }, + authorizeNoisePayload: { peerID, type, generation in + recorder.authorizationChecks.append( + (peerID, type, generation) + ) + return recorder.authorizeNoisePayload + }, deliverNoisePayload: { - peerID, type, payload, timestamp in + peerID, type, payload, timestamp, _ in recorder.deliveries.append( (peerID, type, payload, timestamp) ) @@ -393,6 +405,30 @@ struct BLENoisePacketHandlerTests { #expect(recorder.initiatedHandshakes.isEmpty) } + @Test + func ndrPayloadWithoutProofForDecryptingGenerationIsDropped() { + let recorder = Recorder() + recorder.authorizeNoisePayload = false + recorder.decryptResult = .success( + Data([NoisePayloadType.ndrEvent.rawValue]) + Data("invite".utf8) + ) + let handler = makeHandler(recorder: recorder) + let packet = makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id) + ) + + handler.handleEncrypted(packet, from: remotePeerID) + + #expect(recorder.deliveries.isEmpty) + #expect(recorder.authorizationChecks.count == 1) + #expect(recorder.authorizationChecks.first?.peerID == remotePeerID) + #expect(recorder.authorizationChecks.first?.type == .ndrEvent) + #expect( + recorder.authorizationChecks.first?.generation + == recorder.sessionGeneration + ) + } + @Test func authenticatedPeerStateIsConsumedByTransportNotDeliveredToUI() { let recorder = Recorder() diff --git a/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift b/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift index 8e8f025b..ed2da870 100644 --- a/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift +++ b/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift @@ -114,5 +114,6 @@ struct BLENoiseReconnectPolicyTests { #expect( PeerCapabilities.localSupported.contains(.privateMediaReceipts) ) + #expect(!PeerCapabilities.localSupported.contains(.doubleRatchet)) } } diff --git a/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift b/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift index c58d5bf5..0c60e09a 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)) @@ -60,6 +78,34 @@ struct BLEOutboundFragmentPlannerTests { #expect(plan.fragmentPackets.allSatisfy { $0.recipientID == Data(hexString: directedPeer.id) }) } + @Test("link-derived chunks keep every directed fragment within the link limit") + func linkDerivedChunksFitTheLinkLimit() throws { + let linkLimit = 512 + let directedPeer = PeerID(str: "8877665544332211") + let packet = makePacket(payload: makePayload(count: 1_024)) + let request = BLEOutboundFragmentTransferRequest( + packet: packet, + pad: false, + maxChunk: BLEOutboundPacketPolicy.fragmentChunkSize( + forLinkLimit: linkLimit + ), + directedPeer: directedPeer, + transferId: nil + ) + + let plan = try #require(BLEOutboundFragmentPlanner.makePlan( + for: request, + defaultChunkSize: TransportConfig.bleDefaultFragmentSize, + bleMaxMTU: linkLimit, + fragmentID: Data(repeating: 0xB3, count: 8) + )) + let encodedFragments = try plan.fragmentPackets.map { + try #require($0.toBinaryData(padding: false)) + } + + #expect(encodedFragments.allSatisfy { $0.count <= linkLimit }) + } + @Test("route-aware fragments use version two and route-sized chunking") func routeAwareFragmentsUseVersionTwoAndRouteSizedChunking() throws { let route = [ @@ -89,6 +135,43 @@ struct BLEOutboundFragmentPlannerTests { #expect(plan.fragmentPackets.allSatisfy { $0.route == route && $0.isRSR }) } + @Test("link-derived chunks cannot override route-safe fragment sizing") + func linkDerivedChunksRespectRouteOverhead() throws { + let linkLimit = 512 + let route = [ + Data([0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17]), + Data([0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27]) + ] + let directedPeer = PeerID(str: "8877665544332211") + let packet = makePacket( + payload: makePayload(count: 1_024), + route: route + ) + let requestedChunk = BLEOutboundPacketPolicy.fragmentChunkSize( + forLinkLimit: linkLimit + ) + let request = BLEOutboundFragmentTransferRequest( + packet: packet, + pad: false, + maxChunk: requestedChunk, + directedPeer: directedPeer, + transferId: nil + ) + + let plan = try #require(BLEOutboundFragmentPlanner.makePlan( + for: request, + defaultChunkSize: TransportConfig.bleDefaultFragmentSize, + bleMaxMTU: linkLimit, + fragmentID: Data(repeating: 0xC4, count: 8) + )) + let encodedFragments = try plan.fragmentPackets.map { + try #require($0.toBinaryData(padding: false)) + } + + #expect(plan.chunkSize < requestedChunk) + #expect(encodedFragments.allSatisfy { $0.count <= linkLimit }) + } + @Test("invalid fragment IDs do not produce a plan") func invalidFragmentIDReturnsNil() { let packet = makePacket(payload: makePayload(count: 128)) @@ -160,6 +243,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/NoiseEncryptionServiceTests.swift b/bitchatTests/Services/NoiseEncryptionServiceTests.swift index c0cd293f..40e0d047 100644 --- a/bitchatTests/Services/NoiseEncryptionServiceTests.swift +++ b/bitchatTests/Services/NoiseEncryptionServiceTests.swift @@ -715,8 +715,7 @@ struct NoiseEncryptionServiceTests { // setup handshake below, where bob is the responder. At 0.06 a // preempted runner could fire it mid-setup, tear down the half-open // responder, and make message 3 be answered as a fresh initiation. - ordinaryResponderHandshakeTimeout: 1.0, - ordinaryReconnectRollbackCooldown: 0.3 + ordinaryResponderHandshakeTimeout: 1.0 ) let mallory = NoiseEncryptionService(keychain: MockKeychain()) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) diff --git a/bitchatTests/Services/NostrRelayManagerTests.swift b/bitchatTests/Services/NostrRelayManagerTests.swift index e6c79201..bff005f0 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) @@ -645,6 +798,195 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0) } + func test_subscribe_changedActiveRequestReplacesSameID() async { + let relayURL = "wss://rotating-subscribe.example" + let context = makeContext(permission: .denied) + let initialFilter = makeFilter() + + context.manager.subscribe( + filter: initialFilter, + id: "rotating-sub", + relayUrls: [relayURL], + handler: { _ in } + ) + let firstSent = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)? + .sentStrings.count == 1 + } + XCTAssertTrue(firstSent) + + var rotatedFilter = initialFilter + rotatedFilter.authors = [String(repeating: "a", count: 64)] + context.manager.subscribe( + filter: rotatedFilter, + id: "rotating-sub", + relayUrls: [relayURL], + handler: { _ in } + ) + + let replacementSent = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)? + .sentStrings.count == 2 + } + XCTAssertTrue( + replacementSent, + "NIP-01 replaces a live subscription atomically when a new REQ reuses its ID" + ) + let messages = context.sessionFactory + .latestConnection(for: relayURL)?.sentStrings ?? [] + XCTAssertTrue(messages.allSatisfy { $0.contains("\"REQ\"") }) + XCTAssertNotEqual(messages.first, messages.last) + let replacementCommitted = await waitUntil { + context.manager.debugPendingSubscriptionCount( + for: relayURL + ) == 0 + } + XCTAssertTrue(replacementCommitted) + } + + func test_subscribe_staleCompletionCannotRegressReplacement() async { + let relayURL = "wss://subscription-completion-order.example" + let context = makeContext(permission: .denied) + context.sessionFactory.deferSendCompletions = true + let initialFilter = makeFilter() + + context.manager.subscribe( + filter: initialFilter, + id: "ordered-sub", + relayUrls: [relayURL], + handler: { _ in } + ) + let firstSent = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)? + .sentStrings.count == 1 + } + XCTAssertTrue(firstSent) + + var replacementFilter = initialFilter + replacementFilter.authors = [String(repeating: "b", count: 64)] + context.manager.subscribe( + filter: replacementFilter, + id: "ordered-sub", + relayUrls: [relayURL], + handler: { _ in } + ) + let replacementSent = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)? + .sentStrings.count == 2 + } + XCTAssertTrue(replacementSent) + + let connection = context.sessionFactory.latestConnection( + for: relayURL + ) + connection?.flushDeferredSendCompletion(at: 1) + let replacementCommitted = await waitUntil { + context.manager.debugPendingSubscriptionCount( + for: relayURL + ) == 0 + } + XCTAssertTrue(replacementCommitted) + + // The first REQ completes last. It must not restore the old logical + // generation or make the same replacement look absent. + connection?.flushDeferredSendCompletion(at: 0) + try? await Task.sleep(nanoseconds: 20_000_000) + context.manager.subscribe( + filter: replacementFilter, + id: "ordered-sub", + relayUrls: [relayURL], + handler: { _ in } + ) + try? await Task.sleep(nanoseconds: 20_000_000) + + XCTAssertEqual(connection?.sentStrings.count, 2) + XCTAssertEqual( + context.manager.debugPendingSubscriptionCount(for: relayURL), + 0 + ) + } + + func test_subscribe_reconnectReplaysOnlyLatestReplacement() async { + let relayURL = "wss://subscription-reconnect.example" + let context = makeContext(permission: .denied) + let initialFilter = makeFilter() + + context.manager.subscribe( + filter: initialFilter, + id: "reconnecting-sub", + relayUrls: [relayURL], + handler: { _ in } + ) + let oldConnection = context.sessionFactory.latestConnection( + for: relayURL + ) + oldConnection?.deferSendCompletions = true + let initialSent = await waitUntil { + oldConnection?.sentStrings.count == 1 + } + XCTAssertTrue(initialSent) + + var replacementFilter = initialFilter + replacementFilter.authors = [String(repeating: "c", count: 64)] + context.manager.subscribe( + filter: replacementFilter, + id: "reconnecting-sub", + relayUrls: [relayURL], + handler: { _ in } + ) + let replacementSent = await waitUntil { + oldConnection?.sentStrings.count == 2 + } + XCTAssertTrue(replacementSent) + + oldConnection?.fail( + error: NSError( + domain: NSURLErrorDomain, + code: NSURLErrorNetworkConnectionLost + ) + ) + let retryScheduled = await waitUntil { + !context.scheduler.scheduled.isEmpty + } + XCTAssertTrue(retryScheduled) + context.scheduler.runNext() + + let replayed = await waitUntil { + let connections = + context.sessionFactory.connectionsByURL[relayURL] ?? [] + return connections.count == 2 + && connections.last?.sentStrings.count == 1 + } + XCTAssertTrue(replayed) + + let connections = + context.sessionFactory.connectionsByURL[relayURL] ?? [] + XCTAssertEqual( + connections.last?.sentStrings.first, + oldConnection?.sentStrings.last + ) + XCTAssertNotEqual( + oldConnection?.sentStrings.first, + oldConnection?.sentStrings.last + ) + + oldConnection?.flushDeferredSendCompletions() + try? await Task.sleep(nanoseconds: 20_000_000) + context.manager.subscribe( + filter: replacementFilter, + id: "reconnecting-sub", + relayUrls: [relayURL], + handler: { _ in } + ) + try? await Task.sleep(nanoseconds: 20_000_000) + + XCTAssertEqual(connections.last?.sentStrings.count, 1) + XCTAssertEqual( + context.manager.debugPendingSubscriptionCount(for: relayURL), + 0 + ) + } + func test_subscribe_waitsForTorReadinessAndPreservesEOSECallback() async throws { let relayURL = "wss://tor-subscribe.example" let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) @@ -1045,6 +1387,165 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertTrue(counted) } + func test_receiveEvent_withoutHandlerDoesNotPoisonFutureSubscriptionReplay() async throws { + let relayURL = "wss://future-subscription.example" + let context = makeContext(permission: .denied) + let event = try makeSignedEvent(content: "future subscription") + var barrierEOSECount = 0 + + context.manager.subscribe( + filter: makeFilter(), + id: "barrier", + relayUrls: [relayURL], + handler: { _ in }, + onEOSE: { barrierEOSECount += 1 } + ) + let barrierSubscribed = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)? + .sentStrings.count == 1 + } + XCTAssertTrue(barrierSubscribed) + + let connection = try XCTUnwrap( + context.sessionFactory.latestConnection(for: relayURL) + ) + try connection.emitEventMessage( + subscriptionID: "future", + event: event + ) + // The relay pipeline is serial. Observing this EOSE proves the + // unsolicited event ahead of it has finished verification and its + // delivery-side bookkeeping before the real subscription is created. + try connection.emitEOSE(subscriptionID: "barrier") + let unsolicitedSettled = await waitUntil { + barrierEOSECount == 1 + && context.manager.relays.first(where: { + $0.url == relayURL + })?.messagesReceived == 1 + } + XCTAssertTrue(unsolicitedSettled) + + var receivedIDs: [String] = [] + context.manager.subscribe( + filter: makeFilter(), + id: "future", + relayUrls: [relayURL] + ) { replayed in + receivedIDs.append(replayed.id) + } + let futureSubscribed = await waitUntil { + connection.sentStrings.count == 2 + } + XCTAssertTrue(futureSubscribed) + + try connection.emitEventMessage( + subscriptionID: "future", + event: event + ) + let replaySettled = await waitUntil { + receivedIDs == [event.id] + || context.manager + .debugDuplicateInboundEventDropCount( + forSubscriptionID: "future" + ) == 1 + } + XCTAssertTrue(replaySettled) + XCTAssertEqual(receivedIDs, [event.id]) + XCTAssertEqual( + context.manager.debugDuplicateInboundEventDropCount( + forSubscriptionID: "future" + ), + 0 + ) + } + + func test_receiveEvent_fromRetiredSubscriptionCannotReachReplacement() async throws { + let relayURL = "wss://retired-subscription.example" + let verifier = ControllableEventSignatureVerifier() + let context = makeContext( + permission: .denied, + verifyEventSignature: { verifier.verify($0) } + ) + let event = try makeSignedEvent(content: "retired generation") + var retiredReceivedIDs: [String] = [] + + context.manager.subscribe( + filter: makeFilter(), + id: "replaceable", + relayUrls: [relayURL] + ) { received in + retiredReceivedIDs.append(received.id) + } + let initialSubscriptionSent = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)? + .sentStrings.count == 1 + } + XCTAssertTrue(initialSubscriptionSent) + + let connection = try XCTUnwrap( + context.sessionFactory.latestConnection(for: relayURL) + ) + verifier.blockNextVerification() + try connection.emitEventMessage( + subscriptionID: "replaceable", + event: event + ) + let verificationBlocked = await waitUntil { + verifier.isVerificationBlocked + } + guard verificationBlocked else { + verifier.releaseBlockedVerification() + XCTFail("Event never reached signature verification") + return + } + + context.manager.unsubscribe(id: "replaceable") + var replacementReceivedIDs: [String] = [] + var replacementEOSECount = 0 + context.manager.subscribe( + filter: makeFilter(), + id: "replaceable", + relayUrls: [relayURL], + handler: { received in + replacementReceivedIDs.append(received.id) + }, + onEOSE: { replacementEOSECount += 1 } + ) + let replacementSubscriptionSent = await waitUntil { + connection.sentStrings.count == 3 + } + XCTAssertTrue(replacementSubscriptionSent) + + // The EOSE is queued behind the blocked event on the same serial relay + // pipeline, so its callback proves the stale event's delivery phase + // completed before assertions or replay. + try connection.emitEOSE(subscriptionID: "replaceable") + verifier.releaseBlockedVerification() + let staleEventSettled = await waitUntil { + replacementEOSECount == 1 + } + XCTAssertTrue(staleEventSettled) + XCTAssertTrue(retiredReceivedIDs.isEmpty) + XCTAssertTrue( + replacementReceivedIDs.isEmpty, + "An event admitted by the retired subscription reached its replacement" + ) + + try connection.emitEventMessage( + subscriptionID: "replaceable", + event: event + ) + let replaySettled = await waitUntil { + replacementReceivedIDs == [event.id] + || context.manager + .debugDuplicateInboundEventDropCount( + forSubscriptionID: "replaceable" + ) == 1 + } + XCTAssertTrue(replaySettled) + XCTAssertEqual(replacementReceivedIDs, [event.id]) + } + func test_noticeAndMalformedMessages_keepReceiveLoopAliveForLaterEvents() async throws { let relayURL = "wss://parser.example" let context = makeContext(permission: .denied) @@ -1837,6 +2338,9 @@ final class NostrRelayManagerTests: XCTestCase { torIsForeground: Bool = true, notificationCenter: NotificationCenter = NotificationCenter(), customRelays: MutableRelayList = MutableRelayList(urls: []), + verifyEventSignature: @escaping @Sendable (NostrEvent) -> Bool = { + $0.isValidSignature() + }, jitterUnit: @escaping () -> Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter) ) -> RelayManagerTestContext { let permissionSubject = CurrentValueSubject(permission) @@ -1860,6 +2364,7 @@ final class NostrRelayManagerTests: XCTestCase { torIsForeground: { torForeground.value }, awaitTorReady: torWaiter.await(completion:), makeSession: { sessionFactory }, + verifyEventSignature: verifyEventSignature, scheduleAfter: { delay, action in scheduler.schedule(delay: delay, action: action) }, @@ -1921,6 +2426,45 @@ final class NostrRelayManagerTests: XCTestCase { } } +private final class ControllableEventSignatureVerifier: @unchecked Sendable { + private let lock = NSLock() + private let verificationRelease = DispatchSemaphore(value: 0) + private var shouldBlockNext = false + private var blocked = false + + var isVerificationBlocked: Bool { + lock.withLock { blocked } + } + + func blockNextVerification() { + lock.withLock { + shouldBlockNext = true + } + } + + func releaseBlockedVerification() { + verificationRelease.signal() + } + + func verify(_ event: NostrEvent) -> Bool { + let shouldBlock = lock.withLock { + guard shouldBlockNext else { return false } + shouldBlockNext = false + return true + } + if shouldBlock { + lock.withLock { + blocked = true + } + verificationRelease.wait() + lock.withLock { + blocked = false + } + } + return event.isValidSignature() + } +} + @MainActor private struct RelayManagerTestContext { let manager: NostrRelayManager @@ -2019,6 +2563,7 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol { private(set) var connectionsByURL: [String: [MockRelayConnection]] = [:] var pingErrorByURL: [String: Error?] = [:] var sendErrorByURL: [String: Error?] = [:] + var deferSendCompletions = false var allConnections: [MockRelayConnection] { connectionsByURL.values.flatMap { $0 } @@ -2031,6 +2576,7 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol { pingError: pingErrorByURL[url.absoluteString] ?? nil, sendError: sendErrorByURL[url.absoluteString] ?? nil ) + connection.deferSendCompletions = deferSendCompletions connectionsByURL[url.absoluteString, default: []].append(connection) return connection } @@ -2091,6 +2637,13 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol { } } + func flushDeferredSendCompletion(at index: Int) { + guard deferredSendCompletions.indices.contains(index) else { + return + } + deferredSendCompletions.remove(at: index)(sendError) + } + func receive(completionHandler: @escaping (Result) -> Void) { if !pendingResults.isEmpty { completionHandler(pendingResults.removeFirst()) diff --git a/bitchatTests/Services/NostrTransportTests.swift b/bitchatTests/Services/NostrTransportTests.swift index 5f657000..c2acfe8e 100644 --- a/bitchatTests/Services/NostrTransportTests.swift +++ b/bitchatTests/Services/NostrTransportTests.swift @@ -21,6 +21,7 @@ struct NostrTransportTests { func reachabilityCacheWarmsFromFavorites() async throws { let keychain = MockKeychain() let idBridge = NostrIdentityBridge(keychain: keychain) + let ndrService = try makeNdrService(label: "reachability-cache") let recipient = try NostrIdentity.generate() let noiseKey = Data((0..<32).map(UInt8.init)) let fullPeerID = PeerID(hexData: noiseKey) @@ -35,6 +36,7 @@ struct NostrTransportTests { let transport = NostrTransport( keychain: keychain, idBridge: idBridge, + ndrService: ndrService, dependencies: makeDependencies( loadFavorites: { favorites }, favoriteStatusForNoiseKey: { favorites[$0] }, @@ -55,6 +57,7 @@ struct NostrTransportTests { func favoriteStatusNotificationRefreshesReachability() async throws { let keychain = MockKeychain() let idBridge = NostrIdentityBridge(keychain: keychain) + let ndrService = try makeNdrService(label: "favorite-refresh") let recipient = try NostrIdentity.generate() let noiseKey = Data((32..<64).map(UInt8.init)) let peerID = PeerID(hexData: noiseKey).toShort() @@ -64,6 +67,7 @@ struct NostrTransportTests { let transport = NostrTransport( keychain: keychain, idBridge: idBridge, + ndrService: ndrService, dependencies: makeDependencies( notificationCenter: notificationCenter, loadFavorites: { favorites }, @@ -136,6 +140,7 @@ struct NostrTransportTests { func sendPrivateMessageResolvesShortPeerID() async throws { let keychain = MockKeychain() let idBridge = NostrIdentityBridge(keychain: keychain) + let ndrService = try makeNdrService(label: "private-message") let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() let noiseKey = Data((64..<96).map(UInt8.init)) @@ -149,6 +154,7 @@ struct NostrTransportTests { let transport = NostrTransport( keychain: keychain, idBridge: idBridge, + ndrService: ndrService, dependencies: makeDependencies( favoriteStatusForNoiseKey: { _ in nil }, favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil }, @@ -176,11 +182,487 @@ struct NostrTransportTests { #expect(probe.pendingGiftWrapIDs.isEmpty) } + @Test("Private message prefers NDR when a session already exists") + @MainActor + func sendPrivateMessagePrefersNdrWhenSessionExists() 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 senderStorage = try makeTempDir(label: "transport-ndr-sender") + let recipientStorage = try makeTempDir(label: "transport-ndr-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 + ) + + let noiseKey = Data((64..<96).map(UInt8.init)) + let fullPeerID = PeerID(hexData: noiseKey) + let relationship = makeRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: recipient.npub, + peerNickname: "Carol" + ) + let transport = NostrTransport( + keychain: keychain, + idBridge: idBridge, + ndrService: senderNdr, + dependencies: makeDependencies( + favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil }, + favoriteStatusForPeerID: { _ in nil }, + currentIdentity: { sender } + ) + ) + transport.senderPeerID = PeerID(str: "0123456789abcdef") + + let transportUsed = try transport.sendPrivateMessageAndReturnTransport( + "hello via ndr", + to: fullPeerID, + recipientNickname: "Carol", + messageID: "pm-ndr" + ) + + #expect(transportUsed == .ndr) + #expect(senderRelay.sentEvents.contains(where: { $0.kind == 1060 })) + } + + @Test("Disappearing-message expiry reaches the pairwise delivery") + @MainActor + 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() + let recipient = try NostrIdentity.generate() + let senderRelay = FakeRelayManager() + let recipientRelay = FakeRelayManager() + let senderStorage = try makeTempDir(label: "transport-ndr-queued-sender") + let recipientStorage = try makeTempDir(label: "transport-ndr-queued-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 + ) + #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() + let noiseKey = Data((80..<112).map(UInt8.init)) + let fullPeerID = PeerID(hexData: noiseKey) + let relationship = makeRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: recipient.npub, + peerNickname: "Queued" + ) + 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") + + let transportUsed = try transport.sendPrivateMessageAndReturnTransport( + "pairwise ndr", + to: fullPeerID, + recipientNickname: "Queued", + messageID: "pm-pairwise-ndr" + ) + + #expect(transportUsed == .ndr) + #expect(probe.sentEvents.isEmpty) + #expect(senderRelay.sentEvents.filter { $0.kind == 1060 }.count == 1) + } + + @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) + + // 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) + ) + + 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") @MainActor func sendFavoriteNotificationEmbedsCurrentIdentity() async throws { let keychain = MockKeychain() let idBridge = NostrIdentityBridge(keychain: keychain) + let ndrService = try makeNdrService(label: "favorite-notification") let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() let noiseKey = Data((96..<128).map(UInt8.init)) @@ -194,6 +676,7 @@ struct NostrTransportTests { let transport = NostrTransport( keychain: keychain, idBridge: idBridge, + ndrService: ndrService, dependencies: makeDependencies( favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil }, favoriteStatusForPeerID: { _ in nil }, @@ -222,6 +705,7 @@ struct NostrTransportTests { func sendDeliveryAckEmitsDeliveredAck() async throws { let keychain = MockKeychain() let idBridge = NostrIdentityBridge(keychain: keychain) + let ndrService = try makeNdrService(label: "delivery-ack") let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() let noiseKey = Data((128..<160).map(UInt8.init)) @@ -235,6 +719,7 @@ struct NostrTransportTests { let transport = NostrTransport( keychain: keychain, idBridge: idBridge, + ndrService: ndrService, dependencies: makeDependencies( favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil }, favoriteStatusForPeerID: { _ in nil }, @@ -259,17 +744,139 @@ struct NostrTransportTests { #expect(result.packet.recipientID == fullPeerID.toShort().routingData) } + @Test("Direct delivery and read ACKs stay on an established NDR session") + @MainActor + func directAcksUseNdrWhenSessionExists() async throws { + let keychain = MockKeychain() + 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: "direct-ack-ndr-sender") + } + ) + let recipientNdr = NdrNostrService( + relayManager: recipientRelay, + rolloutEnabled: true, + storageDirectoryProvider: { + try makeTempDir(label: "direct-ack-ndr-recipient") + } + ) + 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((144..<176).map(UInt8.init)) + let peerID = PeerID(hexData: noiseKey) + let relationship = makeRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: recipient.npub, + peerNickname: "Ack peer" + ) + let legacyProbe = NostrTransportProbe() + let transport = NostrTransport( + keychain: keychain, + idBridge: NostrIdentityBridge(keychain: keychain), + ndrService: senderNdr, + dependencies: makeDependencies( + favoriteStatusForNoiseKey: { + $0 == noiseKey ? relationship : nil + }, + isNdrFallbackBlockedForPeerID: { + $0.toShort() == peerID.toShort() + }, + currentIdentity: { sender }, + sendEvent: legacyProbe.record(event:), + scheduleAfter: { delay, action in + legacyProbe.enqueueScheduledAction( + delay: delay, + action: action + ) + } + ) + ) + transport.senderPeerID = PeerID(str: "0123456789abcdef") + var decryptedMessages: [NdrDecryptedMessage] = [] + recipientNdr.onDecryptedMessage = { message, completion in + decryptedMessages.append(message) + completion(.consumed) + } + + transport.sendDeliveryAck(for: "ndr-delivered-1", to: peerID) + let deliveredSent = await TestHelpers.waitUntil({ + senderRelay.sentEvents.filter { $0.kind == 1060 }.count == 1 + }) + #expect(deliveredSent) + let deliveredOuter = try #require( + senderRelay.sentEvents.first { $0.kind == 1060 } + ) + recipientNdr.processInboundRelayEvent(deliveredOuter) + + let receipt = ReadReceipt( + originalMessageID: "ndr-read-1", + readerID: transport.myPeerID, + readerNickname: "me" + ) + transport.sendReadReceipt(receipt, to: peerID) + let readQueued = await TestHelpers.waitUntil({ + legacyProbe.scheduledActionCount == 1 + }) + #expect(readQueued) + #expect(legacyProbe.runNextScheduledAction()) + let readSent = await TestHelpers.waitUntil({ + senderRelay.sentEvents.filter { $0.kind == 1060 }.count == 2 + }) + #expect(readSent) + let readOuter = try #require( + senderRelay.sentEvents.filter { $0.kind == 1060 }.last + ) + recipientNdr.processInboundRelayEvent(readOuter) + + #expect(legacyProbe.sentEvents.isEmpty) + #expect(decryptedMessages.count == 2) + let deliveredPayload = try decodeNdrEmbeddedPayload( + from: decryptedMessages[0].event.content + ) + #expect(deliveredPayload.type == .delivered) + #expect( + String(data: deliveredPayload.data, encoding: .utf8) + == "ndr-delivered-1" + ) + let readPayload = try decodeNdrEmbeddedPayload( + from: decryptedMessages[1].event.content + ) + #expect(readPayload.type == .readReceipt) + #expect( + String(data: readPayload.data, encoding: .utf8) + == "ndr-read-1" + ) + } + @Test("Geohash private message registers pending gift wrap") @MainActor func sendPrivateMessageGeohashRegistersPendingGiftWrap() async throws { let keychain = MockKeychain() let idBridge = NostrIdentityBridge(keychain: keychain) + let ndrService = try makeNdrService(label: "geohash-pm") let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() let probe = NostrTransportProbe() let transport = NostrTransport( keychain: keychain, idBridge: idBridge, + ndrService: ndrService, dependencies: makeDependencies( currentIdentity: { sender }, registerPendingGiftWrap: probe.recordPendingGiftWrap(id:), @@ -305,6 +912,7 @@ struct NostrTransportTests { func readReceiptQueueThrottlesSequentially() async throws { let keychain = MockKeychain() let idBridge = NostrIdentityBridge(keychain: keychain) + let ndrService = try makeNdrService(label: "read-queue") let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() let noiseKey = Data((160..<192).map(UInt8.init)) @@ -318,6 +926,7 @@ struct NostrTransportTests { let transport = NostrTransport( keychain: keychain, idBridge: idBridge, + ndrService: ndrService, dependencies: makeDependencies( favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil }, favoriteStatusForPeerID: { _ in nil }, @@ -372,7 +981,8 @@ struct NostrTransportTests { func concurrentReadReceiptEnqueue() async throws { let keychain = MockKeychain() let idBridge = NostrIdentityBridge(keychain: keychain) - let transport = NostrTransport(keychain: keychain, idBridge: idBridge) + let ndrService = try makeNdrService(label: "concurrent-read") + let transport = NostrTransport(keychain: keychain, idBridge: idBridge, ndrService: ndrService) let iterations = 100 await withCheckedContinuation { (continuation: CheckedContinuation) in @@ -397,7 +1007,8 @@ struct NostrTransportTests { func isPeerReachableThreadSafety() async throws { let keychain = MockKeychain() let idBridge = NostrIdentityBridge(keychain: keychain) - let transport = NostrTransport(keychain: keychain, idBridge: idBridge) + let ndrService = try makeNdrService(label: "reachable-thread-safety") + let transport = NostrTransport(keychain: keychain, idBridge: idBridge, ndrService: ndrService) let iterations = 100 await withCheckedContinuation { (continuation: CheckedContinuation) in @@ -418,6 +1029,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 }, @@ -429,6 +1042,10 @@ struct NostrTransportTests { loadFavorites: loadFavorites, favoriteStatusForNoiseKey: favoriteStatusForNoiseKey, favoriteStatusForPeerID: favoriteStatusForPeerID, + canUseNdrBindingForPeerID: + canUseNdrBindingForPeerID, + isNdrFallbackBlockedForPeerID: + isNdrFallbackBlockedForPeerID, currentIdentity: currentIdentity, registerPendingGiftWrap: registerPendingGiftWrap, sendEvent: sendEvent, @@ -437,6 +1054,16 @@ struct NostrTransportTests { ) } + @MainActor + private func makeNdrService(label: String) throws -> NdrNostrService { + let storage = try makeTempDir(label: label) + return NdrNostrService( + relayManager: FakeRelayManager(), + rolloutEnabled: true, + storageDirectoryProvider: { storage } + ) + } + private func makeRelationship( peerNoisePublicKey: Data, peerNostrPublicKey: String?, @@ -453,6 +1080,92 @@ struct NostrTransportTests { ) } + private func makeTempDir(label: String) throws -> URL { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent( + "bitchat-tests-\(label)-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) + return dir + } + + @MainActor + private func establishMutualSession( + _ senderService: NdrNostrService, + _ recipientService: NdrNostrService, + senderIdentity: NostrIdentity, + recipientIdentity: NostrIdentity, + senderRelay: FakeRelayManager, + recipientRelay: FakeRelayManager + ) throws { + 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 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 } + ) + } + + 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( from event: NostrEvent, recipient: NostrIdentity @@ -473,6 +1186,21 @@ struct NostrTransportTests { return (packet, payload, senderPubkey) } + private func decodeNdrEmbeddedPayload( + from content: String + ) throws -> NoisePayload { + guard content.hasPrefix("bitchat1:") else { + throw NostrTransportTestError.invalidEmbeddedContent + } + let encoded = String(content.dropFirst("bitchat1:".count)) + guard let packetData = base64URLDecode(encoded), + let packet = BitchatPacket.from(packetData), + let payload = NoisePayload.decode(packet.payload) else { + throw NostrTransportTestError.invalidPacket + } + return payload + } + private func decodePrivateMessage(from payload: NoisePayload) throws -> PrivateMessagePacket { guard payload.type == .privateMessage, let message = PrivateMessagePacket.decode(from: payload.data) else { @@ -480,12 +1208,15 @@ struct NostrTransportTests { } return message } + } private enum NostrTransportTestError: Error { case invalidEmbeddedContent case invalidPacket case invalidPrivateMessage + case failedToEstablishNdrSession + case storageUnavailable } private func base64URLDecode(_ string: String) -> Data? { diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift index b1308c9b..695c46e7 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift @@ -40,6 +40,11 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable { /// this bit; keep it decodable so the wire assignment is never reused. public static let nonDestructiveNoiseReplacement = PeerCapabilities(rawValue: 1 << 10) + /// Supports double-ratchet relay DMs with invite/response bootstrap carried + /// as Noise payload `0x22`. Both the capability and the payload value are + /// coordinated with Android before either feature branch can ship. + public static let doubleRatchet = + PeerCapabilities(rawValue: 1 << 11) /// Minimal little-endian byte encoding; always at least one byte so an /// empty set is distinguishable from an absent TLV. diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift index d66f0cdf..90fed1c9 100644 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift @@ -27,8 +27,10 @@ struct PeerCapabilitiesTests { == Data([0x00, 0x04]) ) - let high = PeerCapabilities(rawValue: 1 << 11) - #expect(high.encoded() == Data([0x00, 0x08])) + #expect( + PeerCapabilities.doubleRatchet.encoded() + == Data([0x00, 0x08]) + ) let all: PeerCapabilities = [ .prekeys, @@ -40,10 +42,15 @@ struct PeerCapabilitiesTests { .meshDiagnostics, .privateMedia, .privateMediaReceipts, - .nonDestructiveNoiseReplacement + .nonDestructiveNoiseReplacement, + .doubleRatchet ] #expect(PeerCapabilities(encoded: all.encoded()) == all) - #expect(PeerCapabilities(encoded: high.encoded()) == high) + #expect( + PeerCapabilities( + encoded: PeerCapabilities.doubleRatchet.encoded() + ) == .doubleRatchet + ) #expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == []) } diff --git a/localPackages/NdrFfi/Package.swift b/localPackages/NdrFfi/Package.swift new file mode 100644 index 00000000..2d5caf16 --- /dev/null +++ b/localPackages/NdrFfi/Package.swift @@ -0,0 +1,38 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "NdrFfi", + platforms: [ + .iOS(.v16), + .macOS(.v13) + ], + products: [ + .library( + name: "NdrFfi", + targets: ["NdrFfi"] + ) + ], + targets: [ + // Swift bindings generated by uniffi-bindgen + .target( + name: "NdrFfi", + dependencies: ["ndr_ffiFFI"], + path: "Sources/NdrFfi" + ), + // 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( + name: "ndr_ffiFFI", + path: "Frameworks/NdrFfi.xcframework" + ), + // Tests + .testTarget( + name: "NdrFfiTests", + dependencies: ["NdrFfi"], + path: "Tests" + ) + ] +) diff --git a/localPackages/NdrFfi/README.md b/localPackages/NdrFfi/README.md new file mode 100644 index 00000000..4b38a52f --- /dev/null +++ b/localPackages/NdrFfi/README.md @@ -0,0 +1,77 @@ +# NdrFfi + +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 +`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/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 + +Prerequisites: + +- Xcode and command line tools +- Rust toolchain version from `RUST_TOOLCHAIN`, with cargo +- Rust targets: + - `aarch64-apple-darwin` + - `x86_64-apple-darwin` + - `aarch64-apple-ios` + - `aarch64-apple-ios-sim` + - `x86_64-apple-ios` + +Example: + +```bash +rustup target add \ + aarch64-apple-darwin \ + x86_64-apple-darwin \ + aarch64-apple-ios \ + aarch64-apple-ios-sim \ + x86_64-apple-ios +git submodule update --init --checkout vendor/nostr-double-ratchet +./localPackages/NdrFfi/build-apple.sh +``` + +Or: + +```bash +cd localPackages/NdrFfi +NOSTR_DOUBLE_RATCHET_DIR=/path/to/nostr-double-ratchet ./build-apple.sh +``` + +The script: + +- 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` +- bakes in the current Apple deployment targets used by `bitchat` + +The NDR runtime is an embedded dynamic framework because the shipping app +already links Arti as a Rust static library. Rust static libraries each bundle +their own standard library and are not safe to combine independently in one +foreign-linker process. + +## Outputs Updated By The Script + +- `Sources/NdrFfi/NdrFfi.swift` +- `Frameworks/NdrFfi.xcframework` (local build output, ignored) + +## Recommended Verification + +```bash +swift test --package-path localPackages/NdrFfi +swift test --filter NdrOutOfBandTransportTests +swift test --filter NostrTransportTests +``` diff --git a/localPackages/NdrFfi/RUST_TOOLCHAIN b/localPackages/NdrFfi/RUST_TOOLCHAIN new file mode 100644 index 00000000..55f6ae93 --- /dev/null +++ b/localPackages/NdrFfi/RUST_TOOLCHAIN @@ -0,0 +1 @@ +1.95.0 diff --git a/localPackages/NdrFfi/SOURCE_REVISION b/localPackages/NdrFfi/SOURCE_REVISION new file mode 100644 index 00000000..9c18dcab --- /dev/null +++ b/localPackages/NdrFfi/SOURCE_REVISION @@ -0,0 +1 @@ +0fe8caf2d4e24e2030ffae195597a2764613a659 diff --git a/localPackages/NdrFfi/Sources/NdrFfi/NdrFfi.swift b/localPackages/NdrFfi/Sources/NdrFfi/NdrFfi.swift new file mode 100644 index 00000000..b9d900fb --- /dev/null +++ b/localPackages/NdrFfi/Sources/NdrFfi/NdrFfi.swift @@ -0,0 +1,1707 @@ +// This file was autogenerated by some hot garbage in the `uniffi` crate. +// Trust me, you don't want to mess with it! + +// swiftlint:disable all +import Foundation + +// Depending on the consumer's build setup, the low-level FFI code +// might be in a separate module, or it might be compiled inline into +// this module. This is a bit of light hackery to work with both. +#if canImport(ndr_ffiFFI) +import ndr_ffiFFI +#endif + +fileprivate extension RustBuffer { + // Allocate a new buffer, copying the contents of a `UInt8` array. + init(bytes: [UInt8]) { + let rbuf = bytes.withUnsafeBufferPointer { ptr in + RustBuffer.from(ptr) + } + self.init(capacity: rbuf.capacity, len: rbuf.len, data: rbuf.data) + } + + static func empty() -> RustBuffer { + RustBuffer(capacity: 0, len:0, data: nil) + } + + static func from(_ ptr: UnsafeBufferPointer) -> RustBuffer { + try! rustCall { ffi_ndr_ffi_rustbuffer_from_bytes(ForeignBytes(bufferPointer: ptr), $0) } + } + + // Frees the buffer in place. + // The buffer must not be used after this is called. + func deallocate() { + try! rustCall { ffi_ndr_ffi_rustbuffer_free(self, $0) } + } +} + +fileprivate extension ForeignBytes { + init(bufferPointer: UnsafeBufferPointer) { + self.init(len: Int32(bufferPointer.count), data: bufferPointer.baseAddress) + } +} + +// For every type used in the interface, we provide helper methods for conveniently +// lifting and lowering that type from C-compatible data, and for reading and writing +// values of that type in a buffer. + +// Helper classes/extensions that don't change. +// Someday, this will be in a library of its own. + +fileprivate extension Data { + init(rustBuffer: RustBuffer) { + self.init( + bytesNoCopy: rustBuffer.data!, + count: Int(rustBuffer.len), + deallocator: .none + ) + } +} + +// Define reader functionality. Normally this would be defined in a class or +// struct, but we use standalone functions instead in order to make external +// types work. +// +// With external types, one swift source file needs to be able to call the read +// method on another source file's FfiConverter, but then what visibility +// should Reader have? +// - If Reader is fileprivate, then this means the read() must also +// be fileprivate, which doesn't work with external types. +// - If Reader is internal/public, we'll get compile errors since both source +// files will try define the same type. +// +// Instead, the read() method and these helper functions input a tuple of data + +fileprivate func createReader(data: Data) -> (data: Data, offset: Data.Index) { + (data: data, offset: 0) +} + +// Reads an integer at the current offset, in big-endian order, and advances +// the offset on success. Throws if reading the integer would move the +// offset past the end of the buffer. +fileprivate func readInt(_ reader: inout (data: Data, offset: Data.Index)) throws -> T { + let range = reader.offset...size + guard reader.data.count >= range.upperBound else { + throw UniffiInternalError.bufferOverflow + } + if T.self == UInt8.self { + let value = reader.data[reader.offset] + reader.offset += 1 + return value as! T + } + var value: T = 0 + let _ = withUnsafeMutableBytes(of: &value, { reader.data.copyBytes(to: $0, from: range)}) + reader.offset = range.upperBound + return value.bigEndian +} + +// Reads an arbitrary number of bytes, to be used to read +// raw bytes, this is useful when lifting strings +fileprivate func readBytes(_ reader: inout (data: Data, offset: Data.Index), count: Int) throws -> Array { + let range = reader.offset..<(reader.offset+count) + guard reader.data.count >= range.upperBound else { + throw UniffiInternalError.bufferOverflow + } + var value = [UInt8](repeating: 0, count: count) + value.withUnsafeMutableBufferPointer({ buffer in + reader.data.copyBytes(to: buffer, from: range) + }) + reader.offset = range.upperBound + return value +} + +// Reads a float at the current offset. +fileprivate func readFloat(_ reader: inout (data: Data, offset: Data.Index)) throws -> Float { + return Float(bitPattern: try readInt(&reader)) +} + +// Reads a float at the current offset. +fileprivate func readDouble(_ reader: inout (data: Data, offset: Data.Index)) throws -> Double { + return Double(bitPattern: try readInt(&reader)) +} + +// Indicates if the offset has reached the end of the buffer. +fileprivate func hasRemaining(_ reader: (data: Data, offset: Data.Index)) -> Bool { + return reader.offset < reader.data.count +} + +// Define writer functionality. Normally this would be defined in a class or +// struct, but we use standalone functions instead in order to make external +// types work. See the above discussion on Readers for details. + +fileprivate func createWriter() -> [UInt8] { + return [] +} + +fileprivate func writeBytes(_ writer: inout [UInt8], _ byteArr: S) where S: Sequence, S.Element == UInt8 { + writer.append(contentsOf: byteArr) +} + +// Writes an integer in big-endian order. +// +// Warning: make sure what you are trying to write +// is in the correct type! +fileprivate func writeInt(_ writer: inout [UInt8], _ value: T) { + var value = value.bigEndian + withUnsafeBytes(of: &value) { writer.append(contentsOf: $0) } +} + +fileprivate func writeFloat(_ writer: inout [UInt8], _ value: Float) { + writeInt(&writer, value.bitPattern) +} + +fileprivate func writeDouble(_ writer: inout [UInt8], _ value: Double) { + writeInt(&writer, value.bitPattern) +} + +// Protocol for types that transfer other types across the FFI. This is +// analogous to the Rust trait of the same name. +fileprivate protocol FfiConverter { + associatedtype FfiType + associatedtype SwiftType + + static func lift(_ value: FfiType) throws -> SwiftType + static func lower(_ value: SwiftType) -> FfiType + static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType + static func write(_ value: SwiftType, into buf: inout [UInt8]) +} + +// Types conforming to `Primitive` pass themselves directly over the FFI. +fileprivate protocol FfiConverterPrimitive: FfiConverter where FfiType == SwiftType { } + +extension FfiConverterPrimitive { +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public static func lift(_ value: FfiType) throws -> SwiftType { + return value + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public static func lower(_ value: SwiftType) -> FfiType { + return value + } +} + +// Types conforming to `FfiConverterRustBuffer` lift and lower into a `RustBuffer`. +// Used for complex types where it's hard to write a custom lift/lower. +fileprivate protocol FfiConverterRustBuffer: FfiConverter where FfiType == RustBuffer {} + +extension FfiConverterRustBuffer { +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public static func lift(_ buf: RustBuffer) throws -> SwiftType { + var reader = createReader(data: Data(rustBuffer: buf)) + let value = try read(from: &reader) + if hasRemaining(reader) { + throw UniffiInternalError.incompleteData + } + buf.deallocate() + return value + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public static func lower(_ value: SwiftType) -> RustBuffer { + var writer = createWriter() + write(value, into: &writer) + return RustBuffer(bytes: writer) + } +} +// An error type for FFI errors. These errors occur at the UniFFI level, not +// the library level. +fileprivate enum UniffiInternalError: LocalizedError { + case bufferOverflow + case incompleteData + case unexpectedOptionalTag + case unexpectedEnumCase + case unexpectedNullPointer + case unexpectedRustCallStatusCode + case unexpectedRustCallError + case unexpectedStaleHandle + case rustPanic(_ message: String) + + public var errorDescription: String? { + switch self { + case .bufferOverflow: return "Reading the requested value would read past the end of the buffer" + case .incompleteData: return "The buffer still has data after lifting its containing value" + case .unexpectedOptionalTag: return "Unexpected optional tag; should be 0 or 1" + case .unexpectedEnumCase: return "Raw enum value doesn't match any cases" + case .unexpectedNullPointer: return "Raw pointer value was null" + case .unexpectedRustCallStatusCode: return "Unexpected RustCallStatus code" + case .unexpectedRustCallError: return "CALL_ERROR but no errorClass specified" + case .unexpectedStaleHandle: return "The object in the handle map has been dropped already" + case let .rustPanic(message): return message + } + } +} + +fileprivate extension NSLock { + func withLock(f: () throws -> T) rethrows -> T { + self.lock() + defer { self.unlock() } + return try f() + } +} + +fileprivate let CALL_SUCCESS: Int8 = 0 +fileprivate let CALL_ERROR: Int8 = 1 +fileprivate let CALL_UNEXPECTED_ERROR: Int8 = 2 +fileprivate let CALL_CANCELLED: Int8 = 3 + +fileprivate extension RustCallStatus { + init() { + self.init( + code: CALL_SUCCESS, + errorBuf: RustBuffer.init( + capacity: 0, + len: 0, + data: nil + ) + ) + } +} + +private func rustCall(_ callback: (UnsafeMutablePointer) -> T) throws -> T { + let neverThrow: ((RustBuffer) throws -> Never)? = nil + return try makeRustCall(callback, errorHandler: neverThrow) +} + +private func rustCallWithError( + _ errorHandler: @escaping (RustBuffer) throws -> E, + _ callback: (UnsafeMutablePointer) -> T) throws -> T { + try makeRustCall(callback, errorHandler: errorHandler) +} + +private func makeRustCall( + _ callback: (UnsafeMutablePointer) -> T, + errorHandler: ((RustBuffer) throws -> E)? +) throws -> T { + uniffiEnsureNdrFfiInitialized() + var callStatus = RustCallStatus.init() + let returnedVal = callback(&callStatus) + try uniffiCheckCallStatus(callStatus: callStatus, errorHandler: errorHandler) + return returnedVal +} + +private func uniffiCheckCallStatus( + callStatus: RustCallStatus, + errorHandler: ((RustBuffer) throws -> E)? +) throws { + switch callStatus.code { + case CALL_SUCCESS: + return + + case CALL_ERROR: + if let errorHandler = errorHandler { + throw try errorHandler(callStatus.errorBuf) + } else { + callStatus.errorBuf.deallocate() + throw UniffiInternalError.unexpectedRustCallError + } + + case CALL_UNEXPECTED_ERROR: + // When the rust code sees a panic, it tries to construct a RustBuffer + // with the message. But if that code panics, then it just sends back + // an empty buffer. + if callStatus.errorBuf.len > 0 { + throw UniffiInternalError.rustPanic(try FfiConverterString.lift(callStatus.errorBuf)) + } else { + callStatus.errorBuf.deallocate() + throw UniffiInternalError.rustPanic("Rust panic") + } + + case CALL_CANCELLED: + fatalError("Cancellation not supported yet") + + default: + throw UniffiInternalError.unexpectedRustCallStatusCode + } +} + +private func uniffiTraitInterfaceCall( + callStatus: UnsafeMutablePointer, + makeCall: () throws -> T, + writeReturn: (T) -> () +) { + do { + try writeReturn(makeCall()) + } catch let error { + callStatus.pointee.code = CALL_UNEXPECTED_ERROR + callStatus.pointee.errorBuf = FfiConverterString.lower(String(describing: error)) + } +} + +private func uniffiTraitInterfaceCallWithError( + callStatus: UnsafeMutablePointer, + makeCall: () throws -> T, + writeReturn: (T) -> (), + lowerError: (E) -> RustBuffer +) { + do { + try writeReturn(makeCall()) + } catch let error as E { + callStatus.pointee.code = CALL_ERROR + callStatus.pointee.errorBuf = lowerError(error) + } catch { + callStatus.pointee.code = CALL_UNEXPECTED_ERROR + callStatus.pointee.errorBuf = FfiConverterString.lower(String(describing: error)) + } +} +fileprivate final class UniffiHandleMap: @unchecked Sendable { + // All mutation happens with this lock held, which is why we implement @unchecked Sendable. + private let lock = NSLock() + private var map: [UInt64: T] = [:] + private var currentHandle: UInt64 = 1 + + func insert(obj: T) -> UInt64 { + lock.withLock { + let handle = currentHandle + currentHandle += 1 + map[handle] = obj + return handle + } + } + + func get(handle: UInt64) throws -> T { + try lock.withLock { + guard let obj = map[handle] else { + throw UniffiInternalError.unexpectedStaleHandle + } + return obj + } + } + + @discardableResult + func remove(handle: UInt64) throws -> T { + try lock.withLock { + guard let obj = map.removeValue(forKey: handle) else { + throw UniffiInternalError.unexpectedStaleHandle + } + return obj + } + } + + var count: Int { + get { + map.count + } + } +} + + +// Public interface members begin here. + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterUInt64: FfiConverterPrimitive { + typealias FfiType = UInt64 + typealias SwiftType = UInt64 + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UInt64 { + return try lift(readInt(&buf)) + } + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterBool : FfiConverter { + typealias FfiType = Int8 + typealias SwiftType = Bool + + public static func lift(_ value: Int8) throws -> Bool { + return value != 0 + } + + public static func lower(_ value: Bool) -> Int8 { + return value ? 1 : 0 + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Bool { + return try lift(readInt(&buf)) + } + + public static func write(_ value: Bool, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterString: FfiConverter { + typealias SwiftType = String + typealias FfiType = RustBuffer + + public static func lift(_ value: RustBuffer) throws -> String { + defer { + value.deallocate() + } + if value.data == nil { + return String() + } + let bytes = UnsafeBufferPointer(start: value.data!, count: Int(value.len)) + return String(bytes: bytes, encoding: String.Encoding.utf8)! + } + + public static func lower(_ value: String) -> RustBuffer { + return value.utf8CString.withUnsafeBufferPointer { ptr in + // The swift string gives us int8_t, we want uint8_t. + ptr.withMemoryRebound(to: UInt8.self) { ptr in + // The swift string gives us a trailing null byte, we don't want it. + let buf = UnsafeBufferPointer(rebasing: ptr.prefix(upTo: ptr.count - 1)) + return RustBuffer.from(buf) + } + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> String { + let len: Int32 = try readInt(&buf) + return String(bytes: try readBytes(&buf, count: Int(len)), encoding: String.Encoding.utf8)! + } + + public static func write(_ value: String, into buf: inout [UInt8]) { + let len = Int32(value.utf8.count) + writeInt(&buf, len) + writeBytes(&buf, value.utf8) + } +} + + + + +public protocol PairwiseInviteProtocol: AnyObject, Sendable { + + func getPeerPubkeyHex() -> String + + func toUrl(root: String) throws -> String + +} +open class PairwiseInvite: PairwiseInviteProtocol, @unchecked Sendable { + fileprivate let pointer: UnsafeMutableRawPointer! + + /// Used to instantiate a [FFIObject] without an actual pointer, for fakes in tests, mostly. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public struct NoPointer { + public init() {} + } + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + required public init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { + self.pointer = pointer + } + + // This constructor can be used to instantiate a fake object. + // - Parameter noPointer: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing [Pointer] the FFI lower functions will crash. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public init(noPointer: NoPointer) { + self.pointer = nil + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public func uniffiClonePointer() -> UnsafeMutableRawPointer { + return try! rustCall { uniffi_ndr_ffi_fn_clone_pairwiseinvite(self.pointer, $0) } + } + // No primary constructor declared for this class. + + deinit { + guard let pointer = pointer else { + return + } + + try! rustCall { uniffi_ndr_ffi_fn_free_pairwiseinvite(pointer, $0) } + } + + +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 -> PairwiseInvite { + return try FfiConverterTypePairwiseInvite_lift(try rustCallWithError(FfiConverterTypeNdrError_lift) { + uniffi_ndr_ffi_fn_constructor_pairwiseinvite_from_url( + FfiConverterString.lower(url),$0 + ) +}) +} + + + +open func getPeerPubkeyHex() -> String { + return try! FfiConverterString.lift(try! rustCall() { + 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_pairwiseinvite_to_url(self.uniffiClonePointer(), + FfiConverterString.lower(root),$0 + ) +}) +} + + +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePairwiseInvite: FfiConverter { + + typealias FfiType = UnsafeMutableRawPointer + typealias SwiftType = PairwiseInvite + + public static func lift(_ pointer: UnsafeMutableRawPointer) throws -> PairwiseInvite { + return PairwiseInvite(unsafeFromRawPointer: pointer) + } + + public static func lower(_ value: PairwiseInvite) -> UnsafeMutableRawPointer { + return value.uniffiClonePointer() + } + + 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. + let ptr = UnsafeMutableRawPointer(bitPattern: UInt(truncatingIfNeeded: v)) + if (ptr == nil) { + throw UniffiInternalError.unexpectedNullPointer + } + return try lift(ptr!) + } + + 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))))) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePairwiseInvite_lift(_ pointer: UnsafeMutableRawPointer) throws -> PairwiseInvite { + return try FfiConverterTypePairwiseInvite.lift(pointer) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePairwiseInvite_lower(_ value: PairwiseInvite) -> UnsafeMutableRawPointer { + return FfiConverterTypePairwiseInvite.lower(value) +} + + + + + + +public protocol PairwiseManagerProtocol: AnyObject, Sendable { + + func acceptInviteFromEventJson(eventJson: String, authenticatedPeerPubkeyHex: String) throws -> PairwiseAcceptResult + + func acceptInviteFromUrl(inviteUrl: String, authenticatedPeerPubkeyHex: String) throws -> PairwiseAcceptResult + + func ackActions(actionIds: [String]) throws + + func currentInviteEventJson() throws -> String + + func currentInviteUrl(root: String) throws -> String + + func getOurPubkeyHex() throws -> String + + func getTotalSessions() throws -> UInt64 + + func knownPeerPubkeys() throws -> [String] + + func pendingActions() throws -> [PairwiseAction] + + func pendingActionsAt(nowSeconds: UInt64) throws -> [PairwiseAction] + + func processEvent(eventJson: String) throws + + func processOutOfBandResponse(eventJson: String, authenticatedPeerPubkeyHex: String) throws + + func retirePeer(peerPubkeyHex: String) throws -> Bool + + func sendText(peerPubkeyHex: String, text: String, expiresAtSeconds: UInt64?) throws -> PairwiseSendResult + + func sessionInfo(peerPubkeyHex: String) throws -> PairwiseSessionInfo? + +} +open class PairwiseManager: PairwiseManagerProtocol, @unchecked Sendable { + fileprivate let pointer: UnsafeMutableRawPointer! + + /// Used to instantiate a [FFIObject] without an actual pointer, for fakes in tests, mostly. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public struct NoPointer { + public init() {} + } + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + required public init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { + self.pointer = pointer + } + + // This constructor can be used to instantiate a fake object. + // - Parameter noPointer: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing [Pointer] the FFI lower functions will crash. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public init(noPointer: NoPointer) { + self.pointer = nil + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public func uniffiClonePointer() -> UnsafeMutableRawPointer { + return try! rustCall { uniffi_ndr_ffi_fn_clone_pairwisemanager(self.pointer, $0) } + } + // No primary constructor declared for this class. + + deinit { + guard let pointer = pointer else { + return + } + + try! rustCall { uniffi_ndr_ffi_fn_free_pairwisemanager(pointer, $0) } + } + + +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(ourIdentityPrivateKeyHex), + FfiConverterString.lower(storagePath),$0 + ) +}) +} + + + +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), + FfiConverterString.lower(authenticatedPeerPubkeyHex),$0 + ) +}) +} + +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), + FfiConverterString.lower(authenticatedPeerPubkeyHex),$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 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 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 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_pairwisemanager_known_peer_pubkeys(self.uniffiClonePointer(),$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 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_pairwisemanager_process_event(self.uniffiClonePointer(), + FfiConverterString.lower(eventJson),$0 + ) +} +} + +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(authenticatedPeerPubkeyHex),$0 + ) +} +} + +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 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 + ) +}) +} + + +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePairwiseManager: FfiConverter { + + typealias FfiType = UnsafeMutableRawPointer + typealias SwiftType = PairwiseManager + + public static func lift(_ pointer: UnsafeMutableRawPointer) throws -> PairwiseManager { + return PairwiseManager(unsafeFromRawPointer: pointer) + } + + public static func lower(_ value: PairwiseManager) -> UnsafeMutableRawPointer { + return value.uniffiClonePointer() + } + + 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. + let ptr = UnsafeMutableRawPointer(bitPattern: UInt(truncatingIfNeeded: v)) + if (ptr == nil) { + throw UniffiInternalError.unexpectedNullPointer + } + return try lift(ptr!) + } + + 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))))) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePairwiseManager_lift(_ pointer: UnsafeMutableRawPointer) throws -> PairwiseManager { + return try FfiConverterTypePairwiseManager.lift(pointer) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePairwiseManager_lower(_ value: PairwiseManager) -> UnsafeMutableRawPointer { + return FfiConverterTypePairwiseManager.lower(value) +} + + + + +public struct FfiKeyPair { + public var publicKeyHex: String + public var privateKeyHex: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(publicKeyHex: String, privateKeyHex: String) { + self.publicKeyHex = publicKeyHex + self.privateKeyHex = privateKeyHex + } +} + +#if compiler(>=6) +extension FfiKeyPair: Sendable {} +#endif + + +extension FfiKeyPair: Equatable, Hashable { + public static func ==(lhs: FfiKeyPair, rhs: FfiKeyPair) -> Bool { + if lhs.publicKeyHex != rhs.publicKeyHex { + return false + } + if lhs.privateKeyHex != rhs.privateKeyHex { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(publicKeyHex) + hasher.combine(privateKeyHex) + } +} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeFfiKeyPair: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> FfiKeyPair { + return + try FfiKeyPair( + publicKeyHex: FfiConverterString.read(from: &buf), + privateKeyHex: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: FfiKeyPair, into buf: inout [UInt8]) { + FfiConverterString.write(value.publicKeyHex, into: &buf) + FfiConverterString.write(value.privateKeyHex, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeFfiKeyPair_lift(_ buf: RustBuffer) throws -> FfiKeyPair { + return try FfiConverterTypeFfiKeyPair.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeFfiKeyPair_lower(_ value: FfiKeyPair) -> RustBuffer { + return FfiConverterTypeFfiKeyPair.lower(value) +} + + +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(peerPubkeyHex: String, createdNewSession: Bool) { + self.peerPubkeyHex = peerPubkeyHex + self.createdNewSession = createdNewSession + } +} + +#if compiler(>=6) +extension PairwiseAcceptResult: Sendable {} +#endif + + +extension PairwiseAcceptResult: Equatable, Hashable { + public static func ==(lhs: PairwiseAcceptResult, rhs: PairwiseAcceptResult) -> Bool { + if lhs.peerPubkeyHex != rhs.peerPubkeyHex { + return false + } + if lhs.createdNewSession != rhs.createdNewSession { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(peerPubkeyHex) + hasher.combine(createdNewSession) + } +} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePairwiseAcceptResult: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PairwiseAcceptResult { + return + try PairwiseAcceptResult( + peerPubkeyHex: FfiConverterString.read(from: &buf), + createdNewSession: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: PairwiseAcceptResult, into buf: inout [UInt8]) { + FfiConverterString.write(value.peerPubkeyHex, into: &buf) + FfiConverterBool.write(value.createdNewSession, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePairwiseAcceptResult_lift(_ buf: RustBuffer) throws -> PairwiseAcceptResult { + return try FfiConverterTypePairwiseAcceptResult.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +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) +} + + +public enum NdrError: Swift.Error { + + + + case InvalidKey(String + ) + case InvalidEvent(String + ) + case PeerMismatch(String + ) + case SessionNotReady(String + ) + case QueueFull(String + ) + case Storage(String + ) + case CryptoFailure(String + ) +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeNdrError: FfiConverterRustBuffer { + typealias SwiftType = NdrError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> NdrError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .InvalidKey( + try FfiConverterString.read(from: &buf) + ) + case 2: return .InvalidEvent( + try FfiConverterString.read(from: &buf) + ) + case 3: return .PeerMismatch( + try FfiConverterString.read(from: &buf) + ) + case 4: return .SessionNotReady( + try FfiConverterString.read(from: &buf) + ) + case 5: return .QueueFull( + try FfiConverterString.read(from: &buf) + ) + case 6: return .Storage( + try FfiConverterString.read(from: &buf) + ) + case 7: return .CryptoFailure( + try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: NdrError, into buf: inout [UInt8]) { + switch value { + + + + + + case let .InvalidKey(v1): + writeInt(&buf, Int32(1)) + FfiConverterString.write(v1, into: &buf) + + + case let .InvalidEvent(v1): + writeInt(&buf, Int32(2)) + FfiConverterString.write(v1, into: &buf) + + + case let .PeerMismatch(v1): + writeInt(&buf, Int32(3)) + FfiConverterString.write(v1, into: &buf) + + + case let .SessionNotReady(v1): + writeInt(&buf, Int32(4)) + FfiConverterString.write(v1, into: &buf) + + + case let .QueueFull(v1): + writeInt(&buf, Int32(5)) + FfiConverterString.write(v1, into: &buf) + + + case let .Storage(v1): + writeInt(&buf, Int32(6)) + FfiConverterString.write(v1, into: &buf) + + + case let .CryptoFailure(v1): + writeInt(&buf, Int32(7)) + FfiConverterString.write(v1, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeNdrError_lift(_ buf: RustBuffer) throws -> NdrError { + return try FfiConverterTypeNdrError.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeNdrError_lower(_ value: NdrError) -> RustBuffer { + return FfiConverterTypeNdrError.lower(value) +} + + +extension NdrError: Equatable, Hashable {} + + + + +extension NdrError: Foundation.LocalizedError { + public var errorDescription: String? { + String(reflecting: self) + } +} + + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionUInt64: FfiConverterRustBuffer { + typealias SwiftType = UInt64? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterUInt64.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 FfiConverterUInt64.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionString: FfiConverterRustBuffer { + typealias SwiftType = String? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterString.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 FfiConverterString.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + +#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 +fileprivate struct FfiConverterSequenceString: FfiConverterRustBuffer { + typealias SwiftType = [String] + + public static func write(_ value: [String], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterString.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [String] { + let len: Int32 = try readInt(&buf) + var seq = [String]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterString.read(from: &buf)) + } + return seq + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypePairwiseAction: FfiConverterRustBuffer { + typealias SwiftType = [PairwiseAction] + + public static func write(_ value: [PairwiseAction], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypePairwiseAction.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [PairwiseAction] { + let len: Int32 = try readInt(&buf) + var seq = [PairwiseAction]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypePairwiseAction.read(from: &buf)) + } + return seq + } +} +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(privateKeyHex),$0 + ) +}) +} +public func generateKeypair() -> FfiKeyPair { + return try! FfiConverterTypeFfiKeyPair_lift(try! rustCall() { + uniffi_ndr_ffi_fn_func_generate_keypair($0 + ) +}) +} +public func version() -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffi_ndr_ffi_fn_func_version($0 + ) +}) +} + +private enum InitializationResult { + case ok + case contractVersionMismatch + case apiChecksumMismatch +} +// Use a global variable to perform the versioning checks. Swift ensures that +// the code inside is only computed once. +private let initializationResult: InitializationResult = { + // Get the bindings contract version from our ComponentInterface + let bindings_contract_version = 29 + // Get the scaffolding contract version by calling the into the dylib + let scaffolding_contract_version = ffi_ndr_ffi_uniffi_contract_version() + if bindings_contract_version != scaffolding_contract_version { + return InitializationResult.contractVersionMismatch + } + if (uniffi_ndr_ffi_checksum_func_derive_public_key() != 22065) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_func_generate_keypair() != 57537) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_func_version() != 35402) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_method_pairwiseinvite_get_peer_pubkey_hex() != 25596) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_method_pairwiseinvite_to_url() != 1141) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_accept_invite_from_event_json() != 42574) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_accept_invite_from_url() != 29995) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_ack_actions() != 17265) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_current_invite_event_json() != 41966) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_current_invite_url() != 50454) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_get_our_pubkey_hex() != 24347) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_get_total_sessions() != 5478) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_known_peer_pubkeys() != 21367) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_pending_actions() != 4469) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_pending_actions_at() != 26221) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_process_event() != 51097) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_process_out_of_band_response() != 48382) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_retire_peer() != 12247) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_send_text() != 20592) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_method_pairwisemanager_session_info() != 49395) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_constructor_pairwiseinvite_from_event_json() != 9371) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_constructor_pairwiseinvite_from_url() != 12100) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ndr_ffi_checksum_constructor_pairwisemanager_new_with_storage_path() != 24319) { + return InitializationResult.apiChecksumMismatch + } + + return InitializationResult.ok +}() + +// Make the ensure init function public so that other modules which have external type references to +// our types can call it. +public func uniffiEnsureNdrFfiInitialized() { + switch initializationResult { + case .ok: + break + case .contractVersionMismatch: + fatalError("UniFFI contract version mismatch: try cleaning and rebuilding your project") + case .apiChecksumMismatch: + fatalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } +} + +// swiftlint:enable all \ No newline at end of file diff --git a/localPackages/NdrFfi/Tests/NdrFfiTests.swift b/localPackages/NdrFfi/Tests/NdrFfiTests.swift new file mode 100644 index 00000000..1095e342 --- /dev/null +++ b/localPackages/NdrFfi/Tests/NdrFfiTests.swift @@ -0,0 +1,364 @@ +import Foundation +import XCTest +@testable import NdrFfi + +final class NdrFfiTests: XCTestCase { + func testVersionAndKeyGeneration() throws { + XCTAssertFalse(NdrFfi.version().isEmpty) + + 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 + ) + } + + func testCurrentInviteIsIdentityBoundKind30078() throws { + let keys = generateKeypair() + let manager = try makeManager(keys) + let inviteJSON = try manager.currentInviteEventJson() + let invite = try PairwiseInvite.fromEventJson(eventJson: inviteJSON) + + XCTAssertEqual(try extractNostrKind(json: inviteJSON), 30078) + XCTAssertEqual(invite.getPeerPubkeyHex(), keys.publicKeyHex) + XCTAssertEqual( + try PairwiseInvite.fromUrl( + url: invite.toUrl(root: "https://b") + ).getPeerPubkeyHex(), + keys.publicKeyHex + ) + } + + func testAuthenticatedHandshakeBecomesBidirectionallySendReady() throws { + let aliceKeys = generateKeypair() + let bobKeys = generateKeypair() + let alice = try makeManager(aliceKeys) + let bob = try makeManager(bobKeys) + + let artifacts = try establishSession( + inviter: alice, + inviterKeys: aliceKeys, + acceptor: bob, + acceptorKeys: bobKeys + ) + + 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 + ) + XCTAssertEqual( + try bob.sessionInfo(peerPubkeyHex: aliceKeys.publicKeyHex)? + .sendReady, + true + ) + } + + 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 expiration = UInt64(Date().timeIntervalSince1970) + 60 + let result = try bob.sendText( + peerPubkeyHex: aliceKeys.publicKeyHex, + text: "hello from bob", + expiresAtSeconds: expiration + ) + let publish = try requireAction( + in: bob, + kind: "publish", + outerEventID: result.outerEventId + ) + let outerJSON = try XCTUnwrap(publish.eventJson) + try alice.processEvent(eventJson: outerJSON) + + 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 + ) + } + + 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" + } + ) + } + + 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" + ) + } + + func testInvalidInviteAndAuthenticatedPeerMismatchAreRejected() throws { + let aliceKeys = generateKeypair() + let bobKeys = generateKeypair() + let unexpectedKeys = generateKeypair() + let alice = try makeManager(aliceKeys) + let bob = try makeManager(bobKeys) + + 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 + ) + } +} + +private struct HandshakeArtifacts { + let response: PairwiseAction + let responseJSON: String + let bootstrapJSON: String +} + +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) { + guard hexString.count.isMultiple(of: 2) else { return nil } + var data = Data(capacity: hexString.count / 2) + var index = hexString.startIndex + while index < hexString.endIndex { + let next = hexString.index(index, offsetBy: 2) + guard let byte = UInt8(hexString[index..&2 + exit 1 +fi + +if [[ ! -f "$BINDGEN_MANIFEST" ]]; then + echo "error: expected UniFFI bindgen manifest at $BINDGEN_MANIFEST" >&2 + exit 1 +fi + +ACTUAL_RUST="$(rustc --version | awk '{print $2}')" +if [[ "$ACTUAL_RUST" != "$EXPECTED_RUST" ]]; then + echo "error: rustc $ACTUAL_RUST is active; NdrFfi is pinned to $EXPECTED_RUST" >&2 + echo "install/select it with rustup before rebuilding" >&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")" +TARGET_DIR="${NDR_FFI_TARGET_DIR:-$REPOSITORY_DIR/.cache/ndr-ffi/apple/target}" +OUT_DIR="$WORK_ROOT/out" +BINDINGS_DIR="$OUT_DIR/bindings" +HEADERS_DIR="$OUT_DIR/headers" + +cleanup() { + rm -rf "$WORK_ROOT" +} +trap cleanup EXIT + +mkdir -p "$TARGET_DIR" "$BINDINGS_DIR" "$HEADERS_DIR" + +echo "==> Building the pairwise ndr_ffi artifacts from $CRATE_DIR" +echo " macOS minimum: $MACOS_MIN" +echo " iOS minimum: $IOS_MIN" + +cd "$CRATE_DIR" + +echo "==> Generating Swift bindings" +env \ + CARGO_TARGET_DIR="$TARGET_DIR" \ + cargo build --locked --manifest-path "$CRATE_MANIFEST" --lib + +env \ + CARGO_TARGET_DIR="$TARGET_DIR" \ + cargo run --locked --manifest-path "$BINDGEN_MANIFEST" --features bindgen --bin uniffi-bindgen -- \ + generate \ + --library "$TARGET_DIR/debug/libndr_ffi.dylib" \ + --language swift \ + --out-dir "$BINDINGS_DIR" + +cp "$BINDINGS_DIR/ndr_ffiFFI.h" "$HEADERS_DIR/ndr_ffiFFI.h" + +echo "==> Building dynamic macOS slices" +for target in aarch64-apple-darwin x86_64-apple-darwin; do + env \ + CARGO_TARGET_DIR="$TARGET_DIR" \ + MACOSX_DEPLOYMENT_TARGET="$MACOS_MIN" \ + CFLAGS_aarch64_apple_darwin="-mmacosx-version-min=$MACOS_MIN" \ + CXXFLAGS_aarch64_apple_darwin="-mmacosx-version-min=$MACOS_MIN" \ + CFLAGS_x86_64_apple_darwin="-mmacosx-version-min=$MACOS_MIN" \ + CXXFLAGS_x86_64_apple_darwin="-mmacosx-version-min=$MACOS_MIN" \ + RUSTFLAGS="-C panic=abort -C strip=debuginfo -C link-arg=-mmacosx-version-min=$MACOS_MIN -C link-arg=-Wl,-install_name,$INSTALL_NAME" \ + cargo build --locked --manifest-path "$CRATE_MANIFEST" --lib --release --target "$target" +done + +echo "==> Building dynamic iOS slices" +for target in aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios; do + env \ + CARGO_TARGET_DIR="$TARGET_DIR" \ + IPHONEOS_DEPLOYMENT_TARGET="$IOS_MIN" \ + RUSTFLAGS="-C panic=abort -C strip=debuginfo -C link-arg=-Wl,-install_name,$INSTALL_NAME" \ + cargo build --locked --manifest-path "$CRATE_MANIFEST" --lib --release --target "$target" +done + +MACOS_DYLIB="$OUT_DIR/libndr_ffi_macos.dylib" +SIM_DYLIB="$OUT_DIR/libndr_ffi_sim.dylib" +lipo -create \ + "$TARGET_DIR/aarch64-apple-darwin/release/libndr_ffi.dylib" \ + "$TARGET_DIR/x86_64-apple-darwin/release/libndr_ffi.dylib" \ + -output "$MACOS_DYLIB" +lipo -create \ + "$TARGET_DIR/aarch64-apple-ios-sim/release/libndr_ffi.dylib" \ + "$TARGET_DIR/x86_64-apple-ios/release/libndr_ffi.dylib" \ + -output "$SIM_DYLIB" + +make_framework() { + local binary="$1" + local destination="$2" + local supported_platform="$3" + local minimum_version="$4" + local framework="$destination/$FRAMEWORK_NAME.framework" + local contents="$framework" + local plist="$framework/Info.plist" + + if [[ "$supported_platform" == "MacOSX" ]]; then + contents="$framework/Versions/A" + plist="$contents/Resources/Info.plist" + mkdir -p "$contents/Headers" "$contents/Modules" "$contents/Resources" + else + mkdir -p "$contents/Headers" "$contents/Modules" + fi + + cp "$binary" "$contents/$FRAMEWORK_NAME" + chmod +x "$contents/$FRAMEWORK_NAME" + cp "$HEADERS_DIR/ndr_ffiFFI.h" "$contents/Headers/ndr_ffiFFI.h" + + cat > "$contents/Modules/module.modulemap" < "$plist" < + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $FRAMEWORK_NAME + CFBundleIdentifier + chat.bitchat.ndrffi + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $FRAMEWORK_NAME + CFBundlePackageType + FMWK + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + MinimumOSVersion + $minimum_version + CFBundleSupportedPlatforms + + $supported_platform + + + +EOF + + if [[ "$supported_platform" == "MacOSX" ]]; then + ln -s A "$framework/Versions/Current" + ln -s "Versions/Current/$FRAMEWORK_NAME" "$framework/$FRAMEWORK_NAME" + ln -s Versions/Current/Headers "$framework/Headers" + ln -s Versions/Current/Modules "$framework/Modules" + ln -s Versions/Current/Resources "$framework/Resources" + fi +} + +DEVICE_ROOT="$OUT_DIR/iphoneos" +SIM_ROOT="$OUT_DIR/iphonesimulator" +MACOS_ROOT="$OUT_DIR/macos" +make_framework \ + "$TARGET_DIR/aarch64-apple-ios/release/libndr_ffi.dylib" \ + "$DEVICE_ROOT" \ + "iPhoneOS" \ + "$IOS_MIN" +make_framework "$SIM_DYLIB" "$SIM_ROOT" "iPhoneSimulator" "$IOS_MIN" +make_framework "$MACOS_DYLIB" "$MACOS_ROOT" "MacOSX" "$MACOS_MIN" + +echo "==> Assembling dynamic XCFramework" +xcodebuild -create-xcframework \ + -framework "$DEVICE_ROOT/$FRAMEWORK_NAME.framework" \ + -framework "$SIM_ROOT/$FRAMEWORK_NAME.framework" \ + -framework "$MACOS_ROOT/$FRAMEWORK_NAME.framework" \ + -output "$OUT_DIR/NdrFfi.xcframework" + +echo "==> Updating generated package outputs" +LC_ALL=C sed -E 's/[[:blank:]]+$//' \ + "$BINDINGS_DIR/ndr_ffi.swift" \ + > "$BINDINGS_DIR/ndr_ffi.swift.normalized" +mv "$BINDINGS_DIR/ndr_ffi.swift.normalized" "$BINDINGS_DIR/ndr_ffi.swift" +cp "$BINDINGS_DIR/ndr_ffi.swift" "$PACKAGE_DIR/Sources/NdrFfi/NdrFfi.swift" +mkdir -p "$PACKAGE_DIR/Frameworks" +rm -rf "$PACKAGE_DIR/Frameworks/NdrFfi.xcframework" +cp -R "$OUT_DIR/NdrFfi.xcframework" "$PACKAGE_DIR/Frameworks/NdrFfi.xcframework" + +echo "==> Verifying dynamic framework install names" +for binary in "$PACKAGE_DIR"/Frameworks/NdrFfi.xcframework/*/"$FRAMEWORK_NAME.framework/$FRAMEWORK_NAME"; do + otool -D "$binary" | grep -F "$INSTALL_NAME" >/dev/null +done + +echo "==> Done" +echo " Updated $PACKAGE_DIR/Sources/NdrFfi/NdrFfi.swift" +echo " Updated $PACKAGE_DIR/Frameworks/NdrFfi.xcframework" 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