-
Notifications
You must be signed in to change notification settings - Fork 413
Add client certificate authentication support #4261
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems you haven't yet signed a CLA. Please do so here.
Once you do that we will be able to review and accept this pull request.
Thanks!
|
Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Adds mTLS client certificate support across Home Assistant iOS networking (WebSocket + HTTP), with UI and onboarding affordances to import/select .p12 identities per server.
Changes:
- Adds client-certificate handling to WebSocket (custom Starscream/URLSession engine) and to Alamofire sessions used for API + token exchange.
- Persists a selected client certificate on
ConnectionInfoand introduces a Keychain-backed certificate manager. - Adds Settings + Onboarding UI to import
.p12certificates and configure them for a server, plus localization updates.
Reviewed changes
Copilot reviewed 53 out of 55 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| Sources/Shared/Resources/Swiftgen/Strings.swift | Adds SwiftGen string accessors for new client-certificate UI text. |
| Sources/Shared/API/MJPEGStreamer.swift | Marks delegate as final and @unchecked Sendable (Swift 6 concurrency compatibility). |
| Sources/Shared/API/HAAPI.swift | Wires client-certificate handling into API HTTP session and WebSocket connection. |
| Sources/Shared/API/ConnectionInfo.swift | Persists optional clientCertificate and evaluates client-cert auth challenges. |
| Sources/Shared/API/ClientCertificateNativeEngine.swift | Adds URLSession-backed WebSocket engine supporting client-cert challenges. |
| Sources/Shared/API/ClientCertificateManager.swift | Adds Keychain-backed import/list/lookup/delete for PKCS#12 identities. |
| Sources/Shared/API/ClientCertificate.swift | Defines the model for selecting a client certificate by name. |
| Sources/Shared/API/Authentication/TokenManager.swift | Passes configured client certificate into token exchange. |
| Sources/Shared/API/Authentication/AuthenticationAPI.swift | Adds client-cert support to the token exchange Alamofire session. |
| Sources/App/WebView/ConnectivityCheck/ConnectivityChecker.swift | Replaces non-atomic completion flag with a locked atomic bool. |
| Sources/App/Settings/Connection/ConnectionSettingsViewModel.swift | Exposes current client-certificate selection status for UI. |
| Sources/App/Settings/Connection/ConnectionSettingsView.swift | Adds navigation entry to client-certificate settings screen. |
| Sources/App/Settings/Connection/ClientCertificateSettingsView.swift | New Settings UI for importing/selecting/deleting client certificates. |
| Sources/App/Resources/en.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/en-GB.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/bg.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/ca-ES.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/cs.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/cy-GB.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/da.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/de.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/el.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/es.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/es-ES.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/es-MX.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/et.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/fi.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/fr.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/he.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/hu.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/id.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/it.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/ja.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/ko-KR.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/ml.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/nb.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/nl.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/pl-PL.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/pt-BR.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/ru.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/sl.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/sv.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/tr.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/uk.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/vi.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/zh-Hans.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Resources/zh-Hant.lproj/Localizable.strings | Adds/updates client-certificate strings (onboarding + settings). |
| Sources/App/Onboarding/Steps/Servers/OnboardingServersListView.swift | Adds onboarding toolbar import flow for client certificates. |
| Sources/App/Onboarding/API/Steps/OnboardingAuthStepConnectivity.swift | Adds client-certificate support during connectivity pre-step challenges. |
| Sources/App/Onboarding/API/OnboardingAuthLoginViewController.swift | Adds client-certificate support during onboarding auth web session. |
| Sources/App/Onboarding/API/OnboardingAuthDetails.swift | Persists selected certificate through onboarding auth details. |
| Sources/App/Onboarding/API/OnboardingAuth.swift | Propagates onboarding-selected certificate into saved ConnectionInfo. |
| Podfile.lock | Updates dependency lockfile checksums and CocoaPods version metadata. |
| HomeAssistant.xcodeproj/project.pbxproj | Adds new sources and adjusts signing/build settings. |
| Gemfile.lock | Updates Ruby lockfile platforms list. |
| public var errorDescription: String? { | ||
| switch self { | ||
| case .wrongPassword: | ||
| return "The password is incorrect." | ||
| case .noIdentity: | ||
| return "The file does not contain a valid identity." | ||
| case .importFailed(let status): | ||
| return "Failed to import certificate (error \(status))." | ||
| case .saveFailed(let status): | ||
| return "Failed to save certificate (error \(status))." | ||
| case .deleteFailed(let status): | ||
| return "Failed to delete certificate (error \(status))." | ||
| case .readFailed(let status): | ||
| return "Failed to read certificates (error \(status))." | ||
| } |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ClientCertificateError.errorDescription contains hard-coded English strings, but these errors are surfaced to users (e.g., in the import UI). Please localize these messages via the app’s localization system (L10n/Localizable.strings) so they can be translated.
| guard let itemsArray = items as? [[String: Any]], | ||
| let identityDict = itemsArray.first, | ||
| let identityRef = identityDict[kSecImportItemIdentity as String], | ||
| CFGetTypeID(identityRef as CFTypeRef) == SecIdentityGetTypeID() else { | ||
| throw ClientCertificateError.noIdentity | ||
| } | ||
| // swiftlint:disable:next force_cast | ||
| let identity = identityRef as! SecIdentity | ||
|
|
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid as! here; a force-cast will crash if the Keychain import returns an unexpected type. Please safely cast the identity (as? SecIdentity) and throw a meaningful ClientCertificateError if it’s not a SecIdentity.
| // swiftlint:disable:next force_cast | ||
| return result as! SecIdentity? | ||
| } |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid as! here; even though the status is errSecSuccess, a force-cast can still crash if the returned reference is not a SecIdentity. Please use a safe cast (as? SecIdentity) and return nil (or throw) on mismatch.
| Button(L10n.Settings.ConnectionSection.ClientCertificate.Import.importButton) { | ||
| performImport() | ||
| } | ||
| .disabled(importName.isEmpty || importPassword.isEmpty) | ||
| } |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The import button is disabled when the password field is empty, but PKCS#12 files can legitimately have an empty passphrase. This UI currently prevents importing those certificates. Consider allowing an empty password (e.g., disable only when the name is empty).
| if let firstCert = ClientCertificateManager.shared.availableCertificates().first { | ||
| authDetails.clientCertificate = firstCert | ||
| } | ||
|
|
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This automatically assigns the first available client certificate during onboarding. That can unintentionally present the wrong certificate to an arbitrary server and is a security/privacy risk. Please require an explicit user selection (or only set a certificate after detecting a client-certificate challenge and prompting the user).
| if let firstCert = ClientCertificateManager.shared.availableCertificates().first { | |
| authDetails.clientCertificate = firstCert | |
| } |
| Text(cert.name) | ||
| Spacer() | ||
| if selectedCertificate?.name == cert.name { | ||
| Image(systemName: "checkmark") | ||
| .foregroundStyle(.tint) | ||
| } |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This uses Image(systemName:), which is discouraged by the repo’s SwiftLint sf_safe_symbol rule (see .swiftlint.yml:68-73). Please switch to Image(systemSymbol:) with an SFSymbol instead (e.g., .checkmark).
| Button(L10n.Settings.ConnectionSection.ClientCertificate.Import.importButton) { | ||
| performCertificateImport() | ||
| } | ||
| .disabled(certificateImportName.isEmpty || certificateImportPassword.isEmpty) |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The import button is disabled when the password field is empty, but PKCS#12 files can legitimately have an empty passphrase. This UI currently prevents importing those certificates. Consider allowing an empty password (e.g., disable only when the name is empty).
| .disabled(certificateImportName.isEmpty || certificateImportPassword.isEmpty) | |
| .disabled(certificateImportName.isEmpty) |
| CODE_SIGN_ENTITLEMENTS = "Configuration/Entitlements/Extension-ios.entitlements"; | ||
| "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Configuration/Entitlements/Extension-catalyst.entitlements"; | ||
| DEVELOPMENT_TEAM = 25HMR6S4X7; | ||
| INFOPLIST_FILE = Sources/Extensions/Widgets/Resources/Info.plist; |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
DEVELOPMENT_TEAM is being set in the project file. This is a machine/user-specific signing setting and will break builds for other contributors/CI. Please remove this assignment and rely on xcconfig/automatic signing or local overrides instead.
| name: certificateImportName | ||
| ) | ||
| resetCertificateImportState() | ||
| certificateImportSuccessMessage = "Imported \(certificateImportName)" |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The success message is hard-coded and not localized. Please add a localized string (with a placeholder for the certificate name) and use that instead.
| certificateImportSuccessMessage = "Imported \(certificateImportName)" | |
| let successFormat = NSLocalizedString( | |
| "client_certificate_import_success_format", | |
| tableName: "Localizable", | |
| bundle: .main, | |
| value: "Imported %@", | |
| comment: "Success message after importing a client certificate. Placeholder is the certificate name." | |
| ) | |
| certificateImportSuccessMessage = String(format: successFormat, certificateImportName) |
| Text(L10n.Settings.ConnectionSection.ClientCertificate.none) | ||
| Spacer() | ||
| if selectedCertificate == nil { | ||
| Image(systemName: "checkmark") | ||
| .foregroundStyle(.tint) | ||
| } |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This uses Image(systemName:), which is discouraged by the repo’s SwiftLint sf_safe_symbol rule (see .swiftlint.yml:68-73). Please switch to Image(systemSymbol:) with an SFSymbol instead (e.g., .checkmark).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for working on that, I can see it is challenging but thats a good start, besides my comments I would also take into consideration:
- Will it work from Apple Watch? Does any extra UI/logic is needed for that?
- Will it work in background when executing a script from a widget? (Just 1 example)
- How can we add tests to this?
| } | ||
| } | ||
|
|
||
| private class AtomicBool: @unchecked Sendable { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why Atomic bool is needed?
| import UIKit | ||
|
|
||
| class MJPEGStreamerSessionDelegate: SessionDelegate { | ||
| final class MJPEGStreamerSessionDelegate: SessionDelegate, @unchecked Sendable { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This Swift 6 change could be done in a separate PR to keep this one on topic
| import HAKit | ||
| import Starscream | ||
|
|
||
| /// Custom WebSocket engine that supports mTLS client certificate authentication. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How will this be used combined with HAKit? As I wrote above we need to coordinate a PR in HAKit as well to have 1 single approach
|
For the lint error you can use |
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.
|
Fixed the review feedback: Code fixes:
Tests added:
Cleanup:
Still need to do:
|
Add mTLS Client Certificate Support
This PR adds support for mTLS client certificates, which is a major requirement for anyone running Home Assistant behind Cloudflare Tunnels or proxies that require certificate authentication.
Key Changes
ClientCertificateNativeEngine) to handle certificate challenges.AuthenticationAPIto support certificates during token exchange..p12certificates and assign them to servers.Notes
The code might not be 100% perfect yet. Specifically, the HAKit change is currently a patch in the
Pods/directory that should probably be moved upstream later. There are also some Swift 6Sendablewarnings to clean up, but the core functionality is working and tested.Screenshots