diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index cb2b02034a..e406e883fe 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -93,6 +93,9 @@ AFCE353927E5DE0500FEA6C2 /* Shareable.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFCE353827E5DE0400FEA6C2 /* Shareable.swift */; }; D575039F27146F93008DC9DC /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A0D1342591FBC5008F8A13 /* String+Extension.swift */; }; D5B6AA7827200C7200D49C24 /* NCActivityTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B6AA7727200C7200D49C24 /* NCActivityTableViewCell.swift */; }; + F30E77E92EAB716900B1EFAB /* CertificatePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F30E77E82EAB716900B1EFAB /* CertificatePicker.swift */; }; + F30E77EC2EAB7C9B00B1EFAB /* DocumentPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F30E77EB2EAB7C9800B1EFAB /* DocumentPicker.swift */; }; + F30E77EF2EAF9BCD00B1EFAB /* CertificatePickerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F30E77EE2EAF9BC700B1EFAB /* CertificatePickerModel.swift */; }; F310B1EF2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = F310B1EE2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift */; }; F314F1142A30E2DE00BC7FAB /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7E8A390295DC5E0006CB2D0 /* View+Extension.swift */; }; F321DA8A2B71205A00DDA0E6 /* NCTrashSelectTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F321DA892B71205A00DDA0E6 /* NCTrashSelectTabBar.swift */; }; @@ -1380,6 +1383,9 @@ C0046CDA2A17B98400D87C9D /* NextcloudUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NextcloudUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C04E2F202A17BB4D001BAD85 /* NextcloudIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NextcloudIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D5B6AA7727200C7200D49C24 /* NCActivityTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCActivityTableViewCell.swift; sourceTree = ""; }; + F30E77E82EAB716900B1EFAB /* CertificatePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificatePicker.swift; sourceTree = ""; }; + F30E77EB2EAB7C9800B1EFAB /* DocumentPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentPicker.swift; sourceTree = ""; }; + F30E77EE2EAF9BC700B1EFAB /* CertificatePickerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificatePickerModel.swift; sourceTree = ""; }; F310B1EE2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCViewerMedia+VisionKit.swift"; sourceTree = ""; }; F321DA892B71205A00DDA0E6 /* NCTrashSelectTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCTrashSelectTabBar.swift; sourceTree = ""; }; F32FADA82D1176DE007035E2 /* UIButton+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIButton+Extension.swift"; sourceTree = ""; }; @@ -2257,6 +2263,16 @@ path = Tests; sourceTree = ""; }; + F30E77EA2EAB7C1700B1EFAB /* CertificatePicker */ = { + isa = PBXGroup; + children = ( + F30E77EE2EAF9BC700B1EFAB /* CertificatePickerModel.swift */, + F30E77EB2EAB7C9800B1EFAB /* DocumentPicker.swift */, + F30E77E82EAB716900B1EFAB /* CertificatePicker.swift */, + ); + path = CertificatePicker; + sourceTree = ""; + }; F3374A7F2D64AB40002A38F9 /* Components */ = { isa = PBXGroup; children = ( @@ -3393,6 +3409,7 @@ F7725A5D251F33BB00D125E0 /* Files */, F757CC8929E82D0500F31428 /* Groupfolders */, F7BFFA621A24D7300044ED85 /* Login */, + F30E77EA2EAB7C1700B1EFAB /* CertificatePicker */, F7EC9CB921185F2000F1C5CE /* Media */, 371B5A2F23D0B04B00FAFAE9 /* Menu */, F7CB68942541670D0050EC94 /* More */, @@ -4762,6 +4779,7 @@ F3F442EE2DDE292D00FD701F /* NCMetadataPermissions.swift in Sources */, F3374A812D64AB9F002A38F9 /* StatusInfo.swift in Sources */, AF7E504E27A2D8FF00B5E4AF /* UIBarButton+Extension.swift in Sources */, + F30E77E92EAB716900B1EFAB /* CertificatePicker.swift in Sources */, AA8D31682D41224800FE2775 /* NCShareToggleCell.swift in Sources */, F7A846DE2BB01ACB0024816F /* NCTrashCellProtocol.swift in Sources */, F799DF852C4B7E56003410B5 /* NCSectionHeader.swift in Sources */, @@ -4841,6 +4859,7 @@ F75D90212D2BE26F003E740B /* NCRecommendationsCell.swift in Sources */, F7E98C1627E0D0FC001F9F19 /* NCManageDatabase+Video.swift in Sources */, F7F4F11227ECDC52008676F9 /* UIFont+Extension.swift in Sources */, + F30E77EF2EAF9BCD00B1EFAB /* CertificatePickerModel.swift in Sources */, F76882222C0DD1E7001CF441 /* NCCapabilitiesView.swift in Sources */, F3CA337D2D0B2B6C00672333 /* AlbumModel.swift in Sources */, AF93471A27E2361E002537EE /* NCShareHeader.swift in Sources */, @@ -4926,6 +4945,7 @@ F7A03E332D426115007AA677 /* NCMoreNavigationController.swift in Sources */, F7E402312BA891EB007E5609 /* NCTrash+SelectTabBarDelegate.swift in Sources */, F70753EB2542A99800972D44 /* NCViewerMediaPage.swift in Sources */, + F30E77EC2EAB7C9B00B1EFAB /* DocumentPicker.swift in Sources */, F7817CF829801A3500FFBC65 /* Data+Extension.swift in Sources */, F749B651297B0F2400087535 /* NCManageDatabase+Avatar.swift in Sources */, F7FAFD3A28BFA948000777FE /* NCNotification+Menu.swift in Sources */, diff --git a/Nextcloud.xcodeproj/xcshareddata/xcschemes/Nextcloud.xcscheme b/Nextcloud.xcodeproj/xcshareddata/xcschemes/Nextcloud.xcscheme index 796a251f27..5ea7246847 100755 --- a/Nextcloud.xcodeproj/xcshareddata/xcschemes/Nextcloud.xcscheme +++ b/Nextcloud.xcodeproj/xcshareddata/xcschemes/Nextcloud.xcscheme @@ -70,7 +70,9 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "NO" enableThreadSanitizer = "YES" - codeCoverageEnabled = "YES"> + disableMainThreadChecker = "YES" + codeCoverageEnabled = "YES" + disablePerformanceAntipatternChecker = "YES"> + allowLocationSimulation = "NO" + disablePerformanceAntipatternChecker = "YES"> SecIdentity? { + guard let p12Data = try? Data(contentsOf: url) else { return nil } + + let options = [kSecImportExportPassphrase as String: password] + var items: CFArray? + let status = SecPKCS12Import(p12Data as CFData, options as CFDictionary, &items) + + if status == errSecSuccess, + let array = items as? [[String: Any]] { + // swiftlint:disable force_cast + if let identity = array.first?[kSecImportItemIdentity as String] as! SecIdentity? { + // swiftlint:enable force_cast + return identity + } + } + return nil + } + + func storeIdentityInKeychain(identity: SecIdentity, label: String) { + let addQuery: [String: Any] = [ + kSecValueRef as String: identity, + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: label, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + let classes = [kSecClassIdentity, kSecClassCertificate, kSecClassKey] + for secClass in classes { + let deleteQuery: [String: Any] = [ + kSecClass as String: secClass, + kSecAttrLabel as String: label, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + let status = SecItemDelete(deleteQuery as CFDictionary) + print("Deleting \(secClass): \(status)") + } + + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + print("Add status: \(addStatus)") + + } + +} diff --git a/iOSClient/CertificatePicker/DocumentPicker.swift b/iOSClient/CertificatePicker/DocumentPicker.swift new file mode 100644 index 0000000000..3943e9f042 --- /dev/null +++ b/iOSClient/CertificatePicker/DocumentPicker.swift @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import UniformTypeIdentifiers + +struct DocumentPicker: UIViewControllerRepresentable { + var contentTypes: [UTType] + var onPickURLs: ([URL]) -> Void + + func makeUIViewController(context: Context) -> UIDocumentPickerViewController { + let picker = UIDocumentPickerViewController(forOpeningContentTypes: contentTypes) + picker.delegate = context.coordinator + picker.allowsMultipleSelection = false + return picker + } + + func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(onPickURLs: onPickURLs) + } + + final class Coordinator: NSObject, UIDocumentPickerDelegate { + let onPickURLs: ([URL]) -> Void + + init(onPickURLs: @escaping ([URL]) -> Void) { + self.onPickURLs = onPickURLs + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + onPickURLs(urls) + } + } +} diff --git a/iOSClient/Login/NCLogin.swift b/iOSClient/Login/NCLogin.swift index 23bbcb4b32..e9ba702fef 100644 --- a/iOSClient/Login/NCLogin.swift +++ b/iOSClient/Login/NCLogin.swift @@ -41,9 +41,6 @@ class NCLogin: UIViewController, UITextFieldDelegate, NCLoginQRCodeDelegate { var configPassword: String? var configAppPassword: String? - private var p12Data: Data? - private var p12Password: String? - // MARK: - View Life Cycle override func viewDidLoad() { @@ -270,8 +267,6 @@ class NCLogin: UIViewController, UITextFieldDelegate, NCLoginQRCodeDelegate { } @IBAction func actionButtonLogin(_ sender: Any) { - NCNetworking.shared.p12Data = nil - NCNetworking.shared.p12Password = nil login() } @@ -435,46 +430,36 @@ extension NCLogin: NCShareAccountsDelegate { // MARK: - UIDocumentPickerDelegate -extension NCLogin: ClientCertificateDelegate, UIDocumentPickerDelegate { +extension NCLogin: ClientCertificateDelegate, CertificatePickerDelegate { func didAskForClientCertificate() { - let alertNoCertFound = UIAlertController(title: NSLocalizedString("_no_client_cert_found_", comment: ""), message: NSLocalizedString("_no_client_cert_found_desc_", comment: ""), preferredStyle: .alert) - alertNoCertFound.addAction(UIAlertAction(title: NSLocalizedString("_cancel_", comment: ""), style: .cancel, handler: nil)) - alertNoCertFound.addAction(UIAlertAction(title: NSLocalizedString("_ok_", comment: ""), style: .default, handler: { _ in - let documentProviderMenu = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.pkcs12]) - documentProviderMenu.delegate = self - self.present(documentProviderMenu, animated: true, completion: nil) - })) - DispatchQueue.main.async { - self.present(alertNoCertFound, animated: true) + DispatchQueue.main.async { [self] in + let certPicker = UIHostingController(rootView: CertificatePicker(urlBase: baseUrlTextField.text ?? "", delegate: self)) + self.present(certPicker, animated: true) } } - func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { - let alertEnterPassword = UIAlertController(title: NSLocalizedString("_client_cert_enter_password_", comment: ""), message: "", preferredStyle: .alert) - alertEnterPassword.addAction(UIAlertAction(title: NSLocalizedString("_cancel_", comment: ""), style: .cancel, handler: nil)) - alertEnterPassword.addAction(UIAlertAction(title: NSLocalizedString("_ok_", comment: ""), style: .default, handler: { _ in - NCNetworking.shared.p12Data = try? Data(contentsOf: urls[0]) - NCNetworking.shared.p12Password = alertEnterPassword.textFields?[0].text - self.login() - })) - alertEnterPassword.addTextField { textField in - textField.isSecureTextEntry = true - } - DispatchQueue.main.async { - self.present(alertEnterPassword, animated: true) - } + func certificatePickerDidImportIdentity(_ picker: CertificatePickerModel, for urlBase: String) { + login() } +} - func onIncorrectPassword() { - NCNetworking.shared.p12Data = nil - NCNetworking.shared.p12Password = nil - let alertWrongPassword = UIAlertController(title: NSLocalizedString("_client_cert_wrong_password_", comment: ""), message: "", preferredStyle: .alert) - alertWrongPassword.addAction(UIAlertAction(title: NSLocalizedString("_ok_", comment: ""), style: .default)) - DispatchQueue.main.async { - self.present(alertWrongPassword, animated: true) - } +#if DEBUG +import Security + +private func clearKeychain() { + let secItemClasses = [ + kSecClassGenericPassword, + kSecClassInternetPassword, + kSecClassCertificate, + kSecClassKey, + kSecClassIdentity + ] + for itemClass in secItemClasses { + let query = [kSecClass as String: itemClass] + SecItemDelete(query as CFDictionary) } } +#endif // MARK: - NCLoginProviderDelegate diff --git a/iOSClient/Login/NCLoginProvider.swift b/iOSClient/Login/NCLoginProvider.swift index f4dc78221a..82110ac8d5 100644 --- a/iOSClient/Login/NCLoginProvider.swift +++ b/iOSClient/Login/NCLoginProvider.swift @@ -267,11 +267,49 @@ extension NCLoginProvider: WKNavigationDelegate { } } + func retrieveIdentityFromKeychain(label: String) -> SecIdentity? { + let query: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: label, + kSecReturnRef as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + // swiftlint:disable force_cast + return status == errSecSuccess ? (item as! SecIdentity) : nil + // swiftlint:enable force_cast + } + func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { nkLog(debug: "Web view did receive authentication challenge.") - DispatchQueue.global().async { - if let serverTrust = challenge.protectionSpace.serverTrust { + DispatchQueue.global().async { [self] in + if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate { + let label = "client_identity_\(challenge.protectionSpace.host):\(challenge.protectionSpace.port)" + if let identity = retrieveIdentityFromKeychain(label: label) { + let credential = URLCredential(identity: identity, certificates: nil, persistence: .forSession) + // completionHandler(.useCredential, credential) + + challenge.sender?.use(credential, for: challenge) + completionHandler(.useCredential, credential) + + } else { +// self.certificateDelegate?.didAskForClientCertificate() + completionHandler(.cancelAuthenticationChallenge, nil) + } + // if let p12Data = self.p12Data, + // let cert = (p12Data, self.p12Password) as? UserCertificate, + // let pkcs12 = try? PKCS12(pkcs12Data: cert.data, password: cert.password, onIncorrectPassword: { + // self.certificateDelegate?.onIncorrectPassword() + // }) { + // let creds = PKCS12.urlCredential(for: pkcs12) + // completionHandler(URLSession.AuthChallengeDisposition.useCredential, creds) + // } else { + // completionHandler(URLSession.AuthChallengeDisposition.cancelAuthenticationChallenge, nil) + // } + } else if let serverTrust = challenge.protectionSpace.serverTrust { completionHandler(Foundation.URLSession.AuthChallengeDisposition.useCredential, URLCredential(trust: serverTrust)) } else { completionHandler(URLSession.AuthChallengeDisposition.useCredential, nil) diff --git a/iOSClient/Networking/NCNetworking.swift b/iOSClient/Networking/NCNetworking.swift index 824cd5cc08..29b39a9e11 100644 --- a/iOSClient/Networking/NCNetworking.swift +++ b/iOSClient/Networking/NCNetworking.swift @@ -10,7 +10,6 @@ import Queuer import SwiftUI @objc protocol ClientCertificateDelegate { - func onIncorrectPassword() func didAskForClientCertificate() } @@ -242,8 +241,6 @@ class NCNetworking: @unchecked Sendable, NextcloudKitDelegate { var lastReachability: Bool = true var networkReachability: NKTypeReachability? weak var certificateDelegate: ClientCertificateDelegate? - var p12Data: Data? - var p12Password: String? var tapHudStopDelete = false var controller: UIViewController? @@ -291,21 +288,39 @@ class NCNetworking: @unchecked Sendable, NextcloudKitDelegate { NotificationCenter.default.postOnMainThread(name: self.global.notificationCenterNetworkReachability, userInfo: nil) } + func retrieveIdentityFromKeychain(label: String) -> SecIdentity? { + let query: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: label, + kSecReturnRef as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + // swiftlint:disable force_cast + return status == errSecSuccess ? (item as! SecIdentity) : nil + // swiftlint:enable force_cast + } + func authenticationChallenge(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + nkLog(debug: "Auth challenge method: \(challenge.protectionSpace.authenticationMethod), host: \(challenge.protectionSpace.host):\(challenge.protectionSpace.port)") + if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate { - if let p12Data = self.p12Data, - let cert = (p12Data, self.p12Password) as? UserCertificate, - let pkcs12 = try? PKCS12(pkcs12Data: cert.data, password: cert.password, onIncorrectPassword: { - self.certificateDelegate?.onIncorrectPassword() - }) { - let creds = PKCS12.urlCredential(for: pkcs12) - completionHandler(URLSession.AuthChallengeDisposition.useCredential, creds) - } else { - self.certificateDelegate?.didAskForClientCertificate() - completionHandler(URLSession.AuthChallengeDisposition.cancelAuthenticationChallenge, nil) - } + let label = "client_identity_\(challenge.protectionSpace.host):\(challenge.protectionSpace.port)" + + if let identity = retrieveIdentityFromKeychain(label: label) { + let credential = URLCredential(identity: identity, certificates: nil, persistence: .forSession) + + challenge.sender?.use(credential, for: challenge) + completionHandler(.useCredential, credential) + + } else { + self.certificateDelegate?.didAskForClientCertificate() + completionHandler(.cancelAuthenticationChallenge, nil) + } } else { self.checkTrustedChallenge(session, didReceive: challenge, completionHandler: completionHandler) } @@ -412,10 +427,6 @@ class NCNetworking: @unchecked Sendable, NextcloudKitDelegate { BIO_free(mem) } - func activeAccountCertificate(account: String) { - (self.p12Data, self.p12Password) = NCPreferences().getClientCertificate(account: account) - } - // MARK: - Helper func helperMetadataSuccess(metadata: tableMetadata) async -> (localFile: tableMetadata?, livePhoto: tableMetadata?, autoUpload: tableAutoUploadTransfer?) { diff --git a/iOSClient/SceneDelegate.swift b/iOSClient/SceneDelegate.swift index 393e78fc6d..93c7767c14 100644 --- a/iOSClient/SceneDelegate.swift +++ b/iOSClient/SceneDelegate.swift @@ -134,9 +134,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { withActivateSceneForAccount activateSceneForAccount: Bool) { nkLog(debug: "Account active \(activeTblAccount.account)") - // Networking Certificate - NCNetworking.shared.activeAccountCertificate(account: activeTblAccount.account) - Task { if let capabilities = await NCManageDatabase.shared.getCapabilities(account: activeTblAccount.account) { // set theming color diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 55ff4c976e..bb23c7a9f2 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -705,10 +705,12 @@ You can stop it at any time, adjust the settings, and enable it again."; "_no_types_subtitle_" = "AI Providers need to be installed to use the Assistant."; // MARK: Client certificate -"_no_client_cert_found_" = "The server is requesting a client certificate."; -"_no_client_cert_found_desc_" = "Do you want to install a TLS client certificate? \n Note that the .p12 certificate must be installed on your device first by clicking on it and installing it as an Identitity Certificate Profile in Settings. The certificate MUST also have a password as that is a requirement by iOS."; -"_client_cert_enter_password_" = "Enter the password for the chosen certificate"; -"_client_cert_wrong_password_" = "Sorry, you entered an invalid password."; +"_cert_navigation_title_" = "Client Certificate"; +"_cert_title_" = ".p12 Certificate"; +"_no_client_cert_found_" = "The server '%@' is requesting a client certificate."; +"_no_client_cert_found_desc_" = "Note that the .p12 certificate must be installed on your device first by clicking on it and installing it as an Identitity Certificate Profile in Settings."; +"_no_client_cert_found_desc_password_" = "Please enter the password for the chosen certificate. The certificate **must** have a password set as that is a requirement by iOS."; +"_client_cert_wrong_password_" = "The password entered for the chosen certificate is wrong."; // MARK: Login poll "_poll_desc_" = "Please complete the log in process in your browser.";