From fa45ee3aeae1f7b03425d5313ccc2720ea4d90bb Mon Sep 17 00:00:00 2001 From: islam <2553451+qalandarov@users.noreply.github.com> Date: Sat, 4 Apr 2026 10:25:59 +0100 Subject: [PATCH] Extract Nostr into a dedicated module --- .github/workflows/swift-tests.yml | 2 + Package.swift | 5 +- bitchat.xcodeproj/project.pbxproj | 20 + bitchat/BitchatApp.swift | 5 +- bitchat/Nostr/NostrLiveFactories.swift | 118 +++++ bitchat/Services/BLE/BLEService.swift | 1 + bitchat/Services/CommandProcessor.swift | 1 + bitchat/Services/GeohashPresenceService.swift | 3 +- bitchat/Services/KeychainManager.swift | 3 +- bitchat/Services/LocationNotesManager.swift | 3 +- .../Services/NetworkActivationService.swift | 1 + bitchat/Services/NostrTransport.swift | 1 + bitchat/Services/UnifiedPeerService.swift | 1 + bitchat/ViewModels/ChatViewModel.swift | 3 +- .../Extensions/ChatViewModel+Nostr.swift | 1 + .../ChatViewModel+PrivateChat.swift | 1 + .../Components/CommandSuggestionsView.swift | 3 +- .../Views/Components/TextMessageView.swift | 3 +- bitchat/Views/VerificationViews.swift | 1 + bitchatTests/BLEServiceCoreTests.swift | 1 + .../ChatViewModelDeliveryStatusTests.swift | 1 + .../ChatViewModelExtensionsTests.swift | 1 + .../ChatViewModelRefactoringTests.swift | 1 + bitchatTests/ChatViewModelTests.swift | 1 + bitchatTests/ChatViewModelTorTests.swift | 1 + bitchatTests/CommandProcessorTests.swift | 1 + .../Fragmentation/FragmentationTests.swift | 1 + bitchatTests/GeohashPresenceTests.swift | 1 + bitchatTests/LocationChannelsTests.swift | 1 + bitchatTests/LocationNotesManagerTests.swift | 1 + .../Nostr/GeoRelayDirectoryTests.swift | 2 + bitchatTests/NostrProtocolTests.swift | 1 + .../GeohashPresenceServiceTests.swift | 1 + .../Services/NostrRelayManagerTests.swift | 7 +- .../Services/NostrTransportTests.swift | 1 + .../Services/PrivateChatManagerTests.swift | 1 + .../Services/UnifiedPeerServiceTests.swift | 1 + bitchatTests/ViewSmokeTests.swift | 1 + localPackages/Nostr/Package.resolved | 15 + localPackages/Nostr/Package.swift | 37 ++ .../Nostr/Sources}/Nostr/Bech32.swift | 57 ++- .../Sources}/Nostr/GeoRelayDirectory.swift | 260 +++++----- .../Nostr/Sources}/Nostr/NostrIdentity.swift | 45 +- .../Sources}/Nostr/NostrIdentityBridge.swift | 85 ++-- .../Nostr/Sources}/Nostr/NostrProtocol.swift | 265 +++------- .../Sources}/Nostr/NostrRelayManager.swift | 474 +++++++----------- .../Nostr/XChaCha20Poly1305Compat.swift | 9 - localPackages/Nostr/Tests/Bech32Tests.swift | 41 ++ .../Tests}/XChaCha20Poly1305CompatTests.swift | 2 +- 49 files changed, 778 insertions(+), 714 deletions(-) create mode 100644 bitchat/Nostr/NostrLiveFactories.swift create mode 100644 localPackages/Nostr/Package.resolved create mode 100644 localPackages/Nostr/Package.swift rename {bitchat => localPackages/Nostr/Sources}/Nostr/Bech32.swift (87%) rename {bitchat => localPackages/Nostr/Sources}/Nostr/GeoRelayDirectory.swift (66%) rename {bitchat => localPackages/Nostr/Sources}/Nostr/NostrIdentity.swift (63%) rename {bitchat => localPackages/Nostr/Sources}/Nostr/NostrIdentityBridge.swift (70%) rename {bitchat => localPackages/Nostr/Sources}/Nostr/NostrProtocol.swift (71%) rename {bitchat => localPackages/Nostr/Sources}/Nostr/NostrRelayManager.swift (73%) rename {bitchat => localPackages/Nostr/Sources}/Nostr/XChaCha20Poly1305Compat.swift (93%) create mode 100644 localPackages/Nostr/Tests/Bech32Tests.swift rename {bitchatTests => localPackages/Nostr/Tests}/XChaCha20Poly1305CompatTests.swift (99%) diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml index 54c121e5..31856006 100644 --- a/.github/workflows/swift-tests.yml +++ b/.github/workflows/swift-tests.yml @@ -23,6 +23,8 @@ jobs: path: localPackages/BitFoundation - name: Noise path: localPackages/Noise + - name: Nostr + path: localPackages/Nostr steps: - name: Checkout code diff --git a/Package.swift b/Package.swift index 9eb6a935..c679af91 100644 --- a/Package.swift +++ b/Package.swift @@ -20,6 +20,7 @@ let package = Package( .package(path: "localPackages/Noise"), .package(path: "localPackages/BitFoundation"), .package(path: "localPackages/BitLogger"), + .package(path: "localPackages/Nostr"), .package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1") ], targets: [ @@ -30,6 +31,7 @@ let package = Package( .product(name: "BitFoundation", package: "BitFoundation"), .product(name: "BitLogger", package: "BitLogger"), .product(name: "Noise", package: "Noise"), + .product(name: "Nostr", package: "Nostr"), .product(name: "Tor", package: "Arti") ], path: "bitchat", @@ -50,7 +52,8 @@ let package = Package( name: "bitchatTests", dependencies: [ "bitchat", - .product(name: "BitFoundation", package: "BitFoundation") + .product(name: "BitFoundation", package: "BitFoundation"), + .product(name: "Nostr", package: "Nostr") ], path: "bitchatTests", exclude: [ diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 6f04ce99..b2451ace 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -12,6 +12,8 @@ 885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; }; A63163B62F80CB2500B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B52F80CB2500B8B128 /* Noise */; }; A63163B82F80CB2D00B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B72F80CB2D00B8B128 /* Noise */; }; + A63180832F8103AB00B8B128 /* Nostr in Frameworks */ = {isa = PBXBuildFile; productRef = A63180822F8103AB00B8B128 /* Nostr */; }; + A63180852F8103B600B8B128 /* Nostr in Frameworks */ = {isa = PBXBuildFile; productRef = A63180842F8103B600B8B128 /* Nostr */; }; A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9472F80953E001CF9B9 /* BitFoundation */; }; A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9492F809550001CF9B9 /* BitFoundation */; }; A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; }; @@ -160,6 +162,7 @@ A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */, A63163B82F80CB2D00B8B128 /* Noise in Frameworks */, 3EE336D150427F736F32B56C /* P256K in Frameworks */, + A63180852F8103B600B8B128 /* Nostr in Frameworks */, A6E3EA812E7706A80032EA8A /* Tor in Frameworks */, A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */, ); @@ -170,6 +173,7 @@ A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */, A63163B62F80CB2500B8B128 /* Noise in Frameworks */, 885BBED78092484A5B069461 /* P256K in Frameworks */, + A63180832F8103AB00B8B128 /* Nostr in Frameworks */, A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */, A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */, ); @@ -233,6 +237,7 @@ A6E3EA802E7706A80032EA8A /* Tor */, A6BCF9492F809550001CF9B9 /* BitFoundation */, A63163B72F80CB2D00B8B128 /* Noise */, + A63180842F8103B600B8B128 /* Nostr */, ); productName = bitchat_macOS; productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */; @@ -315,6 +320,7 @@ A6E3EA7E2E7706720032EA8A /* Tor */, A6BCF9472F80953E001CF9B9 /* BitFoundation */, A63163B52F80CB2500B8B128 /* Noise */, + A63180822F8103AB00B8B128 /* Nostr */, ); productName = bitchat_iOS; productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */; @@ -358,6 +364,7 @@ A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */, A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */, A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */, + A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */, ); preferredProjectObjectVersion = 90; projectDirPath = ""; @@ -927,6 +934,10 @@ isa = XCLocalSwiftPackageReference; relativePath = localPackages/Noise; }; + A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = localPackages/Nostr; + }; A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */ = { isa = XCLocalSwiftPackageReference; relativePath = localPackages/BitFoundation; @@ -967,6 +978,15 @@ package = A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */; productName = Noise; }; + A63180822F8103AB00B8B128 /* Nostr */ = { + isa = XCSwiftPackageProductDependency; + productName = Nostr; + }; + A63180842F8103B600B8B128 /* Nostr */ = { + isa = XCSwiftPackageProductDependency; + package = A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */; + productName = Nostr; + }; A6BCF9472F80953E001CF9B9 /* BitFoundation */ = { isa = XCSwiftPackageProductDependency; productName = BitFoundation; diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index 128ea1b7..8cf15c67 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -6,6 +6,7 @@ // For more information, see // +import Nostr import Tor import SwiftUI import BitFoundation @@ -27,9 +28,11 @@ struct BitchatApp: App { @NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate #endif - private let idBridge = NostrIdentityBridge() + private let idBridge = NostrIdentityBridge(keychain: KeychainManager()) init() { + GeoRelayDirectory.setupShared(dependencies: .live()) + NostrRelayManager.setupShared(dependencies: .live()) let keychain = KeychainManager() let idBridge = self.idBridge _chatViewModel = StateObject( diff --git a/bitchat/Nostr/NostrLiveFactories.swift b/bitchat/Nostr/NostrLiveFactories.swift new file mode 100644 index 00000000..2cd8a692 --- /dev/null +++ b/bitchat/Nostr/NostrLiveFactories.swift @@ -0,0 +1,118 @@ +import Nostr +import Combine +import Foundation +import Tor +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +// MARK: - GeoRelayDirectory Live Dependencies + +extension GeoRelayDirectoryDependencies { + @MainActor + static func live() -> Self { + #if os(iOS) + let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification + #elseif os(macOS) + let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification + #else + let activeNotificationName: Notification.Name? = nil + #endif + + return Self( + userDefaults: .standard, + notificationCenter: .default, + now: Date.init, + remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!, + fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds, + refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds, + retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds, + retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds, + awaitTorReady: { await TorManager.shared.awaitReady() }, + makeFetchData: { + let session = TorURLSession.shared.session + return { request in + let (data, _) = try await session.data(for: request) + return data + } + }, + readData: { try? Data(contentsOf: $0) }, + writeData: { data, url in + try data.write(to: url, options: .atomic) + }, + cacheURL: { + do { + let base = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let dir = base.appendingPathComponent("bitchat", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("georelays_cache.csv") + } catch { + return nil + } + }, + bundledCSVURLs: { + [ + Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"), + Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"), + Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays") + ].compactMap { $0 } + }, + currentDirectoryPath: { FileManager.default.currentDirectoryPath }, + retrySleep: { delay in + let nanoseconds = UInt64(delay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + }, + torReadyNotificationName: .TorDidBecomeReady, + activeNotificationName: activeNotificationName, + autoStart: true + ) + } +} + +// MARK: - NostrRelayManager Live Dependencies + +extension NostrRelayManagerDependencies { + @MainActor + static func live() -> Self { + Self( + activationAllowed: { NetworkActivationService.shared.activationAllowed }, + userTorEnabled: { NetworkActivationService.shared.userTorEnabled }, + hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty }, + hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized }, + mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(), + locationPermissionPublisher: LocationChannelManager.shared.$permissionState + .map { state -> LocationPermissionState in + switch state { + case .notDetermined:.notDetermined + case .authorized: .authorized + case .denied: .denied + case .restricted: .denied + } + } + .eraseToAnyPublisher(), + torEnforced: { TorManager.shared.torEnforced }, + torIsReady: { TorManager.shared.isReady }, + torIsForeground: { TorManager.shared.isForeground() }, + awaitTorReady: { completion in + Task.detached { + let ready = await TorManager.shared.awaitReady() + await MainActor.run { + completion(ready) + } + } + }, + makeSession: { NostrRelayManager.makeURLSession(TorURLSession.shared.session) }, + scheduleAfter: { delay, action in + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) + }, + now: Date.init + ) + } +} diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 055c254f..b6b6dd7f 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -1,5 +1,6 @@ import BitLogger import BitFoundation +import Nostr import Foundation import CoreBluetooth import Combine diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index bba73aac..efd78c05 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -6,6 +6,7 @@ // This is free and unencumbered software released into the public domain. // +import Nostr import Foundation import BitFoundation diff --git a/bitchat/Services/GeohashPresenceService.swift b/bitchat/Services/GeohashPresenceService.swift index e311c5c4..eb3aba49 100644 --- a/bitchat/Services/GeohashPresenceService.swift +++ b/bitchat/Services/GeohashPresenceService.swift @@ -8,6 +8,7 @@ // This is free and unencumbered software released into the public domain. // +import Nostr import Foundation import Combine import BitLogger @@ -74,7 +75,7 @@ final class GeohashPresenceService: ObservableObject { ] private init() { - let idBridge = NostrIdentityBridge() + let idBridge = NostrIdentityBridge(keychain: KeychainManager()) self.availableChannelsProvider = { LocationStateManager.shared.availableChannels } self.locationChanges = LocationStateManager.shared.$availableChannels.eraseToAnyPublisher() self.torReadyPublisher = NotificationCenter.default.publisher(for: .TorDidBecomeReady) diff --git a/bitchat/Services/KeychainManager.swift b/bitchat/Services/KeychainManager.swift index 2ed3cf25..7cc7ec01 100644 --- a/bitchat/Services/KeychainManager.swift +++ b/bitchat/Services/KeychainManager.swift @@ -7,6 +7,7 @@ // import BitLogger +import protocol Nostr.NostrKeychainStoring import protocol Noise.SecureMemoryCleaner import Foundation import Security @@ -52,7 +53,7 @@ enum KeychainSaveResult { } } -protocol KeychainManagerProtocol: SecureMemoryCleaner { +protocol KeychainManagerProtocol: SecureMemoryCleaner, NostrKeychainStoring { func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool func getIdentityKey(forKey key: String) -> Data? func deleteIdentityKey(forKey key: String) -> Bool diff --git a/bitchat/Services/LocationNotesManager.swift b/bitchat/Services/LocationNotesManager.swift index 22c5d0e2..5ffca126 100644 --- a/bitchat/Services/LocationNotesManager.swift +++ b/bitchat/Services/LocationNotesManager.swift @@ -1,4 +1,5 @@ import BitLogger +import Nostr import Foundation /// Dependencies for location notes, allowing tests to stub relay/identity behavior. @@ -15,7 +16,7 @@ struct LocationNotesDependencies { var deriveIdentity: (_ geohash: String) throws -> NostrIdentity var now: () -> Date - private static let idBridge = NostrIdentityBridge() + private static let idBridge = NostrIdentityBridge(keychain: KeychainManager()) static let live = LocationNotesDependencies( relayLookup: { geohash, count in diff --git a/bitchat/Services/NetworkActivationService.swift b/bitchat/Services/NetworkActivationService.swift index a9795024..cf6bd271 100644 --- a/bitchat/Services/NetworkActivationService.swift +++ b/bitchat/Services/NetworkActivationService.swift @@ -1,3 +1,4 @@ +import Nostr import Foundation import BitLogger import Combine diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index b822a48b..89df4b83 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -1,5 +1,6 @@ import BitLogger import BitFoundation +import Nostr import Foundation import Combine diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index 3d180c68..773a742e 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -8,6 +8,7 @@ import BitLogger import BitFoundation +import Nostr import Foundation import Combine import SwiftUI diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index a6f5b23c..4a3f56fd 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -79,6 +79,7 @@ import BitLogger import BitFoundation +import Nostr import Foundation import SwiftUI import Combine @@ -1967,7 +1968,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds // Reinitialize Nostr relay manager with new identity - nostrRelayManager = NostrRelayManager() + nostrRelayManager = NostrRelayManager(dependencies: .live()) setupNostrMessageHandling() nostrRelayManager?.connect() } diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift index b36aa49c..5b6daffe 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift @@ -5,6 +5,7 @@ // Geohash and Nostr logic for ChatViewModel // +import Nostr import Foundation import Combine import BitLogger diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift index 146c3959..2f376354 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift @@ -5,6 +5,7 @@ // Private chat and media transfer logic for ChatViewModel // +import Nostr import Foundation import Combine import BitLogger diff --git a/bitchat/Views/Components/CommandSuggestionsView.swift b/bitchat/Views/Components/CommandSuggestionsView.swift index 4c9d0213..09e2b3a5 100644 --- a/bitchat/Views/Components/CommandSuggestionsView.swift +++ b/bitchat/Views/Components/CommandSuggestionsView.swift @@ -5,6 +5,7 @@ // Created by Islam on 29/10/2025. // +import Nostr import SwiftUI struct CommandSuggestionsView: View { @@ -76,7 +77,7 @@ struct CommandSuggestionsView: View { let keychain = KeychainManager() let viewModel = ChatViewModel( keychain: keychain, - idBridge: NostrIdentityBridge(), + idBridge: NostrIdentityBridge(keychain: keychain), identityManager: SecureIdentityStateManager(keychain) ) diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index 194dc002..5542f48a 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -6,6 +6,7 @@ // For more information, see // +import Nostr import SwiftUI struct TextMessageView: View { @@ -89,7 +90,7 @@ struct TextMessageView: View { .environmentObject( ChatViewModel( keychain: keychain, - idBridge: NostrIdentityBridge(), + idBridge: NostrIdentityBridge(keychain: keychain), identityManager: SecureIdentityStateManager(keychain) ) ) diff --git a/bitchat/Views/VerificationViews.swift b/bitchat/Views/VerificationViews.swift index 180e2b46..ec5c2772 100644 --- a/bitchat/Views/VerificationViews.swift +++ b/bitchat/Views/VerificationViews.swift @@ -1,3 +1,4 @@ +import Nostr import SwiftUI import CoreImage import CoreImage.CIFilterBuiltins diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index 8849f089..2e10b6bb 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -6,6 +6,7 @@ // import Testing +import Nostr import Foundation import CoreBluetooth import BitFoundation diff --git a/bitchatTests/ChatViewModelDeliveryStatusTests.swift b/bitchatTests/ChatViewModelDeliveryStatusTests.swift index 057a97c3..c3e46b4a 100644 --- a/bitchatTests/ChatViewModelDeliveryStatusTests.swift +++ b/bitchatTests/ChatViewModelDeliveryStatusTests.swift @@ -6,6 +6,7 @@ // import Testing +import Nostr import Foundation import BitFoundation @testable import bitchat diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index 13934872..10a076bc 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -6,6 +6,7 @@ // import Testing +import Nostr import Foundation import Combine #if os(iOS) diff --git a/bitchatTests/ChatViewModelRefactoringTests.swift b/bitchatTests/ChatViewModelRefactoringTests.swift index fcb3f13f..36b11429 100644 --- a/bitchatTests/ChatViewModelRefactoringTests.swift +++ b/bitchatTests/ChatViewModelRefactoringTests.swift @@ -7,6 +7,7 @@ // import Testing +import Nostr import Foundation import BitFoundation @testable import bitchat diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 1be3b578..26c307f9 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -7,6 +7,7 @@ // import Testing +import Nostr import Foundation import BitFoundation @testable import bitchat diff --git a/bitchatTests/ChatViewModelTorTests.swift b/bitchatTests/ChatViewModelTorTests.swift index d4697dd8..077cd1b1 100644 --- a/bitchatTests/ChatViewModelTorTests.swift +++ b/bitchatTests/ChatViewModelTorTests.swift @@ -6,6 +6,7 @@ // import Testing +import Nostr import Foundation @testable import bitchat diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index 12e57d27..2c6eb74d 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -1,4 +1,5 @@ import Foundation +import Nostr import Testing import BitFoundation @testable import bitchat diff --git a/bitchatTests/Fragmentation/FragmentationTests.swift b/bitchatTests/Fragmentation/FragmentationTests.swift index c645b1f9..56487a40 100644 --- a/bitchatTests/Fragmentation/FragmentationTests.swift +++ b/bitchatTests/Fragmentation/FragmentationTests.swift @@ -7,6 +7,7 @@ // import Testing +import Nostr import Foundation import CoreBluetooth import BitFoundation diff --git a/bitchatTests/GeohashPresenceTests.swift b/bitchatTests/GeohashPresenceTests.swift index dcb5e3bc..3d5782a3 100644 --- a/bitchatTests/GeohashPresenceTests.swift +++ b/bitchatTests/GeohashPresenceTests.swift @@ -7,6 +7,7 @@ // import Testing +import Nostr import Foundation import Combine @testable import bitchat diff --git a/bitchatTests/LocationChannelsTests.swift b/bitchatTests/LocationChannelsTests.swift index 7f5651a9..c226edb9 100644 --- a/bitchatTests/LocationChannelsTests.swift +++ b/bitchatTests/LocationChannelsTests.swift @@ -1,4 +1,5 @@ import Testing +import Nostr import Foundation @testable import bitchat diff --git a/bitchatTests/LocationNotesManagerTests.swift b/bitchatTests/LocationNotesManagerTests.swift index f8efb50a..8abe18de 100644 --- a/bitchatTests/LocationNotesManagerTests.swift +++ b/bitchatTests/LocationNotesManagerTests.swift @@ -1,4 +1,5 @@ import Testing +import Nostr import Foundation @testable import bitchat diff --git a/bitchatTests/Nostr/GeoRelayDirectoryTests.swift b/bitchatTests/Nostr/GeoRelayDirectoryTests.swift index b516f708..e3e72e89 100644 --- a/bitchatTests/Nostr/GeoRelayDirectoryTests.swift +++ b/bitchatTests/Nostr/GeoRelayDirectoryTests.swift @@ -1,4 +1,5 @@ import Foundation +import Nostr import Tor import XCTest @testable import bitchat @@ -303,6 +304,7 @@ final class GeoRelayDirectoryTests: XCTestCase { retrySleep: { delay in await retryRecorder.record(delay) }, + torReadyNotificationName: .TorDidBecomeReady, activeNotificationName: activeNotificationName, autoStart: autoStart ) diff --git a/bitchatTests/NostrProtocolTests.swift b/bitchatTests/NostrProtocolTests.swift index 4ea8020f..e675df4f 100644 --- a/bitchatTests/NostrProtocolTests.swift +++ b/bitchatTests/NostrProtocolTests.swift @@ -5,6 +5,7 @@ // Tests for NIP-17 gift-wrapped private messages // +import Nostr import Testing import CryptoKit import Foundation diff --git a/bitchatTests/Services/GeohashPresenceServiceTests.swift b/bitchatTests/Services/GeohashPresenceServiceTests.swift index f6be1f25..2629a2e7 100644 --- a/bitchatTests/Services/GeohashPresenceServiceTests.swift +++ b/bitchatTests/Services/GeohashPresenceServiceTests.swift @@ -1,4 +1,5 @@ import Combine +import Nostr import XCTest @testable import bitchat diff --git a/bitchatTests/Services/NostrRelayManagerTests.swift b/bitchatTests/Services/NostrRelayManagerTests.swift index 5d3ce8df..6db62421 100644 --- a/bitchatTests/Services/NostrRelayManagerTests.swift +++ b/bitchatTests/Services/NostrRelayManagerTests.swift @@ -1,3 +1,4 @@ +import Nostr import Combine import XCTest @testable import bitchat @@ -697,7 +698,7 @@ final class NostrRelayManagerTests: XCTestCase { } private func makeContext( - permission: LocationChannelManager.PermissionState, + permission: LocationPermissionState, favorites: Set = [], activationAllowed: Bool = true, userTorEnabled: Bool = false, @@ -705,7 +706,7 @@ final class NostrRelayManagerTests: XCTestCase { torIsReady: Bool = true, torIsForeground: Bool = true ) -> RelayManagerTestContext { - let permissionSubject = CurrentValueSubject(permission) + let permissionSubject = CurrentValueSubject(permission) let favoritesSubject = CurrentValueSubject, Never>(favorites) let sessionFactory = MockRelaySessionFactory() let scheduler = MockRelayScheduler() @@ -782,7 +783,7 @@ final class NostrRelayManagerTests: XCTestCase { @MainActor private struct RelayManagerTestContext { let manager: NostrRelayManager - let permissionSubject: CurrentValueSubject + let permissionSubject: CurrentValueSubject let favoritesSubject: CurrentValueSubject, Never> let sessionFactory: MockRelaySessionFactory let scheduler: MockRelayScheduler diff --git a/bitchatTests/Services/NostrTransportTests.swift b/bitchatTests/Services/NostrTransportTests.swift index cb10d7fc..6e74d238 100644 --- a/bitchatTests/Services/NostrTransportTests.swift +++ b/bitchatTests/Services/NostrTransportTests.swift @@ -7,6 +7,7 @@ // import Foundation +import Nostr import Testing import BitFoundation @testable import bitchat diff --git a/bitchatTests/Services/PrivateChatManagerTests.swift b/bitchatTests/Services/PrivateChatManagerTests.swift index a75dac5f..2c81a547 100644 --- a/bitchatTests/Services/PrivateChatManagerTests.swift +++ b/bitchatTests/Services/PrivateChatManagerTests.swift @@ -6,6 +6,7 @@ // import Testing +import Nostr import Foundation import BitFoundation @testable import bitchat diff --git a/bitchatTests/Services/UnifiedPeerServiceTests.swift b/bitchatTests/Services/UnifiedPeerServiceTests.swift index c910af9b..26aa52b7 100644 --- a/bitchatTests/Services/UnifiedPeerServiceTests.swift +++ b/bitchatTests/Services/UnifiedPeerServiceTests.swift @@ -6,6 +6,7 @@ // import Testing +import Nostr import Foundation import BitFoundation @testable import bitchat diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index cdf9a8df..04b6ce53 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -1,4 +1,5 @@ import Testing +import Nostr import Foundation import SwiftUI import CoreGraphics diff --git a/localPackages/Nostr/Package.resolved b/localPackages/Nostr/Package.resolved new file mode 100644 index 00000000..e1ae5e3e --- /dev/null +++ b/localPackages/Nostr/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "6a29e071e2546e255f894c733cad2a53ab20e153bf12e813d16830b195a5970d", + "pins" : [ + { + "identity" : "swift-secp256k1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/21-DOT-DEV/swift-secp256k1", + "state" : { + "revision" : "8c62aba8a3011c9bcea232e5ee007fb0b34a15e2", + "version" : "0.21.1" + } + } + ], + "version" : 3 +} diff --git a/localPackages/Nostr/Package.swift b/localPackages/Nostr/Package.swift new file mode 100644 index 00000000..554a0d31 --- /dev/null +++ b/localPackages/Nostr/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "Nostr", + platforms: [ + .iOS(.v16), + .macOS(.v13) + ], + products: [ + .library( + name: "Nostr", + targets: ["Nostr"] + ), + ], + dependencies: [ + .package(path: "../BitLogger"), + .package(path: "../BitFoundation"), + .package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1"), + ], + targets: [ + .target( + name: "Nostr", + dependencies: [ + .product(name: "BitLogger", package: "BitLogger"), + .product(name: "BitFoundation", package: "BitFoundation"), + .product(name: "P256K", package: "swift-secp256k1"), + ], + path: "Sources" + ), + .testTarget( + name: "NostrTests", + dependencies: ["Nostr"] + ), + ] +) diff --git a/bitchat/Nostr/Bech32.swift b/localPackages/Nostr/Sources/Nostr/Bech32.swift similarity index 87% rename from bitchat/Nostr/Bech32.swift rename to localPackages/Nostr/Sources/Nostr/Bech32.swift index dc798560..13d7a940 100644 --- a/bitchat/Nostr/Bech32.swift +++ b/localPackages/Nostr/Sources/Nostr/Bech32.swift @@ -1,38 +1,37 @@ import Foundation /// Bech32 encoding for Nostr (minimal implementation) -enum Bech32 { +public enum Bech32 { private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] - - static func encode(hrp: String, data: Data) throws -> String { + + public static func encode(hrp: String, data: Data) throws -> String { let values = convertBits(from: 8, to: 5, pad: true, data: Array(data)) let checksum = createChecksum(hrp: hrp, values: values) let combined = values + checksum - - return hrp + "1" + combined.map { + + return hrp + "1" + combined.map { let index = charset.index(charset.startIndex, offsetBy: Int($0)) return String(charset[index]) }.joined() } - - static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) { - // Find the last occurrence of '1' + + public static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) { guard let separatorIndex = bech32String.lastIndex(of: "1") else { throw Bech32Error.invalidFormat } - + let hrp = String(bech32String[..= 6 else { throw Bech32Error.invalidChecksum } - + let payloadValues = Array(values.dropLast(6)) let checksum = Array(values.suffix(6)) let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues) - + guard checksum == expectedChecksum else { throw Bech32Error.invalidChecksum } - + // Convert back to bytes let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues) return (hrp: hrp, data: Data(bytes)) } - + enum Bech32Error: Error { case invalidFormat case invalidCharacter case invalidChecksum } - + private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] { var acc = 0 var bits = 0 var result = [UInt8]() let maxv = (1 << to) - 1 - + for value in data { acc = (acc << from) | Int(value) bits += from - + while bits >= to { bits -= to result.append(UInt8((acc >> bits) & maxv)) } } - + if pad && bits > 0 { result.append(UInt8((acc << (to - bits)) & maxv)) } - + return result } - + private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] { let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0] let polymod = polymod(checksumValues) ^ 1 var checksum = [UInt8]() - + for i in 0..<6 { checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31)) } - + return checksum } - + private static func hrpExpand(_ hrp: String) -> [UInt8] { var result = [UInt8]() for c in hrp { guard let asciiValue = c.asciiValue else { - return [] // Return empty array for invalid input + return [] } result.append(UInt8(asciiValue >> 5)) } result.append(0) for c in hrp { guard let asciiValue = c.asciiValue else { - return [] // Return empty array for invalid input + return [] } result.append(UInt8(asciiValue & 31)) } return result } - + private static func polymod(_ values: [UInt8]) -> Int { var chk = 1 for value in values { diff --git a/bitchat/Nostr/GeoRelayDirectory.swift b/localPackages/Nostr/Sources/Nostr/GeoRelayDirectory.swift similarity index 66% rename from bitchat/Nostr/GeoRelayDirectory.swift rename to localPackages/Nostr/Sources/Nostr/GeoRelayDirectory.swift index 653fc285..3b5905ed 100644 --- a/bitchat/Nostr/GeoRelayDirectory.swift +++ b/localPackages/Nostr/Sources/Nostr/GeoRelayDirectory.swift @@ -1,101 +1,72 @@ import BitLogger import Foundation -import Tor -#if os(iOS) -import UIKit -#elseif os(macOS) -import AppKit -#endif -/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing. -struct GeoRelayDirectoryDependencies { - var userDefaults: UserDefaults - var notificationCenter: NotificationCenter - var now: () -> Date - var remoteURL: URL - var fetchInterval: TimeInterval - var refreshCheckInterval: TimeInterval - var retryInitialSeconds: TimeInterval - var retryMaxSeconds: TimeInterval - var awaitTorReady: @Sendable () async -> Bool - var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data) - var readData: (URL) -> Data? - var writeData: (Data, URL) throws -> Void - var cacheURL: () -> URL? - var bundledCSVURLs: () -> [URL] - var currentDirectoryPath: () -> String? - var retrySleep: (TimeInterval) async -> Void - var activeNotificationName: Notification.Name? - var autoStart: Bool -} +public struct GeoRelayDirectoryDependencies { + public var userDefaults: UserDefaults + public var notificationCenter: NotificationCenter + public var now: () -> Date + public var remoteURL: URL + public var fetchInterval: TimeInterval + public var refreshCheckInterval: TimeInterval + public var retryInitialSeconds: TimeInterval + public var retryMaxSeconds: TimeInterval + public var awaitTorReady: @Sendable () async -> Bool + public var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data) + public var readData: (URL) -> Data? + public var writeData: (Data, URL) throws -> Void + public var cacheURL: () -> URL? + public var bundledCSVURLs: () -> [URL] + public var currentDirectoryPath: () -> String? + public var retrySleep: (TimeInterval) async -> Void + public var torReadyNotificationName: Notification.Name? + public var activeNotificationName: Notification.Name? + public var autoStart: Bool -private extension GeoRelayDirectoryDependencies { - @MainActor - static func live() -> Self { -#if os(iOS) - let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification -#elseif os(macOS) - let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification -#else - let activeNotificationName: Notification.Name? = nil -#endif - - return Self( - userDefaults: .standard, - notificationCenter: .default, - now: Date.init, - remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!, - fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds, - refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds, - retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds, - retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds, - awaitTorReady: { await TorManager.shared.awaitReady() }, - makeFetchData: { - let session = TorURLSession.shared.session - return { request in - let (data, _) = try await session.data(for: request) - return data - } - }, - readData: { try? Data(contentsOf: $0) }, - writeData: { data, url in - try data.write(to: url, options: .atomic) - }, - cacheURL: { - do { - let base = try FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - let dir = base.appendingPathComponent("bitchat", isDirectory: true) - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - return dir.appendingPathComponent("georelays_cache.csv") - } catch { - return nil - } - }, - bundledCSVURLs: { - [ - Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"), - Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"), - Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays") - ].compactMap { $0 } - }, - currentDirectoryPath: { FileManager.default.currentDirectoryPath }, - retrySleep: { delay in - let nanoseconds = UInt64(delay * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanoseconds) - }, - activeNotificationName: activeNotificationName, - autoStart: true - ) + public init( + userDefaults: UserDefaults, + notificationCenter: NotificationCenter, + now: @escaping () -> Date, + remoteURL: URL, + fetchInterval: TimeInterval, + refreshCheckInterval: TimeInterval, + retryInitialSeconds: TimeInterval, + retryMaxSeconds: TimeInterval, + awaitTorReady: @Sendable @escaping () async -> Bool, + makeFetchData: @MainActor @Sendable @escaping () -> (@Sendable (URLRequest) async throws -> Data), + readData: @escaping (URL) -> Data?, + writeData: @escaping (Data, URL) throws -> Void, + cacheURL: @escaping () -> URL?, + bundledCSVURLs: @escaping () -> [URL], + currentDirectoryPath: @escaping () -> String?, + retrySleep: @escaping (TimeInterval) async -> Void, + torReadyNotificationName: Notification.Name?, + activeNotificationName: Notification.Name?, + autoStart: Bool + ) { + self.userDefaults = userDefaults + self.notificationCenter = notificationCenter + self.now = now + self.remoteURL = remoteURL + self.fetchInterval = fetchInterval + self.refreshCheckInterval = refreshCheckInterval + self.retryInitialSeconds = retryInitialSeconds + self.retryMaxSeconds = retryMaxSeconds + self.awaitTorReady = awaitTorReady + self.makeFetchData = makeFetchData + self.readData = readData + self.writeData = writeData + self.cacheURL = cacheURL + self.bundledCSVURLs = bundledCSVURLs + self.currentDirectoryPath = currentDirectoryPath + self.retrySleep = retrySleep + self.torReadyNotificationName = torReadyNotificationName + self.activeNotificationName = activeNotificationName + self.autoStart = autoStart } } @MainActor -final class GeoRelayDirectory { +public final class GeoRelayDirectory { private final class CleanupState { let notificationCenter: NotificationCenter var observers: [NSObjectProtocol] = [] @@ -113,10 +84,16 @@ final class GeoRelayDirectory { } } - struct Entry: Hashable, Sendable { - let host: String - let lat: Double - let lon: Double + public struct Entry: Hashable, Sendable { + public let host: String + public let lat: Double + public let lon: Double + + public init(host: String, lat: Double, lon: Double) { + self.host = host + self.lat = lat + self.lon = lon + } } private enum DetachedFetchOutcome: Sendable { @@ -126,9 +103,13 @@ final class GeoRelayDirectory { case network(String) } - static let shared = GeoRelayDirectory() + nonisolated(unsafe) public static var shared: GeoRelayDirectory! - private(set) var entries: [Entry] = [] + public static func setupShared(dependencies: GeoRelayDirectoryDependencies) { + shared = GeoRelayDirectory(dependencies: dependencies) + } + + private(set) public var entries: [Entry] = [] private let lastFetchKey = "georelay.lastFetchAt" private let dependencies: GeoRelayDirectoryDependencies private let cleanupState: CleanupState @@ -136,18 +117,7 @@ final class GeoRelayDirectory { private var retryAttempt: Int = 0 private var isFetching: Bool = false - private init() { - self.dependencies = .live() - self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter) - entries = loadLocalEntries() - if dependencies.autoStart { - registerObservers() - startRefreshTimer() - prefetchIfNeeded() - } - } - - internal init(dependencies: GeoRelayDirectoryDependencies) { + public init(dependencies: GeoRelayDirectoryDependencies) { self.dependencies = dependencies self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter) entries = loadLocalEntries() @@ -159,13 +129,13 @@ final class GeoRelayDirectory { } /// Returns up to `count` relay URLs (wss://) closest to the geohash center. - func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] { - let center = Geohash.decodeCenter(geohash) + public func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] { + let center = decodeGeohashCenter(geohash) return closestRelays(toLat: center.lat, lon: center.lon, count: count) } /// Returns up to `count` relay URLs (wss://) closest to the given coordinate. - func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] { + public func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] { guard !entries.isEmpty, count > 0 else { return [] } if entries.count <= count { @@ -195,7 +165,7 @@ final class GeoRelayDirectory { } // MARK: - Remote Fetch - func prefetchIfNeeded(force: Bool = false) { + public func prefetchIfNeeded(force: Bool = false) { guard !isFetching else { return } let now = dependencies.now() @@ -205,7 +175,6 @@ final class GeoRelayDirectory { guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return } } else if last != .distantPast, now.timeIntervalSince(last) < dependencies.retryInitialSeconds { - // Skip forced fetches if we just refreshed moments ago. return } @@ -343,7 +312,6 @@ final class GeoRelayDirectory { // MARK: - Loading private func loadLocalEntries() -> [Entry] { - // Prefer cached file if present if let cache = dependencies.cacheURL(), let data = dependencies.readData(cache), let text = String(data: data, encoding: .utf8) { @@ -351,7 +319,6 @@ final class GeoRelayDirectory { if !arr.isEmpty { return arr } } - // Try bundled resource(s) let bundleCandidates = dependencies.bundledCSVURLs() for url in bundleCandidates { @@ -362,7 +329,6 @@ final class GeoRelayDirectory { } } - // Try filesystem path (development/test) if let cwd = dependencies.currentDirectoryPath(), let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")), let text = String(data: data, encoding: .utf8) { @@ -373,7 +339,7 @@ final class GeoRelayDirectory { return [] } - nonisolated static func parseCSV(_ text: String) -> [Entry] { + nonisolated public static func parseCSV(_ text: String) -> [Entry] { var result: Set = [] let lines = text.split(whereSeparator: { $0.isNewline }) for (idx, raw) in lines.enumerated() { @@ -397,17 +363,19 @@ final class GeoRelayDirectory { private func registerObservers() { let center = dependencies.notificationCenter - let torReady = center.addObserver( - forName: .TorDidBecomeReady, - object: nil, - queue: .main - ) { [weak self] _ in - guard let self else { return } - Task { @MainActor in - self.prefetchIfNeeded(force: true) + if let torReadyName = dependencies.torReadyNotificationName { + let torReady = center.addObserver( + forName: torReadyName, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + self.prefetchIfNeeded(force: true) + } } + cleanupState.observers.append(torReady) } - cleanupState.observers.append(torReady) if let activeNotificationName = dependencies.activeNotificationName { let didBecomeActive = center.addObserver( @@ -439,17 +407,49 @@ final class GeoRelayDirectory { RunLoop.main.add(timer, forMode: .common) } - var debugRetryAttempt: Int { retryAttempt } - var debugHasRetryTask: Bool { cleanupState.retryTask != nil } - var debugObserverCount: Int { cleanupState.observers.count } + public var debugRetryAttempt: Int { retryAttempt } + public var debugHasRetryTask: Bool { cleanupState.retryTask != nil } + public var debugObserverCount: Int { cleanupState.observers.count } } // MARK: - Distance private func haversineKm(_ lat1: Double, _ lon1: Double, _ lat2: Double, _ lon2: Double) -> Double { - let r = 6371.0 // Earth radius in km + let r = 6371.0 let dLat = (lat2 - lat1) * .pi / 180 let dLon = (lon2 - lon1) * .pi / 180 let a = sin(dLat/2) * sin(dLat/2) + cos(lat1 * .pi/180) * cos(lat2 * .pi/180) * sin(dLon/2) * sin(dLon/2) let c = 2 * atan2(sqrt(a), sqrt(1 - a)) return r * c } + +// MARK: - Geohash decode (inline to avoid external dependency) + +private let geohashBase32Map: [Character: Int] = { + let chars = Array("0123456789bcdefghjkmnpqrstuvwxyz") + var map: [Character: Int] = [:] + for (i, c) in chars.enumerated() { map[c] = i } + return map +}() + +private func decodeGeohashCenter(_ geohash: String) -> (lat: Double, lon: Double) { + var latInterval: (Double, Double) = (-90.0, 90.0) + var lonInterval: (Double, Double) = (-180.0, 180.0) + + var isEven = true + for ch in geohash.lowercased() { + guard let cd = geohashBase32Map[ch] else { continue } + for mask in [16, 8, 4, 2, 1] { + if isEven { + let mid = (lonInterval.0 + lonInterval.1) / 2 + if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid } + } else { + let mid = (latInterval.0 + latInterval.1) / 2 + if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid } + } + isEven.toggle() + } + } + let lat = (latInterval.0 + latInterval.1) / 2 + let lon = (lonInterval.0 + lonInterval.1) / 2 + return (lat, lon) +} diff --git a/bitchat/Nostr/NostrIdentity.swift b/localPackages/Nostr/Sources/Nostr/NostrIdentity.swift similarity index 63% rename from bitchat/Nostr/NostrIdentity.swift rename to localPackages/Nostr/Sources/Nostr/NostrIdentity.swift index 72fb7bbf..73cf387b 100644 --- a/bitchat/Nostr/NostrIdentity.swift +++ b/localPackages/Nostr/Sources/Nostr/NostrIdentity.swift @@ -2,59 +2,56 @@ import Foundation import P256K /// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging -struct NostrIdentity: Codable { - let privateKey: Data - let publicKey: Data - let npub: String // Bech32-encoded public key - let createdAt: Date - - /// Memberwise initializer - init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) { +public struct NostrIdentity: Codable, Sendable { + public let privateKey: Data + public let publicKey: Data + public let npub: String // Bech32-encoded public key + public let createdAt: Date + + public init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) { self.privateKey = privateKey self.publicKey = publicKey self.npub = npub self.createdAt = createdAt } - + /// Generate a new Nostr identity - static func generate() throws -> NostrIdentity { - // Generate Schnorr key for Nostr + public static func generate() throws -> NostrIdentity { let schnorrKey = try P256K.Schnorr.PrivateKey() let xOnlyPubkey = Data(schnorrKey.xonly.bytes) let npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey) - + return NostrIdentity( privateKey: schnorrKey.dataRepresentation, - publicKey: xOnlyPubkey, // Store x-only public key + publicKey: xOnlyPubkey, npub: npub, createdAt: Date() ) } - + /// Initialize from existing private key data - init(privateKeyData: Data) throws { + public init(privateKeyData: Data) throws { let schnorrKey = try P256K.Schnorr.PrivateKey(dataRepresentation: privateKeyData) let xOnlyPubkey = Data(schnorrKey.xonly.bytes) - + self.privateKey = privateKeyData self.publicKey = xOnlyPubkey self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey) self.createdAt = Date() } - + /// Get signing key for event signatures - func signingKey() throws -> P256K.Signing.PrivateKey { + public func signingKey() throws -> P256K.Signing.PrivateKey { try P256K.Signing.PrivateKey(dataRepresentation: privateKey) } - + /// Get Schnorr signing key for Nostr event signatures - func schnorrSigningKey() throws -> P256K.Schnorr.PrivateKey { + public func schnorrSigningKey() throws -> P256K.Schnorr.PrivateKey { try P256K.Schnorr.PrivateKey(dataRepresentation: privateKey) } - + /// Get hex-encoded public key (for Nostr events) - var publicKeyHex: String { - // Public key is already stored as x-only (32 bytes) - return publicKey.hexEncodedString() + public var publicKeyHex: String { + publicKey.hexEncodedString() } } diff --git a/bitchat/Nostr/NostrIdentityBridge.swift b/localPackages/Nostr/Sources/Nostr/NostrIdentityBridge.swift similarity index 70% rename from bitchat/Nostr/NostrIdentityBridge.swift rename to localPackages/Nostr/Sources/Nostr/NostrIdentityBridge.swift index 01d929e0..05537af9 100644 --- a/bitchat/Nostr/NostrIdentityBridge.swift +++ b/localPackages/Nostr/Sources/Nostr/NostrIdentityBridge.swift @@ -1,51 +1,54 @@ import Foundation import CryptoKit +/// Minimal keychain access required by NostrIdentityBridge. +public protocol NostrKeychainStoring: Sendable { + func save(key: String, data: Data, service: String, accessible: CFString?) + func load(key: String, service: String) -> Data? +} + /// Bridge between Noise and Nostr identities -final class NostrIdentityBridge { +public final class NostrIdentityBridge { private let keychainService = "chat.bitchat.nostr" private let currentIdentityKey = "nostr-current-identity" private let deviceSeedKey = "nostr-device-seed" - // In-memory cache to avoid transient keychain access issues - private var deviceSeedCache: Data? + private let deviceSeedCache: NSLock = NSLock() + private var _deviceSeedCacheValue: Data? // Cache derived identities to avoid repeated crypto during view rendering - private var derivedIdentityCache: [String: NostrIdentity] = [:] + private var _derivedIdentityCache: [String: NostrIdentity] = [:] private let cacheLock = NSLock() - private let keychain: KeychainManagerProtocol + private let keychain: any NostrKeychainStoring - init(keychain: KeychainManagerProtocol = KeychainManager()) { + public init(keychain: any NostrKeychainStoring) { self.keychain = keychain } - + /// Get or create the current Nostr identity - func getCurrentNostrIdentity() throws -> NostrIdentity? { - // Check if we already have a Nostr identity + public func getCurrentNostrIdentity() throws -> NostrIdentity? { if let existingData = keychain.load(key: currentIdentityKey, service: keychainService), let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) { return identity } - - // Generate new Nostr identity + let nostrIdentity = try NostrIdentity.generate() - - // Store it + let data = try JSONEncoder().encode(nostrIdentity) keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil) - + return nostrIdentity } - + /// Associate a Nostr identity with a Noise public key (for favorites) - func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) { + public func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) { let key = "nostr-noise-\(noisePublicKey.base64EncodedString())" if let data = nostrPubkey.data(using: .utf8) { keychain.save(key: key, data: data, service: keychainService, accessible: nil) } } - + /// Get Nostr public key associated with a Noise public key - func getNostrPublicKey(for noisePublicKey: Data) -> String? { + public func getNostrPublicKey(for noisePublicKey: Data) -> String? { let key = "nostr-noise-\(noisePublicKey.base64EncodedString())" guard let data = keychain.load(key: key, service: keychainService), let pubkey = String(data: data, encoding: .utf8) else { @@ -53,9 +56,9 @@ final class NostrIdentityBridge { } return pubkey } - + /// Clear all Nostr identity associations and current identity - func clearAllAssociations() { + public func clearAllAssociations() { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, @@ -76,42 +79,45 @@ final class NostrIdentityBridge { } SecItemDelete(deleteQuery as CFDictionary) } - } else if status == errSecItemNotFound { - // nothing persisted; no action needed } - deviceSeedCache = nil + deviceSeedCache.lock() + _deviceSeedCacheValue = nil + deviceSeedCache.unlock() } // MARK: - Per-Geohash Identities (Location Channels) - /// Returns a stable device seed used to derive unlinkable per-geohash identities. - /// Stored only on device keychain. private func getOrCreateDeviceSeed() -> Data { - if let cached = deviceSeedCache { return cached } + deviceSeedCache.lock() + if let cached = _deviceSeedCacheValue { + deviceSeedCache.unlock() + return cached + } + deviceSeedCache.unlock() + if let existing = keychain.load(key: deviceSeedKey, service: keychainService) { - // Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) - deviceSeedCache = existing + deviceSeedCache.lock() + _deviceSeedCacheValue = existing + deviceSeedCache.unlock() return existing } var seed = Data(count: 32) _ = seed.withUnsafeMutableBytes { ptr in SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) } - // Ensure availability after first unlock to prevent unintended rotation when locked keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) - deviceSeedCache = seed + deviceSeedCache.lock() + _deviceSeedCacheValue = seed + deviceSeedCache.unlock() return seed } /// Derive a deterministic, unlinkable Nostr identity for a given geohash. - /// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing - /// if the candidate is not a valid secp256k1 private key. - func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity { - // Check cache first to avoid repeated crypto + keychain I/O during view rendering + public func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity { cacheLock.lock() - if let cached = derivedIdentityCache[geohash] { + if let cached = _derivedIdentityCache[geohash] { cacheLock.unlock() return cached } @@ -132,24 +138,21 @@ final class NostrIdentityBridge { return Data(code) } - // Try a few iterations to ensure a valid key can be formed for i in 0..<10 { let keyData = candidateKey(iteration: UInt32(i)) if let identity = try? NostrIdentity(privateKeyData: keyData) { - // Cache the result cacheLock.lock() - derivedIdentityCache[geohash] = identity + _derivedIdentityCache[geohash] = identity cacheLock.unlock() return identity } } - // As a final fallback, hash the seed+msg and try again + let fallback = (seed + msg).sha256Hash() let identity = try NostrIdentity(privateKeyData: fallback) - // Cache the result cacheLock.lock() - derivedIdentityCache[geohash] = identity + _derivedIdentityCache[geohash] = identity cacheLock.unlock() return identity diff --git a/bitchat/Nostr/NostrProtocol.swift b/localPackages/Nostr/Sources/Nostr/NostrProtocol.swift similarity index 71% rename from bitchat/Nostr/NostrProtocol.swift rename to localPackages/Nostr/Sources/Nostr/NostrProtocol.swift index 4a05eaf7..69087578 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/localPackages/Nostr/Sources/Nostr/NostrProtocol.swift @@ -1,17 +1,15 @@ +import BitFoundation import BitLogger import Foundation import CryptoKit import P256K import Security -// Note: This file depends on Data extension from BinaryEncodingUtils.swift -// Make sure BinaryEncodingUtils.swift is included in the target - /// NIP-17 Protocol Implementation for Private Direct Messages -struct NostrProtocol { - +public struct NostrProtocol { + /// Nostr event kinds - enum EventKind: Int { + public enum EventKind: Int, Sendable { case metadata = 0 case textNote = 1 case dm = 14 // NIP-17 DM rumor kind @@ -20,88 +18,71 @@ struct NostrProtocol { case ephemeralEvent = 20000 case geohashPresence = 20001 } - + /// Create a NIP-17 private message - static func createPrivateMessage( + public static func createPrivateMessage( content: String, recipientPubkey: String, senderIdentity: NostrIdentity ) throws -> NostrEvent { - - // Creating private message - - // 1. Create the rumor (unsigned event) let rumor = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), - kind: .dm, // NIP-17: DM rumor kind 14 + kind: .dm, tags: [], content: content ) - - // 2. Create ephemeral key for this message + let ephemeralKey = try P256K.Schnorr.PrivateKey() - // Created ephemeral key for seal - - // 3. Seal the rumor (encrypt to recipient) + let sealedEvent = try createSeal( rumor: rumor, recipientPubkey: recipientPubkey, senderKey: ephemeralKey ) - - // 4. Gift wrap the sealed event (encrypt to recipient again) + let giftWrap = try createGiftWrap( seal: sealedEvent, recipientPubkey: recipientPubkey, senderKey: ephemeralKey ) - - // Created gift wrap - + return giftWrap } - + /// Decrypt a received NIP-17 message /// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp) - static func decryptPrivateMessage( + public static func decryptPrivateMessage( giftWrap: NostrEvent, recipientIdentity: NostrIdentity ) throws -> (content: String, senderPubkey: String, timestamp: Int) { - - // Starting decryption - - // 1. Unwrap the gift wrap let seal: NostrEvent do { seal = try unwrapGiftWrap( giftWrap: giftWrap, recipientKey: recipientIdentity.schnorrSigningKey() ) - // Successfully unwrapped gift wrap } catch { SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session) throw error } - - // 2. Open the seal + let rumor: NostrEvent do { rumor = try openSeal( seal: seal, recipientKey: recipientIdentity.schnorrSigningKey() ) - // Successfully opened seal } catch { SecureLogger.error("❌ Failed to open seal: \(error)", category: .session) throw error } - + return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at) } /// Create a geohash-scoped ephemeral public message (kind 20000) - static func createEphemeralGeohashEvent( + public static func createEphemeralGeohashEvent( content: String, geohash: String, senderIdentity: NostrIdentity, @@ -128,7 +109,7 @@ struct NostrProtocol { /// Create a geohash presence heartbeat (kind 20001) /// Must contain empty content and NO nickname tag - static func createGeohashPresenceEvent( + public static func createGeohashPresenceEvent( geohash: String, senderIdentity: NostrIdentity ) throws -> NostrEvent { @@ -145,7 +126,7 @@ struct NostrProtocol { } /// Create a persistent location note (kind 1: text note) tagged to a street-level geohash. - static func createGeohashTextNote( + public static func createGeohashTextNote( content: String, geohash: String, senderIdentity: NostrIdentity, @@ -165,22 +146,21 @@ struct NostrProtocol { let schnorrKey = try senderIdentity.schnorrSigningKey() return try event.sign(with: schnorrKey) } - + // MARK: - Private Methods - + private static func createSeal( rumor: NostrEvent, recipientPubkey: String, senderKey: P256K.Schnorr.PrivateKey ) throws -> NostrEvent { - let rumorJSON = try rumor.jsonString() let encrypted = try encrypt( plaintext: rumorJSON, recipientPubkey: recipientPubkey, senderKey: senderKey ) - + let seal = NostrEvent( pubkey: Data(senderKey.xonly.bytes).hexEncodedString(), createdAt: randomizedTimestamp(), @@ -188,130 +168,108 @@ struct NostrProtocol { tags: [], content: encrypted ) - - // Sign the seal with the sender's Schnorr private key + return try seal.sign(with: senderKey) } - + private static func createGiftWrap( seal: NostrEvent, recipientPubkey: String, - senderKey: P256K.Schnorr.PrivateKey // This is the ephemeral key used for the seal + senderKey: P256K.Schnorr.PrivateKey ) throws -> NostrEvent { - let sealJSON = try seal.jsonString() - - // Create new ephemeral key for gift wrap let wrapKey = try P256K.Schnorr.PrivateKey() - // Creating gift wrap with ephemeral key - - // Encrypt the seal with the new ephemeral key (not the seal's key) + let encrypted = try encrypt( plaintext: sealJSON, recipientPubkey: recipientPubkey, - senderKey: wrapKey // Use the gift wrap ephemeral key + senderKey: wrapKey ) - + let giftWrap = NostrEvent( pubkey: Data(wrapKey.xonly.bytes).hexEncodedString(), createdAt: randomizedTimestamp(), kind: .giftWrap, - tags: [["p", recipientPubkey]], // Tag recipient + tags: [["p", recipientPubkey]], content: encrypted ) - - // Sign the gift wrap with the wrap Schnorr private key + return try giftWrap.sign(with: wrapKey) } - + private static func unwrapGiftWrap( giftWrap: NostrEvent, recipientKey: P256K.Schnorr.PrivateKey ) throws -> NostrEvent { - - // Unwrapping gift wrap - let decrypted = try decrypt( ciphertext: giftWrap.content, senderPubkey: giftWrap.pubkey, recipientKey: recipientKey ) - + guard let data = decrypted.data(using: .utf8), let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw NostrError.invalidEvent } - - let seal = try NostrEvent(from: sealDict) - // Unwrapped seal - - return seal + + return try NostrEvent(from: sealDict) } - + private static func openSeal( seal: NostrEvent, recipientKey: P256K.Schnorr.PrivateKey ) throws -> NostrEvent { - let decrypted = try decrypt( ciphertext: seal.content, senderPubkey: seal.pubkey, recipientKey: recipientKey ) - + guard let data = decrypted.data(using: .utf8), let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw NostrError.invalidEvent } - + return try NostrEvent(from: rumorDict) } - + // MARK: - Encryption (NIP-44 v2) - + private static func encrypt( plaintext: String, recipientPubkey: String, senderKey: P256K.Schnorr.PrivateKey ) throws -> String { - guard let recipientPubkeyData = Data(hexString: recipientPubkey) else { throw NostrError.invalidPublicKey } - - // Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned) - - // Derive shared secret + let sharedSecret = try deriveSharedSecret( privateKey: senderKey, publicKey: recipientPubkeyData ) - // Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info) let key = try deriveNIP44V2Key(from: sharedSecret) - - // 24-byte random nonce for XChaCha20-Poly1305 + var nonce24 = Data(count: 24) _ = nonce24.withUnsafeMutableBytes { ptr in SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!) } - + let pt = Data(plaintext.utf8) let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24) - - // v2: base64url(nonce24 || ciphertext || tag) + var combined = Data() combined.append(nonce24) combined.append(sealed.ciphertext) combined.append(sealed.tag) return "v2:" + base64URLEncode(combined) } - + private static func decrypt( ciphertext: String, senderPubkey: String, recipientKey: P256K.Schnorr.PrivateKey ) throws -> String { - // Expect NIP-44 v2 format guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext } let encoded = String(ciphertext.dropFirst(3)) guard let data = base64URLDecode(encoded), @@ -325,7 +283,6 @@ struct NostrProtocol { let tag = rest.suffix(16) let ct = rest.dropLast(16) - // Try decryption with even-Y then odd-Y when sender pubkey is x-only func attemptDecrypt(using pubKeyData: Data) throws -> Data { let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData) let key = try deriveNIP44V2Key(from: ss) @@ -337,7 +294,6 @@ struct NostrProtocol { ) } - // If 32 bytes (x-only) try both parities, otherwise single try if senderPubkeyData.count == 32 { let even = Data([0x02]) + senderPubkeyData if let pt = try? attemptDecrypt(using: even) { @@ -351,32 +307,23 @@ struct NostrProtocol { return String(data: pt, encoding: .utf8) ?? "" } } - + private static func deriveSharedSecret( privateKey: P256K.Schnorr.PrivateKey, publicKey: Data ) throws -> Data { - // Deriving shared secret - - // Convert Schnorr private key to KeyAgreement private key let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey( dataRepresentation: privateKey.dataRepresentation ) - - // Create KeyAgreement public key from the public key data - // For ECDH, we need the full 33-byte compressed public key (with 0x02 or 0x03 prefix) + var fullPublicKey = Data() - if publicKey.count == 32 { // X-only key, need to add prefix - // For x-only keys in Nostr/Bitcoin, we need to try both possible Y coordinates - // First try with even Y (0x02 prefix) + if publicKey.count == 32 { fullPublicKey.append(0x02) fullPublicKey.append(publicKey) - // Trying with even Y coordinate } else { fullPublicKey = publicKey } - - // Try to create public key, if it fails with even Y, try odd Y + let keyAgreementPublicKey: P256K.KeyAgreement.PublicKey do { keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey( @@ -385,8 +332,6 @@ struct NostrProtocol { ) } catch { if publicKey.count == 32 { - // Try with odd Y (0x03 prefix) - // Even Y failed, trying odd Y fullPublicKey = Data() fullPublicKey.append(0x03) fullPublicKey.append(publicKey) @@ -398,85 +343,32 @@ struct NostrProtocol { throw error } } - - // Perform ECDH + let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement( with: keyAgreementPublicKey, format: .compressed ) - - // Convert SharedSecret to Data - let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } - // ECDH shared secret derived - - // Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key - return sharedSecretData + + return sharedSecret.withUnsafeBytes { Data($0) } } - - // Direct version that doesn't try to add prefixes - private static func deriveSharedSecretDirect( - privateKey: P256K.Schnorr.PrivateKey, - publicKey: Data - ) throws -> Data { - // Direct shared secret calculation - - // Convert Schnorr private key to KeyAgreement private key - let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey( - dataRepresentation: privateKey.dataRepresentation - ) - - // Use the public key as-is (should already have prefix) - let keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey( - dataRepresentation: publicKey, - format: .compressed - ) - - // Perform ECDH - let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement( - with: keyAgreementPublicKey, - format: .compressed - ) - - // Convert SharedSecret to Data - let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } - - // Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key - return sharedSecretData - } - + private static func randomizedTimestamp() -> Date { - // Add random offset to current time for privacy - // This prevents timing correlation attacks while the actual message timestamp - // is preserved in the encrypted rumor - let offset = TimeInterval.random(in: -900...900) // +/- 15 minutes - let now = Date() - let randomized = now.addingTimeInterval(offset) - - // Log with explicit UTC and local time for debugging - let formatter = DateFormatter() - // - formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - formatter.timeZone = TimeZone(abbreviation: "UTC") - - formatter.timeZone = TimeZone.current - - // Timestamp randomized for privacy - - return randomized + let offset = TimeInterval.random(in: -900...900) + return Date().addingTimeInterval(offset) } } /// Nostr Event structure -struct NostrEvent: Codable { - var id: String - let pubkey: String - let created_at: Int - let kind: Int - let tags: [[String]] - let content: String - var sig: String? - - init( +public struct NostrEvent: Codable, Sendable { + public var id: String + public let pubkey: String + public let created_at: Int + public let kind: Int + public let tags: [[String]] + public let content: String + public var sig: String? + + public init( pubkey: String, createdAt: Date, kind: NostrProtocol.EventKind, @@ -489,10 +381,10 @@ struct NostrEvent: Codable { self.tags = tags self.content = content self.sig = nil - self.id = "" // Will be set during signing + self.id = "" } - - init(from dict: [String: Any]) throws { + + public init(from dict: [String: Any]) throws { guard let pubkey = dict["pubkey"] as? String, let createdAt = dict["created_at"] as? Int, let kind = dict["kind"] as? Int, @@ -500,7 +392,7 @@ struct NostrEvent: Codable { let content = dict["content"] as? String else { throw NostrError.invalidEvent } - + self.id = dict["id"] as? String ?? "" self.pubkey = pubkey self.created_at = createdAt @@ -509,20 +401,19 @@ struct NostrEvent: Codable { self.content = content self.sig = dict["sig"] as? String } - - func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent { + + public func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent { let (eventId, eventIdHash) = try calculateEventId() - - // Sign with Schnorr (BIP-340) + var messageBytes = [UInt8](eventIdHash) var auxRand = [UInt8](repeating: 0, count: 32) _ = auxRand.withUnsafeMutableBytes { ptr in SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) } let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand) - + let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString() - + var signed = self signed.id = eventId signed.sig = signatureHex @@ -531,7 +422,7 @@ struct NostrEvent: Codable { /// Validate that the event ID and Schnorr signature match the content and pubkey. /// Returns false when the signature is missing, malformed, or does not verify. - func isValidSignature() -> Bool { + public func isValidSignature() -> Bool { guard let sig = sig, let sigData = Data(hexString: sig), let pubData = Data(hexString: pubkey), @@ -548,7 +439,7 @@ struct NostrEvent: Codable { let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData) return xonly.isValid(signature, for: &messageBytes) } - + private func calculateEventId() throws -> (String, Data) { let serialized = [ 0, @@ -558,12 +449,12 @@ struct NostrEvent: Codable { tags, content ] as [Any] - + let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes]) return (data.sha256Fingerprint(), data.sha256Hash()) } - - func jsonString() throws -> String { + + public func jsonString() throws -> String { let encoder = JSONEncoder() encoder.outputFormatting = [.withoutEscapingSlashes] let data = try encoder.encode(self) @@ -571,7 +462,7 @@ struct NostrEvent: Codable { } } -enum NostrError: Error { +public enum NostrError: Error, Sendable { case invalidPublicKey case invalidPrivateKey case invalidEvent diff --git a/bitchat/Nostr/NostrRelayManager.swift b/localPackages/Nostr/Sources/Nostr/NostrRelayManager.swift similarity index 73% rename from bitchat/Nostr/NostrRelayManager.swift rename to localPackages/Nostr/Sources/Nostr/NostrRelayManager.swift index ebfeadaa..869d2fa4 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/localPackages/Nostr/Sources/Nostr/NostrRelayManager.swift @@ -2,9 +2,8 @@ import BitLogger import Foundation import Network import Combine -import Tor -protocol NostrRelayConnectionProtocol: AnyObject { +public protocol NostrRelayConnectionProtocol: AnyObject { func resume() func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) @@ -12,7 +11,7 @@ protocol NostrRelayConnectionProtocol: AnyObject { func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) } -protocol NostrRelaySessionProtocol { +public protocol NostrRelaySessionProtocol { func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol } @@ -52,75 +51,91 @@ private struct URLSessionAdapter: NostrRelaySessionProtocol { } } -struct NostrRelayManagerDependencies { - var activationAllowed: () -> Bool - var userTorEnabled: () -> Bool - var hasMutualFavorites: () -> Bool - var hasLocationPermission: () -> Bool - var mutualFavoritesPublisher: AnyPublisher, Never> - var locationPermissionPublisher: AnyPublisher - var torEnforced: () -> Bool - var torIsReady: () -> Bool - var torIsForeground: () -> Bool - var awaitTorReady: (@escaping (Bool) -> Void) -> Void - var makeSession: () -> NostrRelaySessionProtocol - var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void - var now: () -> Date +public enum LocationPermissionState: Equatable, Sendable { + case notDetermined + case authorized + case denied } -private extension NostrRelayManagerDependencies { - @MainActor - static func live() -> Self { - Self( - activationAllowed: { NetworkActivationService.shared.activationAllowed }, - userTorEnabled: { NetworkActivationService.shared.userTorEnabled }, - hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty }, - hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized }, - mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(), - locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(), - torEnforced: { TorManager.shared.torEnforced }, - torIsReady: { TorManager.shared.isReady }, - torIsForeground: { TorManager.shared.isForeground() }, - awaitTorReady: { completion in - Task.detached { - let ready = await TorManager.shared.awaitReady() - await MainActor.run { - completion(ready) - } - } - }, - makeSession: { URLSessionAdapter(base: TorURLSession.shared.session) }, - scheduleAfter: { delay, action in - DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) - }, - now: Date.init - ) +public struct NostrRelayManagerDependencies { + public var activationAllowed: () -> Bool + public var userTorEnabled: () -> Bool + public var hasMutualFavorites: () -> Bool + public var hasLocationPermission: () -> Bool + public var mutualFavoritesPublisher: AnyPublisher, Never> + public var locationPermissionPublisher: AnyPublisher + public var torEnforced: () -> Bool + public var torIsReady: () -> Bool + public var torIsForeground: () -> Bool + public var awaitTorReady: (@escaping (Bool) -> Void) -> Void + public var makeSession: () -> NostrRelaySessionProtocol + public var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void + public var now: () -> Date + + public init( + activationAllowed: @escaping () -> Bool, + userTorEnabled: @escaping () -> Bool, + hasMutualFavorites: @escaping () -> Bool, + hasLocationPermission: @escaping () -> Bool, + mutualFavoritesPublisher: AnyPublisher, Never>, + locationPermissionPublisher: AnyPublisher, + torEnforced: @escaping () -> Bool, + torIsReady: @escaping () -> Bool, + torIsForeground: @escaping () -> Bool, + awaitTorReady: @escaping (@escaping (Bool) -> Void) -> Void, + makeSession: @escaping () -> NostrRelaySessionProtocol, + scheduleAfter: @Sendable @escaping (TimeInterval, @escaping @Sendable () -> Void) -> Void, + now: @escaping () -> Date + ) { + self.activationAllowed = activationAllowed + self.userTorEnabled = userTorEnabled + self.hasMutualFavorites = hasMutualFavorites + self.hasLocationPermission = hasLocationPermission + self.mutualFavoritesPublisher = mutualFavoritesPublisher + self.locationPermissionPublisher = locationPermissionPublisher + self.torEnforced = torEnforced + self.torIsReady = torIsReady + self.torIsForeground = torIsForeground + self.awaitTorReady = awaitTorReady + self.makeSession = makeSession + self.scheduleAfter = scheduleAfter + self.now = now } } /// Manages WebSocket connections to Nostr relays @MainActor -final class NostrRelayManager: ObservableObject { - static let shared = NostrRelayManager() +public final class NostrRelayManager: ObservableObject { + nonisolated(unsafe) public static var shared: NostrRelayManager! + + public static func setupShared(dependencies: NostrRelayManagerDependencies) { + shared = NostrRelayManager(dependencies: dependencies) + } + + /// Wraps a URLSession into a NostrRelaySessionProtocol for use in live dependencies. + public static func makeURLSession(_ session: URLSession) -> NostrRelaySessionProtocol { + URLSessionAdapter(base: session) + } + // Track gift-wraps (kind 1059) we initiated so we can log OK acks at info - private(set) static var pendingGiftWrapIDs = Set() - static func registerPendingGiftWrap(id: String) { + private(set) public static var pendingGiftWrapIDs = Set() + public static func registerPendingGiftWrap(id: String) { pendingGiftWrapIDs.insert(id) } - - struct Relay: Identifiable { - let id = UUID() - let url: String - var isConnected: Bool = false - var lastError: Error? - var lastConnectedAt: Date? - var messagesSent: Int = 0 - var messagesReceived: Int = 0 - var reconnectAttempts: Int = 0 - var lastDisconnectedAt: Date? - var nextReconnectTime: Date? + + public struct Relay: Identifiable { + public let id = UUID() + public let url: String + public var isConnected: Bool = false + public var lastError: Error? + public var lastConnectedAt: Date? + public var messagesSent: Int = 0 + public var messagesReceived: Int = 0 + public var reconnectAttempts: Int = 0 + public var lastDisconnectedAt: Date? + public var nextReconnectTime: Date? } - + // Default relay list (can be customized) private static let defaultRelays = [ "wss://relay.damus.io", @@ -128,35 +143,30 @@ final class NostrRelayManager: ObservableObject { "wss://relay.primal.net", "wss://offchain.pub", "wss://nostr21.com" - // For local testing, you can add: "ws://localhost:8080" ] private static let defaultRelaySet = Set(defaultRelays) - - @Published private(set) var relays: [Relay] = [] - @Published private(set) var isConnected = false - + + @Published private(set) public var relays: [Relay] = [] + @Published private(set) public var isConnected = false + private let dependencies: NostrRelayManagerDependencies private var allowDefaultRelays: Bool = false 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 - private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON) + private var subscriptions: [String: Set] = [:] + private var pendingSubscriptions: [String: [String: String]] = [:] private var messageHandlers: [String: (NostrEvent) -> Void] = [:] - // Coalesce duplicate subscribe requests for the same id within a short window private var subscribeCoalesce: [String: Date] = [:] private var cancellables = Set() - // Track EOSE per subscription to signal when initial stored events are done private struct EOSETracker { var pendingRelays: Set var callback: () -> Void var timer: Timer? } private var eoseTrackers: [String: EOSETracker] = [:] - - // Message queue for reliability - // Pending sends held only for relays that are not yet connected. + private struct PendingSend { var event: NostrEvent var pendingRelays: Set @@ -165,22 +175,20 @@ final class NostrRelayManager: ObservableObject { private let messageQueueLock = NSLock() private let encoder = JSONEncoder() private var shouldUseTor: Bool { dependencies.userTorEnabled() } - - // Exponential backoff configuration - private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds - private let maxBackoffInterval: TimeInterval = TransportConfig.nostrRelayMaxBackoffSeconds - private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier - private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts - - // Bump generation to invalidate scheduled reconnects when we reset/disconnect + + // Exponential backoff configuration (matches TransportConfig values) + private let initialBackoffInterval: TimeInterval = 1.0 + private let maxBackoffInterval: TimeInterval = 300.0 + private let backoffMultiplier: Double = 2.0 + private let maxReconnectAttempts = 10 + private var connectionGeneration: Int = 0 - - init() { - self.dependencies = .live() + + public init(dependencies: NostrRelayManagerDependencies) { + self.dependencies = dependencies hasMutualFavorites = dependencies.hasMutualFavorites() hasLocationPermission = dependencies.hasLocationPermission() applyDefaultRelayPolicy(force: true) - // Deterministic JSON shape for outbound requests self.encoder.outputFormatting = .sortedKeys dependencies.mutualFavoritesPublisher .receive(on: DispatchQueue.main) @@ -202,39 +210,10 @@ final class NostrRelayManager: ObservableObject { .store(in: &cancellables) } - internal init(dependencies: NostrRelayManagerDependencies) { - self.dependencies = dependencies - hasMutualFavorites = dependencies.hasMutualFavorites() - hasLocationPermission = dependencies.hasLocationPermission() - applyDefaultRelayPolicy(force: true) - // Deterministic JSON shape for outbound requests - self.encoder.outputFormatting = .sortedKeys - dependencies.mutualFavoritesPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] favorites in - guard let self = self else { return } - self.hasMutualFavorites = !favorites.isEmpty - self.applyDefaultRelayPolicy() - } - .store(in: &cancellables) - dependencies.locationPermissionPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] state in - guard let self = self else { return } - let authorized = (state == .authorized) - if authorized == self.hasLocationPermission { return } - self.hasLocationPermission = authorized - self.applyDefaultRelayPolicy() - } - .store(in: &cancellables) - } - /// Connect to all configured relays - func connect() { - // Global network policy gate + public func connect() { guard dependencies.activationAllowed() else { return } if shouldUseTor { - // Ensure Tor is started early and wait for readiness off-main; then hop back to connect. dependencies.awaitTorReady { [weak self] ready in guard let self = self else { return } if !ready { @@ -253,28 +232,26 @@ final class NostrRelayManager: ObservableObject { } } } - + /// Disconnect from all relays - func disconnect() { + public func disconnect() { connectionGeneration &+= 1 + for (_, task) in connections { task.cancel(with: .goingAway, reason: nil) } connections.removeAll() - // Clear known subscriptions and any queued subs since connections are gone subscriptions.removeAll() pendingSubscriptions.removeAll() updateConnectionStatus() } - + /// Ensure connections exist to the given relay URLs (idempotent). - func ensureConnections(to relayUrls: [String]) { - // Global network policy gate + public func ensureConnections(to relayUrls: [String]) { guard dependencies.activationAllowed() else { return } let targets = allowedRelayList(from: relayUrls) guard !targets.isEmpty else { return } if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { - // Defer until Tor is fully ready; avoid queuing connection attempts early dependencies.awaitTorReady { [weak self] ready in guard let self = self else { return } if ready { self.ensureConnections(to: relayUrls) } @@ -292,11 +269,9 @@ final class NostrRelayManager: ObservableObject { } /// Send an event to specified relays (or all if none specified) - func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) { - // Global network policy gate + public func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) { guard dependencies.activationAllowed() else { return } if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { - // Defer sends until Tor is ready to avoid premature queueing dependencies.awaitTorReady { [weak self] ready in guard let self = self else { return } if ready { self.sendEvent(event, to: relayUrls) } @@ -308,7 +283,6 @@ final class NostrRelayManager: ObservableObject { guard !targetRelays.isEmpty else { return } ensureConnections(to: targetRelays) - // Attempt immediate send to relays with active connections; queue the rest var stillPending = Set() for relayUrl in targetRelays { if let connection = connections[relayUrl] { @@ -324,13 +298,11 @@ final class NostrRelayManager: ObservableObject { } } - /// Try to flush any queued messages for relays that are now connected. private func flushMessageQueue(for relayUrl: String? = nil) { messageQueueLock.lock() defer { messageQueueLock.unlock() } guard !messageQueue.isEmpty else { return } if let target = relayUrl { - // Flush only for a specific relay for i in (0.. Void, onEOSE: (() -> Void)? = nil ) { - // Global network policy gate guard dependencies.activationAllowed() else { return } - // Coalesce rapid duplicate subscribe requests only if a handler already exists let now = dependencies.now() if messageHandlers[id] != nil { if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 { @@ -381,7 +350,6 @@ final class NostrRelayManager: ObservableObject { } subscribeCoalesce[id] = now if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { - // Defer subscription setup until Tor is ready; avoid queuing subs early dependencies.awaitTorReady { [weak self] ready in guard let self = self else { return } if ready { @@ -391,23 +359,19 @@ final class NostrRelayManager: ObservableObject { return } messageHandlers[id] = handler - + let req = NostrRequest.subscribe(id: id, filters: [filter]) - + do { let message = try encoder.encode(req) - guard let messageString = String(data: message, encoding: .utf8) else { + guard let messageString = String(data: message, encoding: .utf8) else { SecureLogger.error("❌ Failed to encode subscription request", category: .session) - return + return } - - // SecureLogger.debug("📋 Subscription filter JSON: \(messageString.prefix(200))...", category: .session) - - // Target specific relays if provided; else default. Filter permanently failed relays. + let baseUrls = relayUrls ?? Self.defaultRelays let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) } let urls = allowedRelayList(from: candidateUrls) - // Always queue subscriptions; sending happens when a relay reports connected let existingSet = Set(relays.map { $0.url }) for url in urls where !existingSet.contains(url) { relays.append(Relay(url: url)) @@ -417,13 +381,11 @@ final class NostrRelayManager: ObservableObject { map[id] = messageString self.pendingSubscriptions[url] = map } - // Initialize EOSE tracking if requested if let onEOSE = onEOSE { if urls.isEmpty { onEOSE() } else { var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil) - // Fallback timeout to avoid hanging if a relay never sends EOSE tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in Task { @MainActor in guard let self = self else { return } @@ -438,9 +400,7 @@ final class NostrRelayManager: ObservableObject { } } SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session) - // Ensure we actually have sockets opening to these relays so queued REQs can flush ensureConnections(to: urls) - // If some targets are already connected, flush immediately for them for url in urls { if let r = relays.first(where: { $0.url == url }), r.isConnected { flushPendingSubscriptions(for: url) @@ -500,56 +460,46 @@ final class NostrRelayManager: ObservableObject { } return result } - + /// Unsubscribe from a subscription - func unsubscribe(id: String) { + public func unsubscribe(id: String) { messageHandlers.removeValue(forKey: id) - // Allow immediate re-subscription by clearing coalescer timestamp subscribeCoalesce.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) - connection.send(.string(messageString)) { _ in - // Local state is cleared before sending so callers can re-subscribe immediately. - } + connection.send(.string(messageString)) { _ in } } } } - + // MARK: - Private Methods - + private func connectToRelay(_ urlString: String) { - // Global network policy gate guard dependencies.activationAllowed() else { return } - guard let url = URL(string: urlString) else { + guard let url = URL(string: urlString) else { SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session) - return + return } - // Avoid initiating connections while app is backgrounded; we'll reconnect on foreground if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsForeground() { return } - - // Skip if we already have a connection object + if connections[urlString] != nil { return } if isPermanentlyFailed(urlString) { return } - - // Attempting to connect to Nostr relay via the proxied session - - // If Tor is enforced but not ready, delay connection until it is. + if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { dependencies.awaitTorReady { [weak self] ready in guard let self = self else { return } @@ -558,35 +508,30 @@ final class NostrRelayManager: ObservableObject { } return } - + let session = dependencies.makeSession() let task = session.webSocketTask(with: url) - + connections[urlString] = task task.resume() - - // Start receiving messages + receiveMessage(from: task, relayUrl: urlString) - - // Send initial ping to verify connection + task.sendPing { [weak self] error in DispatchQueue.main.async { if error == nil { SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session) self?.updateRelayStatus(urlString, isConnected: true) - // Flush any pending subscriptions for this relay self?.flushPendingSubscriptions(for: urlString) } else { SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session) self?.updateRelayStatus(urlString, isConnected: false, error: error) - // Trigger disconnection handler for proper backoff self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil)) } } } } - /// Send any queued subscriptions for a relay that just connected. private func flushPendingSubscriptions(for relayUrl: String) { guard let map = pendingSubscriptions[relayUrl], !map.isEmpty else { return } guard let connection = connections[relayUrl] else { return } @@ -606,26 +551,24 @@ final class NostrRelayManager: ObservableObject { } pendingSubscriptions[relayUrl] = nil } - + private func receiveMessage(from task: NostrRelayConnectionProtocol, relayUrl: String) { task.receive { [weak self] result in guard let self = self else { return } - + switch result { case .success(let message): - // Parse off-main to reduce UI jank, then hop back for state updates Task.detached(priority: .utility) { guard let parsed = ParsedInbound(message) else { return } await MainActor.run { self.handleParsedMessage(parsed, from: relayUrl) } } - - // Continue receiving + Task { @MainActor in self.receiveMessage(from: task, relayUrl: relayUrl) } - + case .failure(let error): DispatchQueue.main.async { self.handleDisconnection(relayUrl: relayUrl, error: error) @@ -633,12 +576,7 @@ final class NostrRelayManager: ObservableObject { } } } - - // Parsed inbound message type (off-main) - // Note: declared at file scope below to avoid MainActor isolation inside this class - // and keep parsing off the main actor. - // Handle parsed message on MainActor (state updates and handlers) private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) { switch parsed { case .event(let subId, let event): @@ -680,23 +618,21 @@ final class NostrRelayManager: ObservableObject { break } } - + private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) { let req = NostrRequest.event(event) - + do { let data = try encoder.encode(req) let message = String(data: data, encoding: .utf8) ?? "" - + SecureLogger.debug("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session) - + connection.send(.string(message)) { [weak self] error in DispatchQueue.main.async { if let error = error { SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session) } else { - // SecureLogger.debug("✅ Event sent to relay: \(relayUrl)", category: .session) - // Update relay stats if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) { self?.relays[index].messagesSent += 1 } @@ -707,32 +643,30 @@ final class NostrRelayManager: ObservableObject { SecureLogger.error("Failed to encode event: \(error)", category: .session) } } - + private func updateRelayStatus(_ url: String, isConnected: Bool, error: Error? = nil) { if let index = relays.firstIndex(where: { $0.url == url }) { relays[index].isConnected = isConnected relays[index].lastError = error if isConnected { relays[index].lastConnectedAt = dependencies.now() - relays[index].reconnectAttempts = 0 // Reset on successful connection + relays[index].reconnectAttempts = 0 relays[index].nextReconnectTime = nil } else { relays[index].lastDisconnectedAt = dependencies.now() } } updateConnectionStatus() - // If we just connected to this relay, flush any queued sends targeting it if isConnected { flushMessageQueue(for: url) } } - + private func updateConnectionStatus() { isConnected = relays.contains { $0.isConnected } } - + private func handleDisconnection(relayUrl: String, error: Error) { - // If networking is disallowed, do not schedule reconnection if !dependencies.activationAllowed() { connections.removeValue(forKey: relayUrl) subscriptions.removeValue(forKey: relayUrl) @@ -742,11 +676,10 @@ final class NostrRelayManager: ObservableObject { connections.removeValue(forKey: relayUrl) subscriptions.removeValue(forKey: relayUrl) updateRelayStatus(relayUrl, isConnected: false, error: error) - - // Check if this is a DNS or handshake error; treat as permanent + let errorDescription = error.localizedDescription.lowercased() let ns = error as NSError - if errorDescription.contains("hostname could not be found") || + if errorDescription.contains("hostname could not be found") || errorDescription.contains("dns") || (ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) { if relays.first(where: { $0.url == relayUrl })?.lastError == nil { @@ -760,106 +693,90 @@ final class NostrRelayManager: ObservableObject { pendingSubscriptions[relayUrl] = nil return } - - // Implement exponential backoff for non-DNS errors + guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return } - + relays[index].reconnectAttempts += 1 - - // Stop attempting after max attempts + if relays[index].reconnectAttempts >= maxReconnectAttempts { SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session) return } - - // Calculate backoff interval + let backoffInterval = min( initialBackoffInterval * pow(backoffMultiplier, Double(relays[index].reconnectAttempts - 1)), maxBackoffInterval ) - + let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval) relays[index].nextReconnectTime = nextReconnectTime - - - // Schedule reconnection with exponential backoff + let gen = connectionGeneration dependencies.scheduleAfter(backoffInterval) { [weak self] in Task { @MainActor [weak self] in guard let self = self else { return } - // Ignore stale scheduled reconnects from a previous generation guard gen == self.connectionGeneration else { return } - // Check if we should still reconnect (relay might have been removed) if self.relays.contains(where: { $0.url == relayUrl }) { self.connectToRelay(relayUrl) } } } } - + // MARK: - Public Utility Methods - - /// Manually retry connection to a specific relay - func retryConnection(to relayUrl: String) { + + public func retryConnection(to relayUrl: String) { guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return } - - // Reset reconnection attempts + relays[index].reconnectAttempts = 0 relays[index].nextReconnectTime = nil relays[index].lastError = nil - - // Disconnect if connected + if let connection = connections[relayUrl] { connection.cancel(with: .goingAway, reason: nil) connections.removeValue(forKey: relayUrl) } - - // Attempt immediate reconnection + connectToRelay(relayUrl) } - - /// Get detailed status for all relays - func getRelayStatuses() -> [(url: String, isConnected: Bool, reconnectAttempts: Int, nextReconnectTime: Date?)] { + + public func getRelayStatuses() -> [(url: String, isConnected: Bool, reconnectAttempts: Int, nextReconnectTime: Date?)] { return relays.map { relay in - (url: relay.url, - isConnected: relay.isConnected, + (url: relay.url, + isConnected: relay.isConnected, reconnectAttempts: relay.reconnectAttempts, nextReconnectTime: relay.nextReconnectTime) } } - var debugPendingMessageQueueCount: Int { + public var debugPendingMessageQueueCount: Int { messageQueueLock.lock() defer { messageQueueLock.unlock() } return messageQueue.count } - func debugPendingSubscriptionCount(for relayUrl: String) -> Int { + public func debugPendingSubscriptionCount(for relayUrl: String) -> Int { pendingSubscriptions[relayUrl]?.count ?? 0 } - func debugFlushMessageQueue() { + public func debugFlushMessageQueue() { flushMessageQueue(for: nil) } - + /// Reset all relay connections - func resetAllConnections() { + public func resetAllConnections() { disconnect() - // New generation begins now connectionGeneration &+= 1 - - // Reset all relay states + for index in relays.indices { relays[index].reconnectAttempts = 0 relays[index].nextReconnectTime = nil relays[index].lastError = nil } - - // Reconnect + connect() } - // MARK: - Failure classification private func isPermanentlyFailed(_ url: String) -> Bool { guard let r = relays.first(where: { $0.url == url }) else { return false } if r.reconnectAttempts >= maxReconnectAttempts { return true } @@ -879,7 +796,7 @@ private enum ParsedInbound { case ok(eventId: String, success: Bool, reason: String) case eose(subscriptionId: String) case notice(String) - + init?(_ message: URLSessionWebSocketTask.Message) { guard let data = message.data, let array = try? JSONSerialization.jsonObject(with: data) as? [Any], @@ -938,26 +855,26 @@ private extension URLSessionWebSocketTask.Message { // MARK: - Nostr Protocol Types -enum NostrRequest: Encodable { +public enum NostrRequest: Encodable { case event(NostrEvent) case subscribe(id: String, filters: [NostrFilter]) case close(id: String) - - func encode(to encoder: Encoder) throws { + + public func encode(to encoder: Encoder) throws { var container = encoder.unkeyedContainer() - + switch self { case .event(let event): try container.encode("EVENT") try container.encode(event) - + case .subscribe(let id, let filters): try container.encode("REQ") try container.encode(id) for filter in filters { try container.encode(filter) } - + case .close(let id): try container.encode("CLOSE") try container.encode(id) @@ -965,57 +882,51 @@ enum NostrRequest: Encodable { } } -struct NostrFilter: Encodable { - var ids: [String]? - var authors: [String]? - var kinds: [Int]? - var since: Int? - var until: Int? - var limit: Int? - - // Tag filters - stored internally but encoded specially +public struct NostrFilter: Encodable { + public var ids: [String]? + public var authors: [String]? + public var kinds: [Int]? + public var since: Int? + public var until: Int? + public var limit: Int? + fileprivate var tagFilters: [String: [String]]? - - init() { - // Default initializer - } - - // Custom encoding to handle tag filters properly + + public init() {} + enum CodingKeys: String, CodingKey { case ids, authors, kinds, since, until, limit } - - func encode(to encoder: Encoder) throws { + + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: DynamicCodingKey.self) - - // Encode standard fields + if let ids = ids { try container.encode(ids, forKey: DynamicCodingKey(stringValue: "ids")) } if let authors = authors { try container.encode(authors, forKey: DynamicCodingKey(stringValue: "authors")) } if let kinds = kinds { try container.encode(kinds, forKey: DynamicCodingKey(stringValue: "kinds")) } if let since = since { try container.encode(since, forKey: DynamicCodingKey(stringValue: "since")) } if let until = until { try container.encode(until, forKey: DynamicCodingKey(stringValue: "until")) } if let limit = limit { try container.encode(limit, forKey: DynamicCodingKey(stringValue: "limit")) } - - // Encode tag filters with # prefix + if let tagFilters = tagFilters { for (tag, values) in tagFilters { try container.encode(values, forKey: DynamicCodingKey(stringValue: "#\(tag)")) } } } - - // For NIP-17 gift wraps - static func giftWrapsFor(pubkey: String, since: Date? = nil) -> NostrFilter { + + // For NIP-17 gift wraps (limit matches TransportConfig.nostrRelayDefaultFetchLimit) + public static func giftWrapsFor(pubkey: String, since: Date? = nil) -> NostrFilter { var filter = NostrFilter() - filter.kinds = [1059] // Gift wrap kind + filter.kinds = [1059] filter.since = since?.timeIntervalSince1970.toInt() filter.tagFilters = ["p": [pubkey]] - filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit + filter.limit = 100 return filter } // For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001) - static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter { + public static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter { var filter = NostrFilter() filter.kinds = [20000, 20001] filter.since = since?.timeIntervalSince1970.toInt() @@ -1025,7 +936,7 @@ struct NostrFilter: Encodable { } // For location notes: persistent text notes (kind 1) tagged with geohash - static func geohashNotes(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter { + public static func geohashNotes(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter { var filter = NostrFilter() filter.kinds = [1] filter.since = since?.timeIntervalSince1970.toInt() @@ -1035,7 +946,7 @@ struct NostrFilter: Encodable { } // For location notes with neighbors: subscribe to multiple geohashes (center + neighbors) - static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter { + public static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter { var filter = NostrFilter() filter.kinds = [1] filter.since = since?.timeIntervalSince1970.toInt() @@ -1045,15 +956,14 @@ struct NostrFilter: Encodable { } } -// Dynamic coding key for tag filters private struct DynamicCodingKey: CodingKey { var stringValue: String var intValue: Int? { nil } - + init(stringValue: String) { self.stringValue = stringValue } - + init?(intValue: Int) { return nil } diff --git a/bitchat/Nostr/XChaCha20Poly1305Compat.swift b/localPackages/Nostr/Sources/Nostr/XChaCha20Poly1305Compat.swift similarity index 93% rename from bitchat/Nostr/XChaCha20Poly1305Compat.swift rename to localPackages/Nostr/Sources/Nostr/XChaCha20Poly1305Compat.swift index 13794508..244b4ad5 100644 --- a/bitchat/Nostr/XChaCha20Poly1305Compat.swift +++ b/localPackages/Nostr/Sources/Nostr/XChaCha20Poly1305Compat.swift @@ -6,7 +6,6 @@ import CryptoKit /// as per XChaCha20 construction. enum XChaCha20Poly1305Compat { - /// Errors that can occur during XChaCha20-Poly1305 operations enum Error: Swift.Error { case invalidKeyLength(expected: Int, got: Int) case invalidNonceLength(expected: Int, got: Int) @@ -59,7 +58,6 @@ enum XChaCha20Poly1305Compat { } private static func hchacha20(key: Data, nonce16: Data) throws -> Data { - // HChaCha20 based on the original ChaCha20 core with a 16-byte nonce. guard key.count == 32 else { throw Error.invalidKeyLength(expected: 32, got: key.count) } @@ -70,28 +68,22 @@ enum XChaCha20Poly1305Compat { // Constants "expand 32-byte k" var state: [UInt32] = [ 0x61707865, 0x3320646e, 0x79622d32, 0x6b206574, - // key (8 words) key.loadLEWord(0), key.loadLEWord(4), key.loadLEWord(8), key.loadLEWord(12), key.loadLEWord(16), key.loadLEWord(20), key.loadLEWord(24), key.loadLEWord(28), - // nonce (4 words) nonce16.loadLEWord(0), nonce16.loadLEWord(4), nonce16.loadLEWord(8), nonce16.loadLEWord(12) ] - // 20 rounds (10 double rounds) for _ in 0..<10 { - // Column rounds quarterRound(&state, 0, 4, 8, 12) quarterRound(&state, 1, 5, 9, 13) quarterRound(&state, 2, 6, 10, 14) quarterRound(&state, 3, 7, 11, 15) - // Diagonal rounds quarterRound(&state, 0, 5, 10, 15) quarterRound(&state, 1, 6, 11, 12) quarterRound(&state, 2, 7, 8, 13) quarterRound(&state, 3, 4, 9, 14) } - // Output subkey: state[0..3] and state[12..15] var out = Data(count: 32) out.storeLEWord(state[0], at: 0) out.storeLEWord(state[1], at: 4) @@ -132,4 +124,3 @@ private extension Data { replaceSubrange(offset..<(offset+4), with: bytes) } } - diff --git a/localPackages/Nostr/Tests/Bech32Tests.swift b/localPackages/Nostr/Tests/Bech32Tests.swift new file mode 100644 index 00000000..26c274d7 --- /dev/null +++ b/localPackages/Nostr/Tests/Bech32Tests.swift @@ -0,0 +1,41 @@ +import Testing +import Foundation +@testable import Nostr + +@Suite("Bech32") +struct Bech32Tests { + @Test("Round-trip encode/decode") + func roundTrip() throws { + let original = Data([0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe]) + let encoded = try Bech32.encode(hrp: "test", data: original) + let decoded = try Bech32.decode(encoded) + #expect(decoded.hrp == "test") + #expect(decoded.data == original) + } + + @Test("HRP is preserved") + func hrpPreserved() throws { + let data = Data(repeating: 0x42, count: 32) + let encoded = try Bech32.encode(hrp: "npub", data: data) + #expect(encoded.hasPrefix("npub1")) + let decoded = try Bech32.decode(encoded) + #expect(decoded.hrp == "npub") + } + + @Test("Decoding invalid checksum throws") + func invalidChecksum() throws { + var encoded = try Bech32.encode(hrp: "test", data: Data([1, 2, 3])) + // Corrupt the last character + encoded = String(encoded.dropLast()) + (encoded.last == "q" ? "p" : "q") + #expect(throws: Bech32.Bech32Error.self) { + try Bech32.decode(encoded) + } + } + + @Test("Decoding string without separator throws") + func missingSeparator() { + #expect(throws: Bech32.Bech32Error.self) { + try Bech32.decode("noseparatorhere") + } + } +} diff --git a/bitchatTests/XChaCha20Poly1305CompatTests.swift b/localPackages/Nostr/Tests/XChaCha20Poly1305CompatTests.swift similarity index 99% rename from bitchatTests/XChaCha20Poly1305CompatTests.swift rename to localPackages/Nostr/Tests/XChaCha20Poly1305CompatTests.swift index 9b607a9b..5130c77c 100644 --- a/bitchatTests/XChaCha20Poly1305CompatTests.swift +++ b/localPackages/Nostr/Tests/XChaCha20Poly1305CompatTests.swift @@ -8,7 +8,7 @@ import Testing import struct Foundation.Data -@testable import bitchat +@testable import Nostr struct XChaCha20Poly1305CompatTests {