Skip to content

Commit 9520bcc

Browse files
Add client certificate authentication support
Introduces support for client certificate authentication, including importing .p12 certificates, selecting certificates in settings, and using them during onboarding and connectivity checks. Updates onboarding flows, view models, and UI to handle certificate selection and import. Adds new localization strings for client certificate features in multiple languages.
1 parent 9c1e91e commit 9520bcc

21 files changed

+1236
-29
lines changed

HomeAssistant.xcodeproj/project.pbxproj

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,13 @@
478478
1A3CC1D75E8EEF7294146BD1 /* ControlFanValueProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A34A5417D650BBBE9D2D7C0 /* ControlFanValueProvider.swift */; };
479479
20226C5AB77E1229852ADDC8 /* Pods_iOS_Extensions_Widgets.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D27653D385E4CEB58E52A350 /* Pods_iOS_Extensions_Widgets.framework */; };
480480
237993F7E11DC585E29EDC7C /* Pods-iOS-Extensions-NotificationService-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 592EED7A6C2444872F11C17B /* Pods-iOS-Extensions-NotificationService-metadata.plist */; };
481+
292306192F296D8D0063FEEE /* ClientCertificateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 292306182F296D8D0063FEEE /* ClientCertificateManager.swift */; };
482+
2923061A2F296D8D0063FEEE /* ClientCertificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 292306172F296D8D0063FEEE /* ClientCertificate.swift */; };
483+
2923061B2F296D8D0063FEEE /* ClientCertificateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 292306182F296D8D0063FEEE /* ClientCertificateManager.swift */; };
484+
2923061C2F296D8D0063FEEE /* ClientCertificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 292306172F296D8D0063FEEE /* ClientCertificate.swift */; };
485+
2923061E2F296EF90063FEEE /* ClientCertificateSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2923061D2F296EF90063FEEE /* ClientCertificateSettingsView.swift */; };
486+
29E6AAA52F298B790087DB45 /* ClientCertificateNativeEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29E6AAA42F298B790087DB45 /* ClientCertificateNativeEngine.swift */; };
487+
29E6AAA62F298B790087DB45 /* ClientCertificateNativeEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29E6AAA42F298B790087DB45 /* ClientCertificateNativeEngine.swift */; };
481488
2F50FC61669812D485E608EC /* Pods-iOS-Extensions-PushProvider-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = E3D5CF14402325076CA105EB /* Pods-iOS-Extensions-PushProvider-metadata.plist */; };
482489
368048FC64829A4E4B82B631 /* Pods_watchOS_WatchExtension_Watch.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A90DD8FC6E4726B7E7187C59 /* Pods_watchOS_WatchExtension_Watch.framework */; };
483490
38A4EBA18ADEEE555AD14F52 /* Pods-iOS-App-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 553A33E097387AA44265DB13 /* Pods-iOS-App-metadata.plist */; };
@@ -2200,6 +2207,10 @@
22002207
207E35C8F1554A9AD616FFA2 /* Pods-iOS-Extensions-Share-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-Share-metadata.plist"; path = "Pods/Pods-iOS-Extensions-Share-metadata.plist"; sourceTree = "<group>"; };
22012208
213EF66D14F92AF8BF2E9E98 /* Pods_iOS_Shared_iOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Shared_iOS.framework; sourceTree = BUILT_PRODUCTS_DIR; };
22022209
287FA864ED0E47B2BB71E1C8 /* Pods-iOS-Shared-iOS-Tests-Shared.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS-Tests-Shared.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared.beta.xcconfig"; sourceTree = "<group>"; };
2210+
292306172F296D8D0063FEEE /* ClientCertificate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificate.swift; sourceTree = "<group>"; };
2211+
292306182F296D8D0063FEEE /* ClientCertificateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificateManager.swift; sourceTree = "<group>"; };
2212+
2923061D2F296EF90063FEEE /* ClientCertificateSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificateSettingsView.swift; sourceTree = "<group>"; };
2213+
29E6AAA42F298B790087DB45 /* ClientCertificateNativeEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificateNativeEngine.swift; sourceTree = "<group>"; };
22032214
29FC93E25AB875716E2F35D4 /* Pods_iOS_Extensions_Intents.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_Intents.framework; sourceTree = BUILT_PRODUCTS_DIR; };
22042215
32DB55A889E2163C52C335D2 /* Pods-iOS-Shared-iOS-Tests-Shared.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS-Tests-Shared.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared.debug.xcconfig"; sourceTree = "<group>"; };
22052216
38BD687E2E320F27D6D576B5 /* Pods-iOS-SharedTesting.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-SharedTesting.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-SharedTesting/Pods-iOS-SharedTesting.release.xcconfig"; sourceTree = "<group>"; };
@@ -4031,6 +4042,7 @@
40314042
42BF8DB32EC4E73700DCB7E7 /* ConnectionSettingsViewModel.swift */,
40324043
1178C4E424D5CEB200FDEC3E /* ConnectionURLView.swift */,
40334044
42AAEE892EC4C57F0049E1F3 /* ConnectionURLViewModel.swift */,
4045+
2923061D2F296EF90063FEEE /* ClientCertificateSettingsView.swift */,
40344046
);
40354047
path = Connection;
40364048
sourceTree = "<group>";
@@ -7047,6 +7059,7 @@
70477059
D014EEAA212928EC008EA6F5 /* API */ = {
70487060
isa = PBXGroup;
70497061
children = (
7062+
29E6AAA42F298B790087DB45 /* ClientCertificateNativeEngine.swift */,
70507063
D014EEA82128E192008EA6F5 /* ConnectionInfo.swift */,
70517064
B66D6B1F2227A2EA009D8B90 /* WatchHelpers.swift */,
70527065
D0BE440B2104224A00C74314 /* Authentication */,
@@ -7068,6 +7081,8 @@
70687081
11F20BFB274D5DA900DFB163 /* Server+Fakes.swift */,
70697082
113A8D48283C7B1700B9DA32 /* PeriodicUpdateManager.swift */,
70707083
11B6774C28303D35006E9B1A /* SecurityExceptions.swift */,
7084+
292306172F296D8D0063FEEE /* ClientCertificate.swift */,
7085+
292306182F296D8D0063FEEE /* ClientCertificateManager.swift */,
70717086
);
70727087
path = API;
70737088
sourceTree = "<group>";
@@ -9296,6 +9311,7 @@
92969311
426E02992EF98F5F008237D4 /* CameraListRow.swift in Sources */,
92979312
B6DD5E6A24940F6F003A0154 /* OpenInFirefoxControllerSwift.swift in Sources */,
92989313
115EF6A72549152F0048597B /* AccountRow.swift in Sources */,
9314+
2923061E2F296EF90063FEEE /* ClientCertificateSettingsView.swift in Sources */,
92999315
111858DF24CB83DF00B8CDDC /* Intents.intentdefinition in Sources */,
93009316
B64BB3A81E9C6551001E8B46 /* WebViewController.swift in Sources */,
93019317
42FCCFE22B9B1B610057783F /* BarcodeScannerCamera.swift in Sources */,
@@ -9674,6 +9690,7 @@
96749690
1110836924AFEFA60027A67A /* Promise+WebhookJson.swift in Sources */,
96759691
4298587F2EB1025E00E33710 /* LocationManager.swift in Sources */,
96769692
420CFC6A2D3F9C40009A94F3 /* WatchConfigTable.swift in Sources */,
9693+
29E6AAA62F298B790087DB45 /* ClientCertificateNativeEngine.swift in Sources */,
96779694
1164D9DF25FB1B9800515E8A /* UIBarButtonItem+Additions.swift in Sources */,
96789695
11B38EF6275C54A300205C7B /* PickAServerError.swift in Sources */,
96799696
426D9C752C9C60B000F278AF /* ControlEntityProvider.swift in Sources */,
@@ -9715,6 +9732,8 @@
97159732
11A3BD2E26192210005237E6 /* LocalPushManager.swift in Sources */,
97169733
4278C9C22C8F226500A7B5F4 /* GuaranteedMessages.swift in Sources */,
97179734
4238DCA42DD1F1E300126434 /* AppSessionValues.swift in Sources */,
9735+
292306192F296D8D0063FEEE /* ClientCertificateManager.swift in Sources */,
9736+
2923061A2F296D8D0063FEEE /* ClientCertificate.swift in Sources */,
97189737
11CFD785273662DF0082D557 /* Server.swift in Sources */,
97199738
116C0C30267EB90F00A992E4 /* UserDefaultsValueSync.swift in Sources */,
97209739
B67CE8B422200F220034C1D0 /* URL+Extensions.swift in Sources */,
@@ -9993,6 +10012,7 @@
999310012
42D3E49C2C5BB88F00444BE6 /* WatchBatterySensor.swift in Sources */,
999410013
11C4629624B19FC700031902 /* URLSessionTask+WebhookPersisted.swift in Sources */,
999510014
11F2F25E25871D6000F61F7C /* NotificationAttachmentParserCamera.swift in Sources */,
10015+
29E6AAA52F298B790087DB45 /* ClientCertificateNativeEngine.swift in Sources */,
999610016
11B63B0A2979A07000D908ED /* AssistIntentHandler.swift in Sources */,
999710017
42196ACE2DA5A49600BD501E /* Bonjour.swift in Sources */,
999810018
1133F59C25F1DA5D00AD776F /* CLLocation+Sanitize.swift in Sources */,
@@ -10112,6 +10132,8 @@
1011210132
4206DE5A2E25055E00142E85 /* WebsiteDataStoreHandler.swift in Sources */,
1011310133
11B38EE8275C54A200205C7B /* WidgetActionsIntentHandler.swift in Sources */,
1011410134
B6D3B4ED225B26900082BB4F /* SensorContainer.swift in Sources */,
10135+
2923061B2F296D8D0063FEEE /* ClientCertificateManager.swift in Sources */,
10136+
2923061C2F296D8D0063FEEE /* ClientCertificate.swift in Sources */,
1011510137
11B38EEE275C54A200205C7B /* FocusStatusIntentHandler.swift in Sources */,
1011610138
427A7CD92EBDFB1700D17841 /* AppArea.swift in Sources */,
1011710139
11C4628E24B128EF00031902 /* WebhookResponseUnhandled.swift in Sources */,

Sources/App/Onboarding/API/OnboardingAuth.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ class OnboardingAuth {
122122
promise = promise.recover { [self] originalError -> Promise<HomeAssistantAPI> in
123123
let authDetails = try OnboardingAuthDetails(baseURL: url)
124124

125+
// Note: Certificate selection happens during mTLS challenge handling
126+
// User must explicitly import and select certificates in settings
127+
125128
return firstly {
126129
performPreSteps(checkPoint: .beforeAuth, authDetails: authDetails, sender: sender)
127130
}.then { [self] in
@@ -200,7 +203,8 @@ private extension ConnectionInfo {
200203
internalHardwareAddresses: nil,
201204
isLocalPushEnabled: false,
202205
securityExceptions: authDetails.exceptions,
203-
connectionAccessSecurityLevel: .undefined
206+
connectionAccessSecurityLevel: .undefined,
207+
clientCertificate: authDetails.clientCertificate
204208
)
205209

206210
// default cloud to on

Sources/App/Onboarding/API/OnboardingAuthDetails.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ class OnboardingAuthDetails: Equatable {
55
var url: URL
66
var scheme: String
77
var exceptions: SecurityExceptions = .init()
8+
var clientCertificate: ClientCertificate?
89

910
init(baseURL: URL) throws {
1011
guard var components = URLComponents(url: baseURL.sanitized(), resolvingAgainstBaseURL: false) else {
@@ -47,6 +48,6 @@ class OnboardingAuthDetails: Equatable {
4748
}
4849

4950
static func == (lhs: OnboardingAuthDetails, rhs: OnboardingAuthDetails) -> Bool {
50-
lhs.url == rhs.url && lhs.scheme == rhs.scheme && lhs.exceptions == rhs.exceptions
51+
lhs.url == rhs.url && lhs.scheme == rhs.scheme && lhs.exceptions == rhs.exceptions && lhs.clientCertificate == rhs.clientCertificate
5152
}
5253
}

Sources/App/Onboarding/API/OnboardingAuthLoginViewController.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,14 @@ class OnboardingAuthLoginViewControllerImpl: UIViewController, OnboardingAuthLog
8484
didReceive challenge: URLAuthenticationChallenge,
8585
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
8686
) {
87+
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate {
88+
if let cert = authDetails.clientCertificate,
89+
let credential = ClientCertificateManager.shared.credential(for: cert) {
90+
completionHandler(.useCredential, credential)
91+
return
92+
}
93+
}
94+
8795
let result = authDetails.exceptions.evaluate(challenge)
8896
completionHandler(result.0, result.1)
8997
}

Sources/App/Onboarding/API/Steps/OnboardingAuthStepConnectivity.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ class OnboardingAuthStepConnectivity: NSObject, OnboardingAuthPreStep, URLSessio
145145
_ session: URLSession,
146146
task: URLSessionTask,
147147
didReceive challenge: URLAuthenticationChallenge,
148-
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
148+
completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
149149
) {
150150
guard let pendingResolver = taskIdentifierToResolver[task.taskIdentifier] else {
151151
completionHandler(.cancelAuthenticationChallenge, nil)
@@ -164,8 +164,13 @@ class OnboardingAuthStepConnectivity: NSObject, OnboardingAuthPreStep, URLSessio
164164
pendingResolver.reject(OnboardingAuthError(kind: .basicAuth))
165165
completionHandler(.cancelAuthenticationChallenge, nil)
166166
case NSURLAuthenticationMethodClientCertificate:
167-
clientCertificateErrorOccurred[task.taskIdentifier] = true
168-
completionHandler(.performDefaultHandling, nil)
167+
if let cert = authDetails.clientCertificate,
168+
let credential = ClientCertificateManager.shared.credential(for: cert) {
169+
completionHandler(.useCredential, credential)
170+
} else {
171+
clientCertificateErrorOccurred[task.taskIdentifier] = true
172+
completionHandler(.performDefaultHandling, nil)
173+
}
169174
default:
170175
pendingResolver
171176
.reject(OnboardingAuthError(kind: .authenticationUnsupported(

Sources/App/Onboarding/Steps/Servers/OnboardingServersListView.swift

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Combine
22
import Shared
33
import SwiftUI
4+
import UniformTypeIdentifiers
45

56
struct OnboardingServersListView: View {
67
enum Constants {
@@ -17,6 +18,13 @@ struct OnboardingServersListView: View {
1718

1819
@State private var showDocumentation = false
1920
@State private var showManualInput = false
21+
@State private var showCertificateImport = false
22+
@State private var certificateImportData: Data?
23+
@State private var certificateImportName = ""
24+
@State private var certificateImportPassword = ""
25+
@State private var certificateImportError: String?
26+
@State private var certificateImportSuccessMessage: String?
27+
@State private var showCertificatePasswordSheet = false
2028
@State private var screenLoaded = false
2129
@State private var autoConnectWorkItem: DispatchWorkItem?
2230
@State private var autoConnectInstance: DiscoveredHomeAssistant?
@@ -97,6 +105,26 @@ struct OnboardingServersListView: View {
97105
viewModel.selectInstance(.init(manualURL: connectURL), presentingController: presentingViewController)
98106
}
99107
}
108+
.fileImporter(
109+
isPresented: $showCertificateImport,
110+
allowedContentTypes: [.pkcs12],
111+
onCompletion: handleCertificateFileImport
112+
)
113+
.sheet(isPresented: $showCertificatePasswordSheet) {
114+
certificatePasswordSheet
115+
}
116+
.alert(
117+
item: Binding<AlertItem?>(
118+
get: {
119+
certificateImportSuccessMessage.map { AlertItem(id: $0, title: $0) }
120+
},
121+
set: {
122+
certificateImportSuccessMessage = $0?.title
123+
}
124+
)
125+
) { item in
126+
Alert(title: Text(item.title), dismissButton: .default(Text(L10n.okLabel)))
127+
}
100128
.fullScreenCover(isPresented: .init(get: {
101129
viewModel.showPermissionsFlow && viewModel.onboardingServer != nil
102130
}, set: { newValue in
@@ -182,7 +210,16 @@ struct OnboardingServersListView: View {
182210
}
183211
}
184212

213+
@ToolbarContentBuilder
185214
private var toolbarItems: some ToolbarContent {
215+
ToolbarItem(placement: .topBarLeading) {
216+
Button {
217+
showCertificateImport = true
218+
} label: {
219+
Image(systemSymbol: .lock)
220+
}
221+
.accessibilityLabel(L10n.Settings.ConnectionSection.ClientCertificate.title)
222+
}
186223
ToolbarItem(placement: .topBarTrailing) {
187224
if prefillURL != nil {
188225
CloseButton {
@@ -389,10 +426,100 @@ struct OnboardingServersListView: View {
389426
.frame(height: 1)
390427
.foregroundStyle(Color(uiColor: .secondaryLabel))
391428
}
429+
430+
// MARK: - Certificate Import
431+
432+
private var certificatePasswordSheet: some View {
433+
NavigationView {
434+
Form {
435+
Section {
436+
TextField(
437+
L10n.Settings.ConnectionSection.ClientCertificate.Import.name,
438+
text: $certificateImportName
439+
)
440+
SecureField(
441+
L10n.Settings.ConnectionSection.ClientCertificate.Import.password,
442+
text: $certificateImportPassword
443+
)
444+
} footer: {
445+
if let error = certificateImportError {
446+
Text(error)
447+
.foregroundStyle(.red)
448+
}
449+
}
450+
451+
Section {
452+
Button(L10n.Settings.ConnectionSection.ClientCertificate.Import.importButton) {
453+
performCertificateImport()
454+
}
455+
.disabled(certificateImportName.isEmpty)
456+
}
457+
}
458+
.navigationTitle(L10n.Settings.ConnectionSection.ClientCertificate.Import.title)
459+
.navigationBarTitleDisplayMode(.inline)
460+
.toolbar {
461+
ToolbarItem(placement: .cancellationAction) {
462+
Button(L10n.Settings.ConnectionSection.ClientCertificate.Import.cancel) {
463+
resetCertificateImportState()
464+
}
465+
}
466+
}
467+
}
468+
}
469+
470+
private func handleCertificateFileImport(_ result: Result<URL, Error>) {
471+
do {
472+
let selectedFile = try result.get()
473+
guard selectedFile.startAccessingSecurityScopedResource() else {
474+
Current.Log.error("Cannot access security scoped resource for certificate")
475+
return
476+
}
477+
defer { selectedFile.stopAccessingSecurityScopedResource() }
478+
479+
certificateImportData = try Data(contentsOf: selectedFile)
480+
certificateImportName = selectedFile.deletingPathExtension().lastPathComponent
481+
certificateImportPassword = ""
482+
certificateImportError = nil
483+
showCertificatePasswordSheet = true
484+
} catch {
485+
Current.Log.error("Error importing certificate file: \(error)")
486+
}
487+
}
488+
489+
private func performCertificateImport() {
490+
guard let data = certificateImportData else { return }
491+
492+
do {
493+
try ClientCertificateManager.shared.importP12(
494+
data: data,
495+
password: certificateImportPassword,
496+
name: certificateImportName
497+
)
498+
resetCertificateImportState()
499+
certificateImportSuccessMessage = "Imported \(certificateImportName)"
500+
} catch let error as ClientCertificateError {
501+
certificateImportError = error.localizedDescription
502+
} catch {
503+
certificateImportError = error.localizedDescription
504+
}
505+
}
506+
507+
private func resetCertificateImportState() {
508+
showCertificatePasswordSheet = false
509+
certificateImportData = nil
510+
certificateImportName = ""
511+
certificateImportPassword = ""
512+
certificateImportError = nil
513+
}
392514
}
393515

394516
#Preview {
395517
NavigationView {
396518
OnboardingServersListView(prefillURL: nil, onboardingStyle: .secondary)
397519
}
398520
}
521+
522+
struct AlertItem: Identifiable {
523+
var id: String
524+
var title: String
525+
}

0 commit comments

Comments
 (0)