Skip to content

Conversation

@mariusangelmann
Copy link

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

  • WebSocket mTLS: Patched HAKit and created a custom Starscream engine (ClientCertificateNativeEngine) to handle certificate challenges.
  • Auth Flow: Updated AuthenticationAPI to support certificates during token exchange.
  • UI: Added a new settings section to import .p12 certificates and assign them to servers.
  • Onboarding: Integrated certificate checks into the initial setup flow.

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 6 Sendable warnings to clean up, but the core functionality is working and tested.

Screenshots

Simulator Screenshot - iPhone 17 Pro - 2026-01-28 at 01 56 02 Simulator Screenshot - iPhone 17 Pro - 2026-01-28 at 01 56 18 Simulator Screenshot - iPhone 17 Pro - 2026-01-28 at 01 56 22

Copilot AI review requested due to automatic review settings January 28, 2026 00:58
Copy link

@home-assistant home-assistant bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @mariusangelmann

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!

@home-assistant
Copy link

Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍

Learn more about our pull request process.

Copy link
Contributor

Copilot AI left a 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 ConnectionInfo and introduces a Keychain-backed certificate manager.
  • Adds Settings + Onboarding UI to import .p12 certificates 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.

Comment on lines +12 to +26
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))."
}
Copy link

Copilot AI Jan 28, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines 47 to 60
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

Copy link

Copilot AI Jan 28, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines 122 to 124
// swiftlint:disable:next force_cast
return result as! SecIdentity?
}
Copy link

Copilot AI Jan 28, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines 145 to 149
Button(L10n.Settings.ConnectionSection.ClientCertificate.Import.importButton) {
performImport()
}
.disabled(importName.isEmpty || importPassword.isEmpty)
}
Copy link

Copilot AI Jan 28, 2026

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).

Copilot uses AI. Check for mistakes.
Comment on lines 125 to 128
if let firstCert = ClientCertificateManager.shared.availableCertificates().first {
authDetails.clientCertificate = firstCert
}

Copy link

Copilot AI Jan 28, 2026

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).

Suggested change
if let firstCert = ClientCertificateManager.shared.availableCertificates().first {
authDetails.clientCertificate = firstCert
}

Copilot uses AI. Check for mistakes.
Comment on lines 114 to 119
Text(cert.name)
Spacer()
if selectedCertificate?.name == cert.name {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
}
Copy link

Copilot AI Jan 28, 2026

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).

Copilot uses AI. Check for mistakes.
Button(L10n.Settings.ConnectionSection.ClientCertificate.Import.importButton) {
performCertificateImport()
}
.disabled(certificateImportName.isEmpty || certificateImportPassword.isEmpty)
Copy link

Copilot AI Jan 28, 2026

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).

Suggested change
.disabled(certificateImportName.isEmpty || certificateImportPassword.isEmpty)
.disabled(certificateImportName.isEmpty)

Copilot uses AI. Check for mistakes.
Comment on lines 10846 to 10849
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;
Copy link

Copilot AI Jan 28, 2026

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.

Copilot uses AI. Check for mistakes.
name: certificateImportName
)
resetCertificateImportState()
certificateImportSuccessMessage = "Imported \(certificateImportName)"
Copy link

Copilot AI Jan 28, 2026

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment on lines 98 to 103
Text(L10n.Settings.ConnectionSection.ClientCertificate.none)
Spacer()
if selectedCertificate == nil {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
}
Copy link

Copilot AI Jan 28, 2026

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).

Copilot uses AI. Check for mistakes.
Copy link
Member

@bgoncal bgoncal left a 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 {
Copy link
Member

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 {
Copy link
Member

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.
Copy link
Member

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

@bgoncal
Copy link
Member

bgoncal commented Jan 28, 2026

For the lint error you can use bundle exec fastlane autocorrect

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.
@mariusangelmann
Copy link
Author

mariusangelmann commented Jan 28, 2026

Fixed the review feedback:

Code fixes:

  • Safe casts instead of force casts
  • Image(systemSymbol:) instead of Image(systemName:)
  • Allow empty P12 passwords
  • Removed auto-selecting first cert during onboarding
  • Better variable names in the WebSocket engine
  • Keychain access group for Watch/Widget
  • Reverted MJPEGStreamer Swift 6 change

Tests added:

  • ClientCertificateManager tests
  • ClientCertificateNativeEngine tests

Cleanup:

  • Removed lock files
  • Fixed lint issues

Still need to do:

  • HAKit upstream PR to expose engine parameter

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants