From 42703b7f4222a993454c2bfbab1cd3bffed48df2 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Mon, 18 Aug 2025 19:25:35 +0200 Subject: [PATCH 1/5] Rewrite mTLS logic Signed-off-by: Milen Pivchev --- iOSClient/Account/NCAccount.swift | 2 - iOSClient/Login/NCLogin.swift | 65 ++++++++++++++++++++----- iOSClient/Login/NCLoginProvider.swift | 42 +++++++++++++++- iOSClient/Networking/NCNetworking.swift | 65 ++++++++++--------------- 4 files changed, 120 insertions(+), 54 deletions(-) diff --git a/iOSClient/Account/NCAccount.swift b/iOSClient/Account/NCAccount.swift index 4cc330b1ae..07acb69d2f 100644 --- a/iOSClient/Account/NCAccount.swift +++ b/iOSClient/Account/NCAccount.swift @@ -58,8 +58,6 @@ class NCAccount: NSObject { await changeAccount(account, userProfile: userProfile, controller: controller) nkLog(debug: "NCAccount changed user profile to \(userProfile.userId).") - NCPreferences().setClientCertificate(account: account, p12Data: NCNetworking.shared.p12Data, p12Password: NCNetworking.shared.p12Password) - if let controller { controller.account = account nkLog(debug: "Dismissing login provider view controller...") diff --git a/iOSClient/Login/NCLogin.swift b/iOSClient/Login/NCLogin.swift index 23bbcb4b32..92e090287e 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() } @@ -449,13 +444,19 @@ extension NCLogin: ClientCertificateDelegate, UIDocumentPickerDelegate { } } - func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + func documentPicker(_: 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.addAction(UIAlertAction(title: NSLocalizedString("_ok_", comment: ""), style: .default, handler: { [self] _ in + if let identity = getIdentityFromP12(from: urls[0], password: alertEnterPassword.textFields?[0].text ?? "") { + let urlBase = baseUrlTextField.text ?? "" + let urlWithoutScheme = urlBase.replacingOccurrences(of: "https://", with: "").replacingOccurrences(of: "http://", with: "") + let label = "client_identity_\(urlWithoutScheme)" + storeIdentityInKeychain(identity: identity, label: label) + self.login() + } else { + //TODO: Show error if password is incorrect and show alert to reenter password + } })) alertEnterPassword.addTextField { textField in textField.isSecureTextEntry = true @@ -465,9 +466,49 @@ extension NCLogin: ClientCertificateDelegate, UIDocumentPickerDelegate { } } + 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)") + + } + + func getIdentityFromP12(from url: URL, password: String) -> 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 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 { 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 fb4b8e8611..32914bf841 100644 --- a/iOSClient/Networking/NCNetworking.swift +++ b/iOSClient/Networking/NCNetworking.swift @@ -127,11 +127,6 @@ actor NCTransferDelegateDispatcher { class NCNetworking: @unchecked Sendable, NextcloudKitDelegate { static let shared = NCNetworking() - struct FileNameServerUrl: Hashable { - var fileName: String - var serverUrl: String - } - let sessionDownload = NextcloudKit.shared.nkCommonInstance.identifierSessionDownload let sessionDownloadBackground = NextcloudKit.shared.nkCommonInstance.identifierSessionDownloadBackground let sessionDownloadBackgroundExt = NextcloudKit.shared.nkCommonInstance.identifierSessionDownloadBackgroundExt @@ -151,8 +146,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 isOffline: Bool { @@ -175,22 +168,6 @@ class NCNetworking: @unchecked Sendable, NextcloudKitDelegate { let saveLivePhotoQueue = Queuer(name: "saveLivePhotoQueue", maxConcurrentOperationCount: 1, qualityOfService: .default) let downloadAvatarQueue = Queuer(name: "downloadAvatarQueue", maxConcurrentOperationCount: 10, qualityOfService: .default) - // MARK: - init - - init() { - if let account = database.getActiveTableAccount()?.account { - getActiveAccountCertificate(account: account) - } - - NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: global.notificationCenterChangeUser), object: nil, queue: .main) { notification in - if let userInfo = notification.userInfo { - if let account = userInfo["account"] as? String { - self.getActiveAccountCertificate(account: account) - } - } - } - } - // MARK: - Communication Delegate func networkReachabilityObserver(_ typeReachability: NKTypeReachability) { @@ -206,21 +183,37 @@ class NCNetworking: @unchecked Sendable, NextcloudKitDelegate { networkReachability = typeReachability } + 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) { 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)" + print(label) + 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) } @@ -365,8 +358,4 @@ class NCNetworking: @unchecked Sendable, NextcloudKitDelegate { } } } - - private func getActiveAccountCertificate(account: String) { - (self.p12Data, self.p12Password) = NCPreferences().getClientCertificate(account: account) - } } From 0ab6e635c1cdbe17555a114aec29fd9917f57150 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Tue, 2 Sep 2025 13:58:55 +0200 Subject: [PATCH 2/5] Fix compile Signed-off-by: Milen Pivchev --- iOSClient/Account/NCAccount.swift | 2 -- iOSClient/SceneDelegate.swift | 3 --- 2 files changed, 5 deletions(-) diff --git a/iOSClient/Account/NCAccount.swift b/iOSClient/Account/NCAccount.swift index ebdecc4677..a347e1724f 100644 --- a/iOSClient/Account/NCAccount.swift +++ b/iOSClient/Account/NCAccount.swift @@ -94,8 +94,6 @@ class NCAccount: NSObject { if let userProfile { await database.setAccountUserProfileAsync(account: account, userProfile: userProfile) } - // Networking Certificate - NCNetworking.shared.activeAccountCertificate(account: account) // Subscribing Push Notification await NCPushNotification.shared.subscribingNextcloudServerPushNotification(account: tblAccount.account, urlBase: tblAccount.urlBase) // Start the service diff --git a/iOSClient/SceneDelegate.swift b/iOSClient/SceneDelegate.swift index ea7e488e2b..72a07ac094 100644 --- a/iOSClient/SceneDelegate.swift +++ b/iOSClient/SceneDelegate.swift @@ -132,9 +132,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 From 9b42a3d5379ab3cdda7afb6f77cd97fb2c7cff6a Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Mon, 27 Oct 2025 11:33:51 +0100 Subject: [PATCH 3/5] Add screen for cert request, add secure scope Signed-off-by: Milen Pivchev --- Nextcloud.xcodeproj/project.pbxproj | 16 ++ .../CertificatePicker/CertificatePicker.swift | 168 ++++++++++++++++++ .../CertificatePicker/DocumentPicker.swift | 36 ++++ iOSClient/Login/NCLogin.swift | 100 +++-------- iOSClient/Networking/NCNetworking.swift | 5 +- .../en.lproj/Localizable.strings | 10 +- 6 files changed, 251 insertions(+), 84 deletions(-) create mode 100644 iOSClient/CertificatePicker/CertificatePicker.swift create mode 100644 iOSClient/CertificatePicker/DocumentPicker.swift diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index a18df71277..33fd3c2869 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -93,6 +93,8 @@ 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 */; }; 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 */; }; @@ -1375,6 +1377,8 @@ 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 = ""; }; 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 = ""; }; @@ -2247,6 +2251,15 @@ path = Tests; sourceTree = ""; }; + F30E77EA2EAB7C1700B1EFAB /* CertificatePicker */ = { + isa = PBXGroup; + children = ( + F30E77EB2EAB7C9800B1EFAB /* DocumentPicker.swift */, + F30E77E82EAB716900B1EFAB /* CertificatePicker.swift */, + ); + path = CertificatePicker; + sourceTree = ""; + }; F3374A7F2D64AB40002A38F9 /* Components */ = { isa = PBXGroup; children = ( @@ -3382,6 +3395,7 @@ F7725A5D251F33BB00D125E0 /* Files */, F757CC8929E82D0500F31428 /* Groupfolders */, F7BFFA621A24D7300044ED85 /* Login */, + F30E77EA2EAB7C1700B1EFAB /* CertificatePicker */, F7EC9CB921185F2000F1C5CE /* Media */, 371B5A2F23D0B04B00FAFAE9 /* Menu */, F7CB68942541670D0050EC94 /* More */, @@ -4746,6 +4760,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 */, @@ -4910,6 +4925,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/iOSClient/CertificatePicker/CertificatePicker.swift b/iOSClient/CertificatePicker/CertificatePicker.swift new file mode 100644 index 0000000000..99ca8d865d --- /dev/null +++ b/iOSClient/CertificatePicker/CertificatePicker.swift @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import UniformTypeIdentifiers + +struct CertificatePicker: View { + @State private var model = CertificatePickerModel() + @State private var showingPicker = false + @State private var fileName: String = "" + @State private var pickedURL: URL? = nil + @State private var password: String = "" + + let urlBase: String + weak var delegate: CertificatePickerDelegate? + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + VStack { + Form { + Section(header: Text(String(format: NSLocalizedString("_no_client_cert_found_", comment: ""), urlBase)), footer: Text("_no_client_cert_found_desc_")) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("_cert_title_") + .font(.headline) + if !fileName.isEmpty { + Text(fileName) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } else { + Text("No file selected") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + Spacer() + Button("_upload_") { + showingPicker = true + } + .buttonStyle(.bordered) + .foregroundStyle(Color(NCBrandColor.shared.customer)) + } + } + + Section(footer: Text("_no_client_cert_found_desc_password_")) { + SecureField("_password_", text: $password) + .textContentType(.password) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + } + } + .onAppear { + model.delegate = delegate + } + .navigationTitle("_cert_navigation_title_") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + } + ToolbarItem(placement: .confirmationAction) { + Button { + if let url = pickedURL { + model.handleCertificate(fileUrl: url, urlBase: urlBase, password: password) + } + } label: { + Image(systemName: "checkmark") + } + .disabled(pickedURL == nil || password.isEmpty) + .tint(Color(NCBrandColor.shared.customer)) + } + } + .sheet(isPresented: $showingPicker) { + DocumentPicker(contentTypes: [UTType.pkcs12]) { urls in + if let url = urls.first { + pickedURL = url + fileName = url.lastPathComponent + } + } + } + .alert("_client_cert_wrong_password_", isPresented: $model.isWrongPassword) {} + } + } +} + +protocol CertificatePickerDelegate: AnyObject { + func certificatePickerDidImportIdentity(_ picker: CertificatePickerModel, for urlBase: String) +} + +@Observable class CertificatePickerModel: NSObject, UIDocumentPickerDelegate { + var isWrongPassword = false + @ObservationIgnored weak var delegate: CertificatePickerDelegate? + + func handleCertificate(fileUrl: URL, urlBase: String, password: String) { + if fileUrl.startAccessingSecurityScopedResource() { + defer { + fileUrl.stopAccessingSecurityScopedResource() + } + + if let identity = getIdentityFromP12(from: fileUrl, password: password) { + let urlWithoutScheme = urlBase.replacingOccurrences(of: "https://", with: "").replacingOccurrences(of: "http://", with: "") + let label = "client_identity_\(urlWithoutScheme)" + storeIdentityInKeychain(identity: identity, label: label) + delegate?.certificatePickerDidImportIdentity(self, for: urlBase) + } else { + isWrongPassword = true + } + } + } + + func getIdentityFromP12(from url: URL, password: String) -> 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)") + + } + +} + +#Preview { + CertificatePicker(urlBase: "test.com") +} + 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 92e090287e..9a92818cd9 100644 --- a/iOSClient/Login/NCLogin.swift +++ b/iOSClient/Login/NCLogin.swift @@ -430,92 +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(_: 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: { [self] _ in - if let identity = getIdentityFromP12(from: urls[0], password: alertEnterPassword.textFields?[0].text ?? "") { - let urlBase = baseUrlTextField.text ?? "" - let urlWithoutScheme = urlBase.replacingOccurrences(of: "https://", with: "").replacingOccurrences(of: "http://", with: "") - let label = "client_identity_\(urlWithoutScheme)" - storeIdentityInKeychain(identity: identity, label: label) - self.login() - } else { - //TODO: Show error if password is incorrect and show alert to reenter password - } - })) - alertEnterPassword.addTextField { textField in - textField.isSecureTextEntry = true - } - DispatchQueue.main.async { - self.present(alertEnterPassword, animated: true) - } - } - - 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)") - - } - - func getIdentityFromP12(from url: URL, password: String) -> 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 certificatePickerDidImportIdentity(_ picker: CertificatePickerModel, for urlBase: String) { + login() } +} - func onIncorrectPassword() { - 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 + +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/Networking/NCNetworking.swift b/iOSClient/Networking/NCNetworking.swift index 191e0aabd5..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() } @@ -307,9 +306,11 @@ class NCNetworking: @unchecked Sendable, NextcloudKitDelegate { 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 { let label = "client_identity_\(challenge.protectionSpace.host):\(challenge.protectionSpace.port)" - print(label) + if let identity = retrieveIdentityFromKeychain(label: label) { let credential = URLCredential(identity: identity, certificates: nil, persistence: .forSession) diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 754e92070c..36747d6347 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -704,10 +704,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."; From c2b1a59c294bab152f4dc7c39fae0dbde0f8afc2 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Mon, 27 Oct 2025 12:32:04 +0100 Subject: [PATCH 4/5] Refactor Signed-off-by: Milen Pivchev --- iOSClient/CertificatePicker/CertificatePicker.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iOSClient/CertificatePicker/CertificatePicker.swift b/iOSClient/CertificatePicker/CertificatePicker.swift index 99ca8d865d..65e3993cbc 100644 --- a/iOSClient/CertificatePicker/CertificatePicker.swift +++ b/iOSClient/CertificatePicker/CertificatePicker.swift @@ -9,7 +9,7 @@ struct CertificatePicker: View { @State private var model = CertificatePickerModel() @State private var showingPicker = false @State private var fileName: String = "" - @State private var pickedURL: URL? = nil + @State private var pickedURL: URL? @State private var password: String = "" let urlBase: String From 9e4f4cbfc94debf296acabf14c35b652535a5d79 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Mon, 27 Oct 2025 13:53:50 +0100 Subject: [PATCH 5/5] Refactor Signed-off-by: Milen Pivchev --- Nextcloud.xcodeproj/project.pbxproj | 4 + .../xcshareddata/xcschemes/Nextcloud.xcscheme | 8 +- .../CertificatePicker/CertificatePicker.swift | 82 +++---------------- .../CertificatePickerModel.swift | 74 +++++++++++++++++ iOSClient/Login/NCLogin.swift | 2 +- 5 files changed, 96 insertions(+), 74 deletions(-) create mode 100644 iOSClient/CertificatePicker/CertificatePickerModel.swift diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 0002c3aea4..e406e883fe 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -95,6 +95,7 @@ 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 */; }; @@ -1384,6 +1385,7 @@ 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 = ""; }; @@ -2264,6 +2266,7 @@ F30E77EA2EAB7C1700B1EFAB /* CertificatePicker */ = { isa = PBXGroup; children = ( + F30E77EE2EAF9BC700B1EFAB /* CertificatePickerModel.swift */, F30E77EB2EAB7C9800B1EFAB /* DocumentPicker.swift */, F30E77E82EAB716900B1EFAB /* CertificatePicker.swift */, ); @@ -4856,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 */, 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)") - - } - } #Preview { CertificatePicker(urlBase: "test.com") } - diff --git a/iOSClient/CertificatePicker/CertificatePickerModel.swift b/iOSClient/CertificatePicker/CertificatePickerModel.swift new file mode 100644 index 0000000000..2130269465 --- /dev/null +++ b/iOSClient/CertificatePicker/CertificatePickerModel.swift @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +protocol CertificatePickerDelegate: AnyObject { + func certificatePickerDidImportIdentity(_ picker: CertificatePickerModel, for urlBase: String) +} + +@Observable class CertificatePickerModel: NSObject, UIDocumentPickerDelegate { + var isWrongPassword = false + var isCertImportedSuccessfully = false + @ObservationIgnored weak var delegate: CertificatePickerDelegate? + + func handleCertificate(fileUrl: URL, urlBase: String, password: String) { + if fileUrl.startAccessingSecurityScopedResource() { + defer { + fileUrl.stopAccessingSecurityScopedResource() + } + + if let identity = getIdentityFromP12(from: fileUrl, password: password) { + let urlWithoutScheme = urlBase.replacingOccurrences(of: "https://", with: "").replacingOccurrences(of: "http://", with: "") + let label = "client_identity_\(urlWithoutScheme)" + storeIdentityInKeychain(identity: identity, label: label) + delegate?.certificatePickerDidImportIdentity(self, for: urlBase) + isCertImportedSuccessfully = true + } else { + isWrongPassword = true + } + } + } + + func getIdentityFromP12(from url: URL, password: String) -> 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/Login/NCLogin.swift b/iOSClient/Login/NCLogin.swift index 9a92818cd9..e9ba702fef 100644 --- a/iOSClient/Login/NCLogin.swift +++ b/iOSClient/Login/NCLogin.swift @@ -446,7 +446,7 @@ extension NCLogin: ClientCertificateDelegate, CertificatePickerDelegate { #if DEBUG import Security -func clearKeychain() { +private func clearKeychain() { let secItemClasses = [ kSecClassGenericPassword, kSecClassInternetPassword,