diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index b511d975d..31fe3873f 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -679,7 +679,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = BitkitWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 195; + CURRENT_PROJECT_VERSION = 196; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BitkitWidget/Info.plist; @@ -691,7 +691,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.4.0; + MARKETING_VERSION = 2.4.1; PRODUCT_BUNDLE_IDENTIFIER = to.bitkit.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -712,7 +712,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = BitkitWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 195; + CURRENT_PROJECT_VERSION = 196; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BitkitWidget/Info.plist; @@ -724,7 +724,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.4.0; + MARKETING_VERSION = 2.4.1; PRODUCT_BUNDLE_IDENTIFIER = to.bitkit.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -744,7 +744,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = BitkitNotification/BitkitNotification.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 195; + CURRENT_PROJECT_VERSION = 196; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BitkitNotification/Info.plist; @@ -756,7 +756,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.4.0; + MARKETING_VERSION = 2.4.1; OTHER_LDFLAGS = ( "-framework", CoreBluetooth, @@ -776,7 +776,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = BitkitNotification/BitkitNotification.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 195; + CURRENT_PROJECT_VERSION = 196; DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BitkitNotification/Info.plist; @@ -788,7 +788,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.4.0; + MARKETING_VERSION = 2.4.1; OTHER_LDFLAGS = ( "-framework", CoreBluetooth, @@ -926,7 +926,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Bitkit/Bitkit.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 195; + CURRENT_PROJECT_VERSION = 196; DEVELOPMENT_ASSET_PATHS = "\"Bitkit/Preview Content\""; DEVELOPMENT_TEAM = KYH47R284B; ENABLE_HARDENED_RUNTIME = YES; @@ -953,7 +953,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 2.4.0; + MARKETING_VERSION = 2.4.1; OTHER_LDFLAGS = ( "-framework", CoreBluetooth, @@ -975,7 +975,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Bitkit/Bitkit.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 195; + CURRENT_PROJECT_VERSION = 196; DEVELOPMENT_ASSET_PATHS = "\"Bitkit/Preview Content\""; DEVELOPMENT_TEAM = KYH47R284B; ENABLE_HARDENED_RUNTIME = YES; @@ -1002,7 +1002,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 2.4.0; + MARKETING_VERSION = 2.4.1; OTHER_LDFLAGS = ( "-framework", CoreBluetooth, @@ -1176,8 +1176,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/vss-rust-client-ffi"; requirement = { - branch = master; - kind = branch; + kind = exactVersion; + version = 0.5.23; }; }; 4AAB08C82E1FE77600BA63DF /* XCRemoteSwiftPackageReference "lottie-ios" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 861c8f81b..4ce28f5d9 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/vss-rust-client-ffi", "state" : { - "branch" : "master", - "revision" : "a004f6ed0fa9f02c52f43bd4bb9bf6217f2be0ed" + "revision" : "d7cbc6237a83e50a55b3c93b8837b6f18134065c", + "version" : "0.5.23" } } ], diff --git a/Bitkit/Components/ShopWebView.swift b/Bitkit/Components/ShopWebView.swift index 6e8d590eb..5adbfc2c5 100644 --- a/Bitkit/Components/ShopWebView.swift +++ b/Bitkit/Components/ShopWebView.swift @@ -6,11 +6,18 @@ struct ShopWebView: UIViewRepresentable { let url: String var webView: Binding? var onMessage: ((String) -> Void)? - - init(url: String, webView: Binding? = nil, onMessage: ((String) -> Void)? = nil) { + var onBlockedNavigation: (() -> Void)? + + init( + url: String, + webView: Binding? = nil, + onMessage: ((String) -> Void)? = nil, + onBlockedNavigation: (() -> Void)? = nil + ) { self.url = url self.webView = webView self.onMessage = onMessage + self.onBlockedNavigation = onBlockedNavigation } func makeCoordinator() -> Coordinator { @@ -55,9 +62,22 @@ struct ShopWebView: UIViewRepresentable { } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - if message.name == "messageHandler", let body = message.body as? String { - parent.onMessage?(body) + guard message.name == "messageHandler", let body = message.body as? String else { return } + let frameInfo = message.frameInfo + let securityOrigin = frameInfo.securityOrigin + guard ShopOrigin.isAllowedMessageSender( + isMainFrame: frameInfo.isMainFrame, + scheme: securityOrigin.protocol, + host: securityOrigin.host, + port: securityOrigin.port + ) else { + Logger.warn( + "Rejected shop payment_intent from untrusted sender '\(securityOrigin.protocol)://\(securityOrigin.host):\(securityOrigin.port)'", + context: "ShopWebView" + ) + return } + parent.onMessage?(body) } func webView( @@ -65,18 +85,29 @@ struct ShopWebView: UIViewRepresentable { decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { - decisionHandler(.allow) + if navigationAction.targetFrame?.isMainFrame == false { + decisionHandler(.allow) + return + } + if ShopOrigin.shouldAllowMainFrameNavigation( + to: navigationAction.request.url, + initialUrl: parent.url + ) { + decisionHandler(.allow) + return + } + Logger.warn( + "Blocked shop navigation to untrusted origin '\(navigationAction.request.url?.absoluteString ?? "")'", + context: "ShopWebView" + ) + parent.onBlockedNavigation?() + decisionHandler(.cancel) } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { // Inject JavaScript to capture postMessage events if message handler is configured if parent.onMessage != nil { - let script = """ - window.addEventListener('message', function(event) { - window.webkit.messageHandlers.messageHandler.postMessage(JSON.stringify(event.data)); - }); - """ - webView.evaluateJavaScript(script) + webView.evaluateJavaScript(ShopOrigin.messageBridgeScript) } } @@ -86,9 +117,19 @@ struct ShopWebView: UIViewRepresentable { for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures ) -> WKWebView? { - // Load the navigation request in the current WebView instead of opening a new window + guard ShopOrigin.shouldAllowMainFrameNavigation( + to: navigationAction.request.url, + initialUrl: parent.url + ) else { + Logger.warn( + "Blocked shop window navigation to untrusted origin '\(navigationAction.request.url?.absoluteString ?? "")'", + context: "ShopWebView" + ) + parent.onBlockedNavigation?() + return nil + } webView.load(navigationAction.request) - return nil // Return nil to use the current WebView + return nil } } } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 495618475..34a51d705 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -532,6 +532,7 @@ "other__shop__discover__travel__title" = "Travel"; "other__shop__discover__travel__description" = "Book your ₿ holiday"; "other__shop__main__nav_title" = "Shop"; +"other__shop__external_link_blocked" = "This link can’t be opened from the shop."; "security__backup_wallet" = "Wallet Backup"; "security__backup_title" = "Safely store your Bitcoin"; "security__backup_funds" = "Now that you have some funds in your wallet, it is time to back up your money!"; diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 936b28857..365a84e58 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -74,6 +74,13 @@ class LightningService { Logger.debug("Checking lightning process lock...") try await StateLocker.lock(.lightning, wait: 30) // Wait 30 seconds to lock because maybe extension is still running + // Unlock if setup fails before a node exists. After a successful build the node keeps the lock until stop. + var shouldReleaseLightningLock = true + defer { + if shouldReleaseLightningLock { + try? StateLocker.unlock(.lightning) + } + } guard var mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { throw CustomServiceError.mnemonicNotFound @@ -136,7 +143,7 @@ class LightningService { let storeId = try await VssStoreIdProvider.shared.getVssStoreId(walletIndex: walletIndex) let vssUrl = Env.vssServerUrl - let lnurlAuthServerUrl = Env.lnurlAuthServerUrl + let lnurlAuthServerUrl = Env.lnurlAuthServerUrl.trimmingCharacters(in: .whitespacesAndNewlines) Logger.debug("Building ldk-node with vssUrl: '\(vssUrl)'") Logger.debug("Building ldk-node with lnurlAuthServerUrl: '\(lnurlAuthServerUrl)'") @@ -147,49 +154,19 @@ class LightningService { builder.setEntropyBip39Mnemonic(mnemonic: mnemonic, passphrase: passphrase) - try await ServiceQueue.background(.ldk) { - do { - if !lnurlAuthServerUrl.isEmpty { - self.node = try builder.buildWithVssStore( - vssUrl: vssUrl, - storeId: storeId, - lnurlAuthServerUrl: lnurlAuthServerUrl, - fixedHeaders: [:] - ) - } else { - self.node = try builder.buildWithVssStoreAndFixedHeaders( - vssUrl: vssUrl, - storeId: storeId, - fixedHeaders: [:] - ) - } - } catch let error as BuildError { - guard case .DangerousValue = error else { throw error } + guard !lnurlAuthServerUrl.isEmpty else { + throw CustomServiceError.vssAuthRequired + } - // Stale ChannelMonitor vs ChannelManager — retry with accept_stale to recover. - Logger.warn( - "Build failed with DangerousValue. Retrying with accept_stale_channel_monitors for recovery.", - context: "Recovery" - ) - builder.setAcceptStaleChannelMonitors(accept: true) - - if !lnurlAuthServerUrl.isEmpty { - self.node = try builder.buildWithVssStore( - vssUrl: vssUrl, - storeId: storeId, - lnurlAuthServerUrl: lnurlAuthServerUrl, - fixedHeaders: [:] - ) - } else { - self.node = try builder.buildWithVssStoreAndFixedHeaders( - vssUrl: vssUrl, - storeId: storeId, - fixedHeaders: [:] - ) - } - Logger.info("Stale monitor recovery: build succeeded with accept_stale", context: "Recovery") - } + try await ServiceQueue.background(.ldk) { + self.node = try builder.buildWithVssStore( + vssUrl: vssUrl, + storeId: storeId, + lnurlAuthServerUrl: lnurlAuthServerUrl, + fixedHeaders: [:] + ) } + shouldReleaseLightningLock = false Logger.info("LDK node setup") diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index 0a248d858..1872e186a 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -291,7 +291,7 @@ enum RNKeychainKey { // MARK: - Channel Migration Data -struct PendingChannelMigration: Codable { +struct PendingChannelMigration: Codable, Equatable { let channelManager: Data let channelMonitors: [Data] } @@ -401,6 +401,17 @@ class MigrationsService: ObservableObject { set { setCodable(newValue, forKey: Self.rnPendingChannelMigrationKey) } } + func withPendingChannelMigration( + _ operation: (PendingChannelMigration?) async throws -> Void + ) async rethrows { + let migration = pendingChannelMigration + try await operation(migration) + + if pendingChannelMigration == migration { + pendingChannelMigration = nil + } + } + /// Stored activity data from RN remote backup for reapplying metadata after sync (persisted) var pendingRemoteActivityData: [RNActivityItem]? { get { getCodable(forKey: Self.rnPendingRemoteActivityDataKey) } diff --git a/Bitkit/Services/VssBackupClient.swift b/Bitkit/Services/VssBackupClient.swift index 7ef20e48b..a99ddc0dc 100644 --- a/Bitkit/Services/VssBackupClient.swift +++ b/Bitkit/Services/VssBackupClient.swift @@ -65,11 +65,11 @@ class VssBackupClient { await ldkSetupCoordinator.reset() } - /// Returns lnurl auth params when lnurl is configured; nil otherwise. + /// Returns LNURL-auth params when configured. Callers fail closed if this returns nil. private func getLnurlAuthParams(walletIndex: Int) async throws -> (vssUrl: String, storeId: String, mnemonic: String, passphrase: String?, lnurlAuthServerUrl: String)? { - let lnurlAuthServerUrl = Env.lnurlAuthServerUrl + let lnurlAuthServerUrl = Env.lnurlAuthServerUrl.trimmingCharacters(in: .whitespacesAndNewlines) guard !lnurlAuthServerUrl.isEmpty else { return nil } guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { throw CustomServiceError.mnemonicNotFound @@ -87,18 +87,16 @@ class VssBackupClient { let vssUrl = Env.vssServerUrl Logger.debug("Building VSS client with vssUrl: '\(vssUrl)'", context: "VssBackupClient") - if let params = try await getLnurlAuthParams(walletIndex: walletIndex) { - try await vssNewClientWithLnurlAuth( - baseUrl: params.vssUrl, - storeId: params.storeId, - mnemonic: params.mnemonic, - passphrase: params.passphrase, - lnurlAuthServerUrl: params.lnurlAuthServerUrl - ) - } else { - let storeId = try await VssStoreIdProvider.shared.getVssStoreId(walletIndex: walletIndex) - try await vssNewClient(baseUrl: vssUrl, storeId: storeId) + guard let params = try await getLnurlAuthParams(walletIndex: walletIndex) else { + throw CustomServiceError.vssAuthRequired } + try await vssNewClientWithLnurlAuth( + baseUrl: params.vssUrl, + storeId: params.storeId, + mnemonic: params.mnemonic, + passphrase: params.passphrase, + lnurlAuthServerUrl: params.lnurlAuthServerUrl + ) Logger.info("VSS client setup with server: '\(vssUrl)'", context: "VssBackupClient") } } catch { @@ -110,7 +108,7 @@ class VssBackupClient { /// Lazily initializes the LDK VSS client (used only by the debug screen). Only runs when lnurl auth is configured. private func setupLdk(walletIndex: Int = 0) async throws { guard let params = try await getLnurlAuthParams(walletIndex: walletIndex) else { - throw AppError(message: "LDK VSS requires lnurl auth", debugMessage: "lnurlAuthServerUrl is not set") + throw CustomServiceError.vssAuthRequired } do { try await withTimeout(seconds: 30) { diff --git a/Bitkit/Utilities/Errors.swift b/Bitkit/Utilities/Errors.swift index 5c0ecab37..83d1cd680 100644 --- a/Bitkit/Utilities/Errors.swift +++ b/Bitkit/Utilities/Errors.swift @@ -6,6 +6,7 @@ enum CustomServiceError: LocalizedError { case nodeNotStarted case onchainWalletNotInitialized case mnemonicNotFound + case vssAuthRequired case nodeStillRunning case onchainWalletStillRunning case invalidNodeSigningMessage @@ -23,6 +24,8 @@ enum CustomServiceError: LocalizedError { return "Onchain wallet not created" case .mnemonicNotFound: return "Mnemonic not found" + case .vssAuthRequired: + return "VSS requires LNURL-auth" case .nodeStillRunning: return "Node is still running" case .onchainWalletStillRunning: @@ -130,6 +133,9 @@ struct AppError: LocalizedError { case .mnemonicNotFound: message = "Mnemonic not found" debugMessage = nil + case .vssAuthRequired: + message = "VSS requires LNURL-auth" + debugMessage = nil case .nodeStillRunning: message = "Node is still running" debugMessage = nil diff --git a/Bitkit/Utilities/ShopOrigin.swift b/Bitkit/Utilities/ShopOrigin.swift new file mode 100644 index 000000000..b638d827e --- /dev/null +++ b/Bitkit/Utilities/ShopOrigin.swift @@ -0,0 +1,53 @@ +import Foundation + +enum ShopOrigin { + static let rootHost = "bitrefill.com" + static let paymentOrigin = "https://embed.bitrefill.com" + private static let defaultHttpsPort = 443 + + static func isAllowedHost(_ host: String?) -> Bool { + guard var host = host?.lowercased() else { return false } + host = host.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return host == rootHost || host.hasSuffix(".\(rootHost)") + } + + static func isAllowed(_ url: URL?) -> Bool { + guard let url else { return false } + guard url.scheme?.lowercased() == "https" else { return false } + return isAllowedHost(url.host) + } + + static func isAllowedMessageSender(isMainFrame: Bool, scheme: String, host: String, port: Int) -> Bool { + guard let expectedOrigin = URL(string: paymentOrigin), + let expectedScheme = expectedOrigin.scheme, + let expectedHost = expectedOrigin.host + else { + return false + } + return isMainFrame + && scheme.lowercased() == expectedScheme + && host.lowercased() == expectedHost + && (port == 0 || port == defaultHttpsPort) + } + + static func shouldRestrictNavigation(initialUrl: String) -> Bool { + isAllowed(URL(string: initialUrl)) + } + + static func shouldAllowMainFrameNavigation(to url: URL?, initialUrl: String) -> Bool { + guard shouldRestrictNavigation(initialUrl: initialUrl) else { return true } + return isAllowed(url) + } + + static var messageBridgeScript: String { + """ + if (!window.__bitkitShopBridgeInstalled) { + window.__bitkitShopBridgeInstalled = true; + window.addEventListener('message', function(event) { + if (event.origin !== '\(paymentOrigin)') return; + window.webkit.messageHandlers.messageHandler.postMessage(JSON.stringify(event.data)); + }); + } + """ + } +} diff --git a/Bitkit/Utilities/ShopPaymentRequest.swift b/Bitkit/Utilities/ShopPaymentRequest.swift new file mode 100644 index 000000000..37411fbd2 --- /dev/null +++ b/Bitkit/Utilities/ShopPaymentRequest.swift @@ -0,0 +1,26 @@ +import BitkitCore +import Foundation + +enum ScanHandlingScope { + case unrestricted + case paymentRequests +} + +enum ShopPaymentRequest { + static func isSupported(_ data: BitkitCore.Scanner) -> Bool { + switch data { + case .onChain, .lightning, .lnurlPay: + return true + default: + return false + } + } +} + +enum ShopPaymentRequestError: LocalizedError { + case unsupportedRequest + + var errorDescription: String? { + t("other__scan__error__generic") + } +} diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 5598341ce..f36dda914 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -369,24 +369,49 @@ extension AppViewModel { // MARK: Scanning/pasting handling extension AppViewModel { - func handleScannedData(_ uri: String) async throws { + func handleScannedData( + _ uri: String, + scope: ScanHandlingScope = .unrestricted + ) async throws { + let uri = uri.removingLightningSchemes() + let prevalidatedPaymentRequest: BitkitCore.Scanner? + if scope == .paymentRequests { + guard SamRockSetupRequest.parse(uri) == nil, + !SamRockSetupRequest.isProtocolURL(uri) + else { + throw ShopPaymentRequestError.unsupportedRequest + } + if Bip21Utils.isDuplicatedBip21(uri) { + toast( + type: .error, + title: t("other__scan_err_decoding"), + description: t("other__scan__error__generic"), + accessibilityIdentifier: "InvalidAddressToast" + ) + return + } + let data = try await decode(invoice: uri) + guard ShopPaymentRequest.isSupported(data) else { throw ShopPaymentRequestError.unsupportedRequest } + prevalidatedPaymentRequest = data + } else { + prevalidatedPaymentRequest = nil + } + // Reset send state before handling new data resetSendState() - let uri = uri.removingLightningSchemes() - - if let samRockSetup = SamRockSetupRequest.parse(uri) { + if scope == .unrestricted, let samRockSetup = SamRockSetupRequest.parse(uri) { handleBTCPayConnection(samRockSetup) return } - if SamRockSetupRequest.isProtocolURL(uri) { + if scope == .unrestricted, SamRockSetupRequest.isProtocolURL(uri) { handleInvalidBTCPayConnection(uri) return } // Workaround for duplicated BIP21 URIs (bitkit-core#63) - if Bip21Utils.isDuplicatedBip21(uri) { + if scope == .unrestricted, Bip21Utils.isDuplicatedBip21(uri) { toast( type: .error, title: t("other__scan_err_decoding"), @@ -396,7 +421,11 @@ extension AppViewModel { return } - let data = try await decode(invoice: uri) + let data: BitkitCore.Scanner = if let prevalidatedPaymentRequest { + prevalidatedPaymentRequest + } else { + try await decode(invoice: uri) + } switch data { // BIP21 (Unified) invoice handling diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 5475460e9..e90d9da86 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -156,23 +156,23 @@ class WalletViewModel: ObservableObject { let electrumServerUrl = electrumConfigService.getCurrentServer().fullUrl let rgsServerUrl = rgsConfigService.getCurrentServerUrl() - var channelMigration: ChannelDataMigration? - if let migration = MigrationsService.shared.pendingChannelMigration { - channelMigration = ChannelDataMigration( - channelManager: [UInt8](migration.channelManager), - channelMonitors: migration.channelMonitors.map { [UInt8]($0) } - ) - MigrationsService.shared.pendingChannelMigration = nil - } + try await MigrationsService.shared.withPendingChannelMigration { migration in + let channelMigration = migration.map { + ChannelDataMigration( + channelManager: [UInt8]($0.channelManager), + channelMonitors: $0.channelMonitors.map { [UInt8]($0) } + ) + } - await runLegacyNetworkGraphCleanupIfNeeded() + await runLegacyNetworkGraphCleanupIfNeeded() - try await lightningService.setup( - walletIndex: walletIndex, - electrumServerUrl: electrumServerUrl, - rgsServerUrl: rgsServerUrl.isEmpty ? nil : rgsServerUrl, - channelMigration: channelMigration - ) + try await lightningService.setup( + walletIndex: walletIndex, + electrumServerUrl: electrumServerUrl, + rgsServerUrl: rgsServerUrl.isEmpty ? nil : rgsServerUrl, + channelMigration: channelMigration + ) + } try await lightningService.start(onEvent: { event in Task { @MainActor in // Notify all event handlers diff --git a/Bitkit/Views/Shop/ShopMain.swift b/Bitkit/Views/Shop/ShopMain.swift index 38c4cc73a..5c4ac4fe3 100644 --- a/Bitkit/Views/Shop/ShopMain.swift +++ b/Bitkit/Views/Shop/ShopMain.swift @@ -13,24 +13,31 @@ struct ShopMain: View { let navTitle = t("other__shop__main__nav_title") private var uri: String { - let baseUrl = "https://embed.bitrefill.com" let paymentMethod = "bitcoin" // Payment method "bitcoin" gives a unified invoice let params = "?ref=\(Env.bitrefillRef)&paymentMethod=\(paymentMethod)&theme=dark&utm_source=\(Env.appName)" - return "\(baseUrl)/\(page)/\(params)" + return "\(ShopOrigin.paymentOrigin)/\(page)/\(params)" } var body: some View { VStack(spacing: 0) { NavigationBar(title: navTitle) - ShopWebView(url: uri, onMessage: handleMessage) - .padding(.top, 16) + ShopWebView( + url: uri, + onMessage: handleMessage, + onBlockedNavigation: handleBlockedNavigation + ) + .padding(.top, 16) } .navigationBarHidden(true) .padding(.horizontal, 16) .offlineOverlay(title: navTitle) } + private func handleBlockedNavigation() { + app.toast(type: .warning, title: navTitle, description: t("other__shop__external_link_blocked")) + } + private func handleMessage(_ message: String) { // Parse the message as a JSON-encoded string guard let messageData = message.data(using: .utf8), @@ -39,14 +46,15 @@ struct ShopMain: View { let json = try? JSONSerialization.jsonObject(with: innerData) as? [String: Any], let event = json["event"] as? String, event == "payment_intent", - let paymentUri = json["paymentUri"] as? String + let paymentUri = (json["paymentUri"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !paymentUri.isEmpty else { return } Task { @MainActor in do { - try await app.handleScannedData(paymentUri) + try await app.handleScannedData(paymentUri, scope: .paymentRequests) PaymentNavigationHelper.openPaymentSheet( app: app, diff --git a/BitkitTests/ChannelMigrationPersistenceTests.swift b/BitkitTests/ChannelMigrationPersistenceTests.swift new file mode 100644 index 000000000..8b262d44b --- /dev/null +++ b/BitkitTests/ChannelMigrationPersistenceTests.swift @@ -0,0 +1,69 @@ +@testable import Bitkit +import XCTest + +final class ChannelMigrationPersistenceTests: XCTestCase { + private enum SetupError: Error { + case failed + } + + private let migrations = MigrationsService.shared + + override func setUp() { + super.setUp() + migrations.pendingChannelMigration = nil + } + + override func tearDown() { + migrations.pendingChannelMigration = nil + super.tearDown() + } + + func testPendingMigrationIsRetainedWhenSetupFails() async { + let migration = makeMigration(seed: 1) + migrations.pendingChannelMigration = migration + + do { + try await migrations.withPendingChannelMigration { pendingMigration in + XCTAssertEqual(pendingMigration, migration) + throw SetupError.failed + } + XCTFail("Expected setup to fail") + } catch SetupError.failed { + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(migrations.pendingChannelMigration, migration) + } + + func testPendingMigrationIsClearedAfterSetupSucceeds() async { + let migration = makeMigration(seed: 2) + migrations.pendingChannelMigration = migration + + await migrations.withPendingChannelMigration { pendingMigration in + XCTAssertEqual(pendingMigration, migration) + } + + XCTAssertNil(migrations.pendingChannelMigration) + } + + func testNewPendingMigrationIsNotClearedAfterSetupSucceeds() async { + let migration = makeMigration(seed: 3) + let replacement = makeMigration(seed: 4) + migrations.pendingChannelMigration = migration + + await migrations.withPendingChannelMigration { pendingMigration in + XCTAssertEqual(pendingMigration, migration) + migrations.pendingChannelMigration = replacement + } + + XCTAssertEqual(migrations.pendingChannelMigration, replacement) + } + + private func makeMigration(seed: UInt8) -> PendingChannelMigration { + PendingChannelMigration( + channelManager: Data([seed]), + channelMonitors: [Data([seed, seed &+ 1])] + ) + } +} diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift new file mode 100644 index 000000000..16dd13d44 --- /dev/null +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -0,0 +1,88 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +@MainActor +final class PaymentNavigationHelperTests: XCTestCase { + private let settings = SettingsViewModel.shared + private var originalEnableQuickpay = false + private var originalQuickpayAmount: Double = 0 + private var originalPinEnabled = false + private var originalRequirePinForPayments = false + private var originalCachedRates: Data? + + override func setUp() { + super.setUp() + originalEnableQuickpay = settings.enableQuickpay + originalQuickpayAmount = settings.quickpayAmount + originalPinEnabled = settings.pinEnabled + originalRequirePinForPayments = settings.requirePinForPayments + originalCachedRates = UserDefaults.standard.data(forKey: "cached_fx_rates") + + settings.enableQuickpay = true + settings.quickpayAmount = 5 + guard let encodedRates = try? JSONEncoder().encode([usdRate]) else { + XCTFail("Failed to encode the QuickPay test exchange rate") + return + } + UserDefaults.standard.set(encodedRates, forKey: "cached_fx_rates") + } + + override func tearDown() { + settings.enableQuickpay = originalEnableQuickpay + settings.quickpayAmount = originalQuickpayAmount + settings.pinEnabled = originalPinEnabled + settings.requirePinForPayments = originalRequirePinForPayments + + if let originalCachedRates { + UserDefaults.standard.set(originalCachedRates, forKey: "cached_fx_rates") + } else { + UserDefaults.standard.removeObject(forKey: "cached_fx_rates") + } + super.tearDown() + } + + func testPaymentPinDoesNotChangeEligibleQuickpayRoute() { + settings.pinEnabled = true + settings.requirePinForPayments = true + + XCTAssertEqual( + PaymentNavigationHelper.appropriateSendRoute( + app: appWithEligibleInvoice, + currency: CurrencyViewModel(), + settings: settings + ), + .quickpay + ) + } + + private var appWithEligibleInvoice: AppViewModel { + let app = AppViewModel() + app.scannedLightningInvoice = LightningInvoice( + bolt11: "test-invoice", + paymentHash: Data(), + amountSatoshis: 1000, + timestampSeconds: 0, + expirySeconds: 0, + isExpired: false, + description: nil, + networkType: .regtest, + payeeNodeId: nil + ) + return app + } + + private var usdRate: FxRate { + FxRate( + symbol: "BTCUSD", + lastPrice: "100000", + base: "BTC", + baseName: "Bitcoin", + quote: "USD", + quoteName: "US Dollar", + currencySymbol: "$", + currencyFlag: "🇺🇸", + lastUpdatedAt: 0 + ) + } +} diff --git a/BitkitTests/ShopOriginTests.swift b/BitkitTests/ShopOriginTests.swift new file mode 100644 index 000000000..9eb944790 --- /dev/null +++ b/BitkitTests/ShopOriginTests.swift @@ -0,0 +1,118 @@ +@testable import Bitkit +import XCTest + +final class ShopOriginTests: XCTestCase { + func testHttpsBitrefillHostsAreAllowed() { + XCTAssertTrue(ShopOrigin.isAllowed(URL(string: "https://embed.bitrefill.com"))) + XCTAssertTrue(ShopOrigin.isAllowed(URL(string: "https://embed.bitrefill.com/gift-cards"))) + XCTAssertTrue(ShopOrigin.isAllowed(URL(string: "https://bitrefill.com"))) + XCTAssertTrue(ShopOrigin.isAllowed(URL(string: "https://www.bitrefill.com/esims"))) + XCTAssertTrue(ShopOrigin.isAllowedHost("embed.bitrefill.com")) + XCTAssertTrue(ShopOrigin.isAllowedHost("BITREFILL.COM")) + } + + func testNonBitrefillAndNonHttpsOriginsAreRejected() { + XCTAssertFalse(ShopOrigin.isAllowed(nil as URL?)) + XCTAssertFalse(ShopOrigin.isAllowed(URL(string: "https://evil.example"))) + XCTAssertFalse(ShopOrigin.isAllowed(URL(string: "https://bitrefill.com.evil.example"))) + XCTAssertFalse(ShopOrigin.isAllowed(URL(string: "https://notbitrefill.com"))) + XCTAssertFalse(ShopOrigin.isAllowed(URL(string: "http://embed.bitrefill.com"))) + XCTAssertFalse(ShopOrigin.isAllowed(URL(string: "javascript:alert(1)"))) + XCTAssertFalse(ShopOrigin.isAllowedHost("evil.example")) + XCTAssertFalse(ShopOrigin.isAllowedHost(nil)) + } + + func testBridgeScriptChecksMessageOrigin() { + let script = ShopOrigin.messageBridgeScript + XCTAssertTrue(script.contains("if (!window.__bitkitShopBridgeInstalled)")) + XCTAssertTrue(script.contains("addEventListener('message'")) + XCTAssertTrue(script.contains("event.origin !== 'https://embed.bitrefill.com'")) + XCTAssertFalse(script.contains("window.postMessage =")) + } + + func testMessageSenderRequiresExactPaymentOriginInMainFrame() { + XCTAssertTrue( + ShopOrigin.isAllowedMessageSender( + isMainFrame: true, + scheme: "https", + host: "embed.bitrefill.com", + port: 0 + ) + ) + XCTAssertTrue( + ShopOrigin.isAllowedMessageSender( + isMainFrame: true, + scheme: "HTTPS", + host: "EMBED.BITREFILL.COM", + port: 443 + ) + ) + XCTAssertFalse( + ShopOrigin.isAllowedMessageSender( + isMainFrame: false, + scheme: "https", + host: "embed.bitrefill.com", + port: 0 + ) + ) + XCTAssertFalse( + ShopOrigin.isAllowedMessageSender( + isMainFrame: true, + scheme: "http", + host: "embed.bitrefill.com", + port: 0 + ) + ) + XCTAssertFalse( + ShopOrigin.isAllowedMessageSender( + isMainFrame: true, + scheme: "https", + host: "www.bitrefill.com", + port: 0 + ) + ) + XCTAssertFalse( + ShopOrigin.isAllowedMessageSender( + isMainFrame: true, + scheme: "https", + host: "embed.bitrefill.com", + port: 8443 + ) + ) + } + + func testBitrefillCheckoutRestrictsMainFrameNavigation() { + let checkout = "https://embed.bitrefill.com/gift-cards" + XCTAssertTrue(ShopOrigin.shouldRestrictNavigation(initialUrl: checkout)) + XCTAssertTrue( + ShopOrigin.shouldAllowMainFrameNavigation( + to: URL(string: "https://www.bitrefill.com/esims"), + initialUrl: checkout + ) + ) + XCTAssertFalse( + ShopOrigin.shouldAllowMainFrameNavigation( + to: URL(string: "https://evil.example"), + initialUrl: checkout + ) + ) + XCTAssertFalse( + ShopOrigin.shouldAllowMainFrameNavigation( + to: URL(string: "https://btcmap.org/map"), + initialUrl: checkout + ) + ) + } + + func testBtcMapDiscoverAllowsNonBitrefillMainFrame() { + let map = "https://btcmap.org/map" + XCTAssertFalse(ShopOrigin.shouldRestrictNavigation(initialUrl: map)) + XCTAssertTrue(ShopOrigin.shouldAllowMainFrameNavigation(to: URL(string: map), initialUrl: map)) + XCTAssertTrue( + ShopOrigin.shouldAllowMainFrameNavigation( + to: URL(string: "https://btcmap.org/merchant/123"), + initialUrl: map + ) + ) + } +} diff --git a/BitkitTests/ShopPaymentRequestTests.swift b/BitkitTests/ShopPaymentRequestTests.swift new file mode 100644 index 000000000..b2f17d3b5 --- /dev/null +++ b/BitkitTests/ShopPaymentRequestTests.swift @@ -0,0 +1,46 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +@MainActor +final class ShopPaymentRequestTests: XCTestCase { + func testLightningInvoiceIsSupported() { + XCTAssertTrue(ShopPaymentRequest.isSupported(.lightning(invoice: lightningInvoice))) + } + + func testNonPaymentScannerDataIsRejected() { + XCTAssertFalse(ShopPaymentRequest.isSupported(.gift(code: "gift-code", amount: 1000))) + XCTAssertFalse(ShopPaymentRequest.isSupported(.pubkyAuth(data: "pubkyauth://example"))) + } + + func testNonPaymentRequestDoesNotClearExistingPaymentState() async { + let app = AppViewModel() + app.scannedLightningInvoice = lightningInvoice + + do { + try await app.handleScannedData( + "https://btcpay.example/plugins/store123/samrock/protocol?setup=btc-chain&otp=abc123", + scope: .paymentRequests + ) + XCTFail("Expected the shop payment scope to reject a setup request") + } catch { + XCTAssertTrue(error is ShopPaymentRequestError) + } + + XCTAssertNotNil(app.scannedLightningInvoice) + } + + private var lightningInvoice: LightningInvoice { + LightningInvoice( + bolt11: "test-invoice", + paymentHash: Data(), + amountSatoshis: 1000, + timestampSeconds: 0, + expirySeconds: 0, + isExpired: false, + description: nil, + networkType: .regtest, + payeeNodeId: nil + ) + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7920f8df7..d30f7b402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.4.1] - 2026-08-21 + +### Security +- Lightning no longer automatically starts from outdated channel monitor data after a storage mismatch. #664 +- Wallet backups no longer fall back to unauthenticated VSS when LNURL-auth is missing. #665 +- Shop checkout now accepts only payment requests from the trusted Bitrefill embed origin. #668 +- Wallet backups now use VSS 0.5.23, which rejects unauthenticated encryption. #673 + ## [2.4.0] - 2026-07-16 ### Added @@ -99,7 +107,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix keyboard and UI issues in the calculator widget #513 - Preserve msat precision for LNURL pay, withdraw callbacks and bolt11 #512 -[Unreleased]: https://github.com/synonymdev/bitkit-ios/compare/v2.4.0...HEAD +[Unreleased]: https://github.com/synonymdev/bitkit-ios/compare/v2.4.1...HEAD +[2.4.1]: https://github.com/synonymdev/bitkit-ios/compare/v2.4.0...v2.4.1 [2.4.0]: https://github.com/synonymdev/bitkit-ios/compare/v2.3.2...v2.4.0 [2.3.2]: https://github.com/synonymdev/bitkit-ios/compare/v2.3.1...v2.3.2 [2.3.1]: https://github.com/synonymdev/bitkit-ios/compare/v2.3.0...v2.3.1