Skip to content

Commit 9b42a3d

Browse files
committed
Add screen for cert request, add secure scope
Signed-off-by: Milen Pivchev <[email protected]>
1 parent c0b9244 commit 9b42a3d

File tree

6 files changed

+251
-84
lines changed

6 files changed

+251
-84
lines changed

Nextcloud.xcodeproj/project.pbxproj

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@
9393
AFCE353927E5DE0500FEA6C2 /* Shareable.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFCE353827E5DE0400FEA6C2 /* Shareable.swift */; };
9494
D575039F27146F93008DC9DC /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A0D1342591FBC5008F8A13 /* String+Extension.swift */; };
9595
D5B6AA7827200C7200D49C24 /* NCActivityTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B6AA7727200C7200D49C24 /* NCActivityTableViewCell.swift */; };
96+
F30E77E92EAB716900B1EFAB /* CertificatePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F30E77E82EAB716900B1EFAB /* CertificatePicker.swift */; };
97+
F30E77EC2EAB7C9B00B1EFAB /* DocumentPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F30E77EB2EAB7C9800B1EFAB /* DocumentPicker.swift */; };
9698
F310B1EF2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = F310B1EE2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift */; };
9799
F314F1142A30E2DE00BC7FAB /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7E8A390295DC5E0006CB2D0 /* View+Extension.swift */; };
98100
F321DA8A2B71205A00DDA0E6 /* NCTrashSelectTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F321DA892B71205A00DDA0E6 /* NCTrashSelectTabBar.swift */; };
@@ -1375,6 +1377,8 @@
13751377
C0046CDA2A17B98400D87C9D /* NextcloudUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NextcloudUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
13761378
C04E2F202A17BB4D001BAD85 /* NextcloudIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NextcloudIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
13771379
D5B6AA7727200C7200D49C24 /* NCActivityTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCActivityTableViewCell.swift; sourceTree = "<group>"; };
1380+
F30E77E82EAB716900B1EFAB /* CertificatePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificatePicker.swift; sourceTree = "<group>"; };
1381+
F30E77EB2EAB7C9800B1EFAB /* DocumentPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentPicker.swift; sourceTree = "<group>"; };
13781382
F310B1EE2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCViewerMedia+VisionKit.swift"; sourceTree = "<group>"; };
13791383
F321DA892B71205A00DDA0E6 /* NCTrashSelectTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCTrashSelectTabBar.swift; sourceTree = "<group>"; };
13801384
F32FADA82D1176DE007035E2 /* UIButton+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIButton+Extension.swift"; sourceTree = "<group>"; };
@@ -2247,6 +2251,15 @@
22472251
path = Tests;
22482252
sourceTree = "<group>";
22492253
};
2254+
F30E77EA2EAB7C1700B1EFAB /* CertificatePicker */ = {
2255+
isa = PBXGroup;
2256+
children = (
2257+
F30E77EB2EAB7C9800B1EFAB /* DocumentPicker.swift */,
2258+
F30E77E82EAB716900B1EFAB /* CertificatePicker.swift */,
2259+
);
2260+
path = CertificatePicker;
2261+
sourceTree = "<group>";
2262+
};
22502263
F3374A7F2D64AB40002A38F9 /* Components */ = {
22512264
isa = PBXGroup;
22522265
children = (
@@ -3382,6 +3395,7 @@
33823395
F7725A5D251F33BB00D125E0 /* Files */,
33833396
F757CC8929E82D0500F31428 /* Groupfolders */,
33843397
F7BFFA621A24D7300044ED85 /* Login */,
3398+
F30E77EA2EAB7C1700B1EFAB /* CertificatePicker */,
33853399
F7EC9CB921185F2000F1C5CE /* Media */,
33863400
371B5A2F23D0B04B00FAFAE9 /* Menu */,
33873401
F7CB68942541670D0050EC94 /* More */,
@@ -4746,6 +4760,7 @@
47464760
F3F442EE2DDE292D00FD701F /* NCMetadataPermissions.swift in Sources */,
47474761
F3374A812D64AB9F002A38F9 /* StatusInfo.swift in Sources */,
47484762
AF7E504E27A2D8FF00B5E4AF /* UIBarButton+Extension.swift in Sources */,
4763+
F30E77E92EAB716900B1EFAB /* CertificatePicker.swift in Sources */,
47494764
AA8D31682D41224800FE2775 /* NCShareToggleCell.swift in Sources */,
47504765
F7A846DE2BB01ACB0024816F /* NCTrashCellProtocol.swift in Sources */,
47514766
F799DF852C4B7E56003410B5 /* NCSectionHeader.swift in Sources */,
@@ -4910,6 +4925,7 @@
49104925
F7A03E332D426115007AA677 /* NCMoreNavigationController.swift in Sources */,
49114926
F7E402312BA891EB007E5609 /* NCTrash+SelectTabBarDelegate.swift in Sources */,
49124927
F70753EB2542A99800972D44 /* NCViewerMediaPage.swift in Sources */,
4928+
F30E77EC2EAB7C9B00B1EFAB /* DocumentPicker.swift in Sources */,
49134929
F7817CF829801A3500FFBC65 /* Data+Extension.swift in Sources */,
49144930
F749B651297B0F2400087535 /* NCManageDatabase+Avatar.swift in Sources */,
49154931
F7FAFD3A28BFA948000777FE /* NCNotification+Menu.swift in Sources */,
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// SPDX-FileCopyrightText: Nextcloud GmbH
2+
// SPDX-FileCopyrightText: 2025 Milen Pivchev
3+
// SPDX-License-Identifier: GPL-3.0-or-later
4+
5+
import SwiftUI
6+
import UniformTypeIdentifiers
7+
8+
struct CertificatePicker: View {
9+
@State private var model = CertificatePickerModel()
10+
@State private var showingPicker = false
11+
@State private var fileName: String = ""
12+
@State private var pickedURL: URL? = nil
13+
@State private var password: String = ""
14+
15+
let urlBase: String
16+
weak var delegate: CertificatePickerDelegate?
17+
18+
@Environment(\.dismiss) private var dismiss
19+
20+
var body: some View {
21+
NavigationStack {
22+
VStack {
23+
Form {
24+
Section(header: Text(String(format: NSLocalizedString("_no_client_cert_found_", comment: ""), urlBase)), footer: Text("_no_client_cert_found_desc_")) {
25+
HStack {
26+
VStack(alignment: .leading, spacing: 4) {
27+
Text("_cert_title_")
28+
.font(.headline)
29+
if !fileName.isEmpty {
30+
Text(fileName)
31+
.font(.subheadline)
32+
.foregroundStyle(.secondary)
33+
.lineLimit(1)
34+
.truncationMode(.middle)
35+
} else {
36+
Text("No file selected")
37+
.font(.subheadline)
38+
.foregroundStyle(.secondary)
39+
}
40+
}
41+
Spacer()
42+
Button("_upload_") {
43+
showingPicker = true
44+
}
45+
.buttonStyle(.bordered)
46+
.foregroundStyle(Color(NCBrandColor.shared.customer))
47+
}
48+
}
49+
50+
Section(footer: Text("_no_client_cert_found_desc_password_")) {
51+
SecureField("_password_", text: $password)
52+
.textContentType(.password)
53+
.autocorrectionDisabled()
54+
.textInputAutocapitalization(.never)
55+
}
56+
}
57+
}
58+
.onAppear {
59+
model.delegate = delegate
60+
}
61+
.navigationTitle("_cert_navigation_title_")
62+
.navigationBarTitleDisplayMode(.inline)
63+
.toolbar {
64+
ToolbarItem(placement: .cancellationAction) {
65+
Button {
66+
dismiss()
67+
} label: {
68+
Image(systemName: "xmark")
69+
}
70+
}
71+
ToolbarItem(placement: .confirmationAction) {
72+
Button {
73+
if let url = pickedURL {
74+
model.handleCertificate(fileUrl: url, urlBase: urlBase, password: password)
75+
}
76+
} label: {
77+
Image(systemName: "checkmark")
78+
}
79+
.disabled(pickedURL == nil || password.isEmpty)
80+
.tint(Color(NCBrandColor.shared.customer))
81+
}
82+
}
83+
.sheet(isPresented: $showingPicker) {
84+
DocumentPicker(contentTypes: [UTType.pkcs12]) { urls in
85+
if let url = urls.first {
86+
pickedURL = url
87+
fileName = url.lastPathComponent
88+
}
89+
}
90+
}
91+
.alert("_client_cert_wrong_password_", isPresented: $model.isWrongPassword) {}
92+
}
93+
}
94+
}
95+
96+
protocol CertificatePickerDelegate: AnyObject {
97+
func certificatePickerDidImportIdentity(_ picker: CertificatePickerModel, for urlBase: String)
98+
}
99+
100+
@Observable class CertificatePickerModel: NSObject, UIDocumentPickerDelegate {
101+
var isWrongPassword = false
102+
@ObservationIgnored weak var delegate: CertificatePickerDelegate?
103+
104+
func handleCertificate(fileUrl: URL, urlBase: String, password: String) {
105+
if fileUrl.startAccessingSecurityScopedResource() {
106+
defer {
107+
fileUrl.stopAccessingSecurityScopedResource()
108+
}
109+
110+
if let identity = getIdentityFromP12(from: fileUrl, password: password) {
111+
let urlWithoutScheme = urlBase.replacingOccurrences(of: "https://", with: "").replacingOccurrences(of: "http://", with: "")
112+
let label = "client_identity_\(urlWithoutScheme)"
113+
storeIdentityInKeychain(identity: identity, label: label)
114+
delegate?.certificatePickerDidImportIdentity(self, for: urlBase)
115+
} else {
116+
isWrongPassword = true
117+
}
118+
}
119+
}
120+
121+
func getIdentityFromP12(from url: URL, password: String) -> SecIdentity? {
122+
guard let p12Data = try? Data(contentsOf: url) else { return nil }
123+
124+
let options = [kSecImportExportPassphrase as String: password]
125+
var items: CFArray?
126+
let status = SecPKCS12Import(p12Data as CFData, options as CFDictionary, &items)
127+
128+
if status == errSecSuccess,
129+
let array = items as? [[String: Any]] {
130+
// swiftlint:disable force_cast
131+
if let identity = array.first?[kSecImportItemIdentity as String] as! SecIdentity? {
132+
// swiftlint:enable force_cast
133+
return identity
134+
}
135+
}
136+
return nil
137+
}
138+
139+
func storeIdentityInKeychain(identity: SecIdentity, label: String) {
140+
let addQuery: [String: Any] = [
141+
kSecValueRef as String: identity,
142+
kSecClass as String: kSecClassIdentity,
143+
kSecAttrLabel as String: label,
144+
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
145+
]
146+
147+
let classes = [kSecClassIdentity, kSecClassCertificate, kSecClassKey]
148+
for secClass in classes {
149+
let deleteQuery: [String: Any] = [
150+
kSecClass as String: secClass,
151+
kSecAttrLabel as String: label,
152+
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
153+
]
154+
let status = SecItemDelete(deleteQuery as CFDictionary)
155+
print("Deleting \(secClass): \(status)")
156+
}
157+
158+
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
159+
print("Add status: \(addStatus)")
160+
161+
}
162+
163+
}
164+
165+
#Preview {
166+
CertificatePicker(urlBase: "test.com")
167+
}
168+
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// SPDX-FileCopyrightText: Nextcloud GmbH
2+
// SPDX-FileCopyrightText: 2025 Milen Pivchev
3+
// SPDX-License-Identifier: GPL-3.0-or-later
4+
5+
import SwiftUI
6+
import UniformTypeIdentifiers
7+
8+
struct DocumentPicker: UIViewControllerRepresentable {
9+
var contentTypes: [UTType]
10+
var onPickURLs: ([URL]) -> Void
11+
12+
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
13+
let picker = UIDocumentPickerViewController(forOpeningContentTypes: contentTypes)
14+
picker.delegate = context.coordinator
15+
picker.allowsMultipleSelection = false
16+
return picker
17+
}
18+
19+
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
20+
21+
func makeCoordinator() -> Coordinator {
22+
Coordinator(onPickURLs: onPickURLs)
23+
}
24+
25+
final class Coordinator: NSObject, UIDocumentPickerDelegate {
26+
let onPickURLs: ([URL]) -> Void
27+
28+
init(onPickURLs: @escaping ([URL]) -> Void) {
29+
self.onPickURLs = onPickURLs
30+
}
31+
32+
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
33+
onPickURLs(urls)
34+
}
35+
}
36+
}

iOSClient/Login/NCLogin.swift

Lines changed: 22 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -430,92 +430,36 @@ extension NCLogin: NCShareAccountsDelegate {
430430

431431
// MARK: - UIDocumentPickerDelegate
432432

433-
extension NCLogin: ClientCertificateDelegate, UIDocumentPickerDelegate {
433+
extension NCLogin: ClientCertificateDelegate, CertificatePickerDelegate {
434434
func didAskForClientCertificate() {
435-
let alertNoCertFound = UIAlertController(title: NSLocalizedString("_no_client_cert_found_", comment: ""), message: NSLocalizedString("_no_client_cert_found_desc_", comment: ""), preferredStyle: .alert)
436-
alertNoCertFound.addAction(UIAlertAction(title: NSLocalizedString("_cancel_", comment: ""), style: .cancel, handler: nil))
437-
alertNoCertFound.addAction(UIAlertAction(title: NSLocalizedString("_ok_", comment: ""), style: .default, handler: { _ in
438-
let documentProviderMenu = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.pkcs12])
439-
documentProviderMenu.delegate = self
440-
self.present(documentProviderMenu, animated: true, completion: nil)
441-
}))
442-
DispatchQueue.main.async {
443-
self.present(alertNoCertFound, animated: true)
435+
DispatchQueue.main.async { [self] in
436+
let certPicker = UIHostingController(rootView: CertificatePicker(urlBase: baseUrlTextField.text ?? "", delegate: self))
437+
self.present(certPicker, animated: true)
444438
}
445439
}
446440

447-
func documentPicker(_: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
448-
let alertEnterPassword = UIAlertController(title: NSLocalizedString("_client_cert_enter_password_", comment: ""), message: "", preferredStyle: .alert)
449-
alertEnterPassword.addAction(UIAlertAction(title: NSLocalizedString("_cancel_", comment: ""), style: .cancel, handler: nil))
450-
alertEnterPassword.addAction(UIAlertAction(title: NSLocalizedString("_ok_", comment: ""), style: .default, handler: { [self] _ in
451-
if let identity = getIdentityFromP12(from: urls[0], password: alertEnterPassword.textFields?[0].text ?? "") {
452-
let urlBase = baseUrlTextField.text ?? ""
453-
let urlWithoutScheme = urlBase.replacingOccurrences(of: "https://", with: "").replacingOccurrences(of: "http://", with: "")
454-
let label = "client_identity_\(urlWithoutScheme)"
455-
storeIdentityInKeychain(identity: identity, label: label)
456-
self.login()
457-
} else {
458-
//TODO: Show error if password is incorrect and show alert to reenter password
459-
}
460-
}))
461-
alertEnterPassword.addTextField { textField in
462-
textField.isSecureTextEntry = true
463-
}
464-
DispatchQueue.main.async {
465-
self.present(alertEnterPassword, animated: true)
466-
}
467-
}
468-
469-
func storeIdentityInKeychain(identity: SecIdentity, label: String) {
470-
let addQuery: [String: Any] = [
471-
kSecValueRef as String: identity,
472-
kSecClass as String: kSecClassIdentity,
473-
kSecAttrLabel as String: label,
474-
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
475-
]
476-
477-
let classes = [kSecClassIdentity, kSecClassCertificate, kSecClassKey]
478-
for secClass in classes {
479-
let deleteQuery: [String: Any] = [
480-
kSecClass as String: secClass,
481-
kSecAttrLabel as String: label,
482-
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
483-
]
484-
let status = SecItemDelete(deleteQuery as CFDictionary)
485-
print("Deleting \(secClass): \(status)")
486-
}
487-
488-
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
489-
print("Add status: \(addStatus)")
490-
491-
}
492-
493-
func getIdentityFromP12(from url: URL, password: String) -> SecIdentity? {
494-
guard let p12Data = try? Data(contentsOf: url) else { return nil }
495-
496-
let options = [kSecImportExportPassphrase as String: password]
497-
var items: CFArray?
498-
let status = SecPKCS12Import(p12Data as CFData, options as CFDictionary, &items)
499-
500-
if status == errSecSuccess,
501-
let array = items as? [[String: Any]] {
502-
// swiftlint:disable force_cast
503-
if let identity = array.first?[kSecImportItemIdentity as String] as! SecIdentity? {
504-
// swiftlint:enable force_cast
505-
return identity
506-
}
507-
}
508-
return nil
441+
func certificatePickerDidImportIdentity(_ picker: CertificatePickerModel, for urlBase: String) {
442+
login()
509443
}
444+
}
510445

511-
func onIncorrectPassword() {
512-
let alertWrongPassword = UIAlertController(title: NSLocalizedString("_client_cert_wrong_password_", comment: ""), message: "", preferredStyle: .alert)
513-
alertWrongPassword.addAction(UIAlertAction(title: NSLocalizedString("_ok_", comment: ""), style: .default))
514-
DispatchQueue.main.async {
515-
self.present(alertWrongPassword, animated: true)
516-
}
446+
#if DEBUG
447+
import Security
448+
449+
func clearKeychain() {
450+
let secItemClasses = [
451+
kSecClassGenericPassword,
452+
kSecClassInternetPassword,
453+
kSecClassCertificate,
454+
kSecClassKey,
455+
kSecClassIdentity
456+
]
457+
for itemClass in secItemClasses {
458+
let query = [kSecClass as String: itemClass]
459+
SecItemDelete(query as CFDictionary)
517460
}
518461
}
462+
#endif
519463

520464
// MARK: - NCLoginProviderDelegate
521465

iOSClient/Networking/NCNetworking.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import Queuer
1010
import SwiftUI
1111

1212
@objc protocol ClientCertificateDelegate {
13-
func onIncorrectPassword()
1413
func didAskForClientCertificate()
1514
}
1615

@@ -307,9 +306,11 @@ class NCNetworking: @unchecked Sendable, NextcloudKitDelegate {
307306
func authenticationChallenge(_ session: URLSession,
308307
didReceive challenge: URLAuthenticationChallenge,
309308
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
309+
nkLog(debug: "Auth challenge method: \(challenge.protectionSpace.authenticationMethod), host: \(challenge.protectionSpace.host):\(challenge.protectionSpace.port)")
310+
310311
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate {
311312
let label = "client_identity_\(challenge.protectionSpace.host):\(challenge.protectionSpace.port)"
312-
print(label)
313+
313314
if let identity = retrieveIdentityFromKeychain(label: label) {
314315
let credential = URLCredential(identity: identity, certificates: nil, persistence: .forSession)
315316

0 commit comments

Comments
 (0)