diff --git a/.swiftlint.yml b/.swiftlint.yml index 642f224a30..541a5c41eb 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -65,7 +65,7 @@ custom_rules: severity: error excluded: - "Tests" - sf_safe_symbol: + explicit_safe_symbol_usage: name: "Safe SFSymbol" message: "Use `SFSafeSymbols` via `systemSymbol` parameters for type safety." regex: > diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 8064997d24..ac92bc3d16 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -478,6 +478,13 @@ 1A3CC1D75E8EEF7294146BD1 /* ControlFanValueProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A34A5417D650BBBE9D2D7C0 /* ControlFanValueProvider.swift */; }; 20226C5AB77E1229852ADDC8 /* Pods_iOS_Extensions_Widgets.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D27653D385E4CEB58E52A350 /* Pods_iOS_Extensions_Widgets.framework */; }; 237993F7E11DC585E29EDC7C /* Pods-iOS-Extensions-NotificationService-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 592EED7A6C2444872F11C17B /* Pods-iOS-Extensions-NotificationService-metadata.plist */; }; + 292306192F296D8D0063FEEE /* ClientCertificateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 292306182F296D8D0063FEEE /* ClientCertificateManager.swift */; }; + 2923061A2F296D8D0063FEEE /* ClientCertificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 292306172F296D8D0063FEEE /* ClientCertificate.swift */; }; + 2923061B2F296D8D0063FEEE /* ClientCertificateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 292306182F296D8D0063FEEE /* ClientCertificateManager.swift */; }; + 2923061C2F296D8D0063FEEE /* ClientCertificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 292306172F296D8D0063FEEE /* ClientCertificate.swift */; }; + 2923061E2F296EF90063FEEE /* ClientCertificateSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2923061D2F296EF90063FEEE /* ClientCertificateSettingsView.swift */; }; + 29E6AAA52F298B790087DB45 /* ClientCertificateNativeEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29E6AAA42F298B790087DB45 /* ClientCertificateNativeEngine.swift */; }; + 29E6AAA62F298B790087DB45 /* ClientCertificateNativeEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29E6AAA42F298B790087DB45 /* ClientCertificateNativeEngine.swift */; }; 2F50FC61669812D485E608EC /* Pods-iOS-Extensions-PushProvider-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = E3D5CF14402325076CA105EB /* Pods-iOS-Extensions-PushProvider-metadata.plist */; }; 368048FC64829A4E4B82B631 /* Pods_watchOS_WatchExtension_Watch.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A90DD8FC6E4726B7E7187C59 /* Pods_watchOS_WatchExtension_Watch.framework */; }; 38A4EBA18ADEEE555AD14F52 /* Pods-iOS-App-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 553A33E097387AA44265DB13 /* Pods-iOS-App-metadata.plist */; }; @@ -1227,13 +1234,13 @@ 5B715903CB3450FE351399BC /* Pods-iOS-Extensions-Share-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 207E35C8F1554A9AD616FFA2 /* Pods-iOS-Extensions-Share-metadata.plist */; }; 5FFBC80F835393915C4748CF /* Pods_iOS_Extensions_PushProvider.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A370326321B07E5ACE0BCB65 /* Pods_iOS_Extensions_PushProvider.framework */; }; 65286F3B745551AD4090EE6B /* Pods-iOS-SharedTesting-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4053903E4C54A6803204286E /* Pods-iOS-SharedTesting-metadata.plist */; }; - 692BCBBA4EEEABCC76DBBECA /* Database/GRDB+Initialization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */; }; + 692BCBBA4EEEABCC76DBBECA /* GRDB+Initialization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */; }; 6FCEBAA2C8E9C5403055E73D /* IntentFanEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E5E2F9F8F008EEA30C533FD /* IntentFanEntity.swift */; }; 78BE7D5D003D9F8C7486DD69 /* Pods_iOS_Shared_iOS_Tests_Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F4DFB087A3A43F9A526B851 /* Pods_iOS_Shared_iOS_Tests_Shared.framework */; }; 81A0C1BBDEFF4F8C5FC314BE /* Pods_iOS_Extensions_NotificationContent.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1F356D0219C7F8A24234511B /* Pods_iOS_Extensions_NotificationContent.framework */; }; 84F7755EFB03C3F463292ABF /* Pods-watchOS-Shared-watchOS-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6B55CB9064A0477C9F456B6A /* Pods-watchOS-Shared-watchOS-metadata.plist */; }; 8E5FA96C740F1D671966CEA9 /* Pods-iOS-Extensions-NotificationContent-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B613440AEDD4209862503F5D /* Pods-iOS-Extensions-NotificationContent-metadata.plist */; }; - A2F3A140CDD1EF1AEA6DFAB9 /* Database/DatabaseTableProtocol.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */; }; + A2F3A140CDD1EF1AEA6DFAB9 /* DatabaseTableProtocol.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */; }; A5A3C1932BE1F4A40EA78754 /* Pods-iOS-Extensions-Matter-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 392B0C44197C98E2653932A5 /* Pods-iOS-Extensions-Matter-metadata.plist */; }; B6022213226DAC9D00E8DBFE /* ScaledFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6022212226DAC9D00E8DBFE /* ScaledFont.swift */; }; B60248001FBD343000998205 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = B60247FE1FBD343000998205 /* InfoPlist.strings */; }; @@ -1487,7 +1494,7 @@ B6E2D4D52270706300446DFA /* ha-loading.json in Resources */ = {isa = PBXBuildFile; fileRef = B6E2D4D42270706200446DFA /* ha-loading.json */; }; B6E42613215C4333007FEB7E /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D03D891720E0A85200D4F28D /* Shared.framework */; }; B9820AF29664869FD0B25CDF /* Pods_iOS_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DD90A8F251D0671EFAC931ED /* Pods_iOS_App.framework */; }; - BECCC152A4E3F69A8EF5A6F3 /* Database/TableSchemaTests.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */; }; + BECCC152A4E3F69A8EF5A6F3 /* TableSchemaTests.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */; }; C10D762EFE08D347D0538339 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B2F5238669D8A7416FBD2B55 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist */; }; C6478E5ADCB3EB7EC959EB53 /* Pods_iOS_Extensions_Intents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 29FC93E25AB875716E2F35D4 /* Pods_iOS_Extensions_Intents.framework */; }; CA6886D02384DA18A91F37DD /* Pods-iOS-Extensions-Intents-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = E41A4AAEF642A72ACDB6C006 /* Pods-iOS-Extensions-Intents-metadata.plist */; }; @@ -1529,7 +1536,7 @@ D0EEF322214DE56B00D1D360 /* LocationTrigger.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EEF321214DE56B00D1D360 /* LocationTrigger.swift */; }; D0EEF324214DF2B700D1D360 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E857A11CB1CCCC00F96925 /* Utils.swift */; }; D0EEF335214EB77100D1D360 /* CLLocation+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C2C17E20D1F64D00BD810B /* CLLocation+Extensions.swift */; }; - DA6F4C18D66EDBA5DCEAE833 /* Database/DatabaseMigration.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */; }; + DA6F4C18D66EDBA5DCEAE833 /* DatabaseMigration.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */; }; FC8E9421FDB864726918B612 /* Pods-watchOS-WatchExtension-Watch-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */; }; FD3BC66329B9FF8F00B19FBE /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */; }; FD3BC66C29BA00D600B19FBE /* CarPlayEntitiesListTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66B29BA00D600B19FBE /* CarPlayEntitiesListTemplate.swift */; }; @@ -2200,6 +2207,10 @@ 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 = ""; }; 213EF66D14F92AF8BF2E9E98 /* Pods_iOS_Shared_iOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Shared_iOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = ""; }; + 292306172F296D8D0063FEEE /* ClientCertificate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificate.swift; sourceTree = ""; }; + 292306182F296D8D0063FEEE /* ClientCertificateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificateManager.swift; sourceTree = ""; }; + 2923061D2F296EF90063FEEE /* ClientCertificateSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificateSettingsView.swift; sourceTree = ""; }; + 29E6AAA42F298B790087DB45 /* ClientCertificateNativeEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificateNativeEngine.swift; sourceTree = ""; }; 29FC93E25AB875716E2F35D4 /* Pods_iOS_Extensions_Intents.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_Intents.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = ""; }; 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 = ""; }; @@ -3004,7 +3015,7 @@ 574F428FD5AD613411644AE4 /* Pods-iOS-Extensions-PushProvider.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-PushProvider.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-PushProvider/Pods-iOS-Extensions-PushProvider.release.xcconfig"; sourceTree = ""; }; 57B9C3C07B5A002D749B5CDA /* Pods_Tests_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Tests_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 592EED7A6C2444872F11C17B /* Pods-iOS-Extensions-NotificationService-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-NotificationService-metadata.plist"; path = "Pods/Pods-iOS-Extensions-NotificationService-metadata.plist"; sourceTree = ""; }; - 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database/GRDB+Initialization.test.swift"; sourceTree = ""; }; + 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database/GRDB+Initialization.test.swift"; sourceTree = ""; }; 6723A4E97E50C3C9141428D0 /* Pods-iOS-Extensions-Widgets-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-Widgets-metadata.plist"; path = "Pods/Pods-iOS-Extensions-Widgets-metadata.plist"; sourceTree = ""; }; 675CE4281FE5F1920B13D553 /* Pods-iOS-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-App/Pods-iOS-App.debug.xcconfig"; sourceTree = ""; }; 6B55CB9064A0477C9F456B6A /* Pods-watchOS-Shared-watchOS-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-watchOS-Shared-watchOS-metadata.plist"; path = "Pods/Pods-watchOS-Shared-watchOS-metadata.plist"; sourceTree = ""; }; @@ -3016,7 +3027,7 @@ 7A6E8DF7DED57BAD4EF47D11 /* Pods_iOS_Extensions_Today.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_Today.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7D94AB7BD65F15C8FEE0912E /* Pods-iOS-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-App/Pods-iOS-App.release.xcconfig"; sourceTree = ""; }; 80854D28D2FCD1482E92ED31 /* Pods-Tests-App.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tests-App.beta.xcconfig"; path = "Pods/Target Support Files/Pods-Tests-App/Pods-Tests-App.beta.xcconfig"; sourceTree = ""; }; - 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseMigration.test.swift; sourceTree = ""; }; + 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseMigration.test.swift; sourceTree = ""; }; 8965FD50AC78F092CEB5F076 /* Pods-iOS-Extensions-Widgets.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Widgets.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Widgets/Pods-iOS-Extensions-Widgets.beta.xcconfig"; sourceTree = ""; }; 89E1823CF2D12BD3161FCC86 /* Pods-iOS-SharedTesting.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-SharedTesting.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-SharedTesting/Pods-iOS-SharedTesting.debug.xcconfig"; sourceTree = ""; }; 8A34A5417D650BBBE9D2D7C0 /* ControlFanValueProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlFanValueProvider.swift; sourceTree = ""; }; @@ -3030,7 +3041,7 @@ 9C4E5E25229D986B0044C8EC /* HomeAssistant.beta.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = HomeAssistant.beta.xcconfig; sourceTree = ""; }; 9C4E5E27229D992A0044C8EC /* HomeAssistant.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = HomeAssistant.xcconfig; sourceTree = ""; }; 9C7970E308CFEAEAFA05E004 /* Pods-iOS-Extensions-NotificationContent.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-NotificationContent.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-NotificationContent/Pods-iOS-Extensions-NotificationContent.release.xcconfig"; sourceTree = ""; }; - 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/TableSchemaTests.test.swift; sourceTree = ""; }; + 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/TableSchemaTests.test.swift; sourceTree = ""; }; A0CE1C12B4ACF0A6876B6F7F /* Pods-iOS-Extensions-Today.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Today.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Today/Pods-iOS-Extensions-Today.beta.xcconfig"; sourceTree = ""; }; A370326321B07E5ACE0BCB65 /* Pods_iOS_Extensions_PushProvider.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_PushProvider.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A90DD8FC6E4726B7E7187C59 /* Pods_watchOS_WatchExtension_Watch.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_watchOS_WatchExtension_Watch.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -3353,7 +3364,7 @@ B6FD0573228411B200AC45BA /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; B6FD0574228411B200AC45BA /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; B9B49F9D3E32AD45659A0A41 /* Pods-iOS-Extensions-Matter.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Matter.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Matter/Pods-iOS-Extensions-Matter.beta.xcconfig"; sourceTree = ""; }; - BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseTableProtocol.test.swift; sourceTree = ""; }; + BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseTableProtocol.test.swift; sourceTree = ""; }; BED1F3255FAD612BC4670B45 /* Pods-iOS-Extensions-Share.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Share.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Share/Pods-iOS-Extensions-Share.beta.xcconfig"; sourceTree = ""; }; BEE6D44D86AC3F2F3E43950D /* Pods-watchOS-Shared-watchOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-Shared-watchOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-Shared-watchOS/Pods-watchOS-Shared-watchOS.debug.xcconfig"; sourceTree = ""; }; C2563441A5A149C269C5F320 /* Pods-iOS-Shared-iOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS/Pods-iOS-Shared-iOS.release.xcconfig"; sourceTree = ""; }; @@ -4031,6 +4042,7 @@ 42BF8DB32EC4E73700DCB7E7 /* ConnectionSettingsViewModel.swift */, 1178C4E424D5CEB200FDEC3E /* ConnectionURLView.swift */, 42AAEE892EC4C57F0049E1F3 /* ConnectionURLViewModel.swift */, + 2923061D2F296EF90063FEEE /* ClientCertificateSettingsView.swift */, ); path = Connection; sourceTree = ""; @@ -7047,6 +7059,7 @@ D014EEAA212928EC008EA6F5 /* API */ = { isa = PBXGroup; children = ( + 29E6AAA42F298B790087DB45 /* ClientCertificateNativeEngine.swift */, D014EEA82128E192008EA6F5 /* ConnectionInfo.swift */, B66D6B1F2227A2EA009D8B90 /* WatchHelpers.swift */, D0BE440B2104224A00C74314 /* Authentication */, @@ -7068,6 +7081,8 @@ 11F20BFB274D5DA900DFB163 /* Server+Fakes.swift */, 113A8D48283C7B1700B9DA32 /* PeriodicUpdateManager.swift */, 11B6774C28303D35006E9B1A /* SecurityExceptions.swift */, + 292306172F296D8D0063FEEE /* ClientCertificate.swift */, + 292306182F296D8D0063FEEE /* ClientCertificateManager.swift */, ); path = API; sourceTree = ""; @@ -7144,10 +7159,10 @@ 11CB98CC249E637300B05222 /* Version+HA.test.swift */, 11883CC424C12C8A0036A6C6 /* CLLocation+Extensions.test.swift */, 11883CC624C131EE0036A6C6 /* RealmZone.test.swift */, - 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */, - BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */, - 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */, - 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */, + 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */, + BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */, + 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */, + 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */, 11EE9B4B24C5181A00404AF8 /* ModelManager.test.swift */, 11BC9E5424FDB88200B9FBF7 /* ActiveStateManager.test.swift */, 1104FCCE253275CF00B8BE34 /* WatchBackgroundRefreshScheduler.test.swift */, @@ -7998,7 +8013,7 @@ packageReferences = ( 420E64BB2D676B2400A31E86 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, 42B89EA62E05CC54000224A2 /* XCRemoteSwiftPackageReference "WebRTC" */, - 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */, + 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */, 4237E6372E5333370023B673 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, ); productRefGroup = B657A8E71CA646EB00121384 /* Products */; @@ -8525,14 +8540,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks.sh\"\n"; @@ -8592,6 +8603,7 @@ inputPaths = ( ); name = Swiftlint; + alwaysOutOfDate = 1; outputFileListPaths = ( ); outputPaths = ( @@ -8610,6 +8622,7 @@ inputPaths = ( ); name = BuildMaterialDesignIconsFont; + alwaysOutOfDate = 1; outputFileListPaths = ( ); outputPaths = ( @@ -8736,14 +8749,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks.sh\"\n"; @@ -8865,14 +8874,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks.sh\"\n"; @@ -8886,14 +8891,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks.sh\"\n"; @@ -9296,6 +9297,7 @@ 426E02992EF98F5F008237D4 /* CameraListRow.swift in Sources */, B6DD5E6A24940F6F003A0154 /* OpenInFirefoxControllerSwift.swift in Sources */, 115EF6A72549152F0048597B /* AccountRow.swift in Sources */, + 2923061E2F296EF90063FEEE /* ClientCertificateSettingsView.swift in Sources */, 111858DF24CB83DF00B8CDDC /* Intents.intentdefinition in Sources */, B64BB3A81E9C6551001E8B46 /* WebViewController.swift in Sources */, 42FCCFE22B9B1B610057783F /* BarcodeScannerCamera.swift in Sources */, @@ -9674,6 +9676,7 @@ 1110836924AFEFA60027A67A /* Promise+WebhookJson.swift in Sources */, 4298587F2EB1025E00E33710 /* LocationManager.swift in Sources */, 420CFC6A2D3F9C40009A94F3 /* WatchConfigTable.swift in Sources */, + 29E6AAA62F298B790087DB45 /* ClientCertificateNativeEngine.swift in Sources */, 1164D9DF25FB1B9800515E8A /* UIBarButtonItem+Additions.swift in Sources */, 11B38EF6275C54A300205C7B /* PickAServerError.swift in Sources */, 426D9C752C9C60B000F278AF /* ControlEntityProvider.swift in Sources */, @@ -9715,6 +9718,8 @@ 11A3BD2E26192210005237E6 /* LocalPushManager.swift in Sources */, 4278C9C22C8F226500A7B5F4 /* GuaranteedMessages.swift in Sources */, 4238DCA42DD1F1E300126434 /* AppSessionValues.swift in Sources */, + 292306192F296D8D0063FEEE /* ClientCertificateManager.swift in Sources */, + 2923061A2F296D8D0063FEEE /* ClientCertificate.swift in Sources */, 11CFD785273662DF0082D557 /* Server.swift in Sources */, 116C0C30267EB90F00A992E4 /* UserDefaultsValueSync.swift in Sources */, B67CE8B422200F220034C1D0 /* URL+Extensions.swift in Sources */, @@ -9993,6 +9998,7 @@ 42D3E49C2C5BB88F00444BE6 /* WatchBatterySensor.swift in Sources */, 11C4629624B19FC700031902 /* URLSessionTask+WebhookPersisted.swift in Sources */, 11F2F25E25871D6000F61F7C /* NotificationAttachmentParserCamera.swift in Sources */, + 29E6AAA52F298B790087DB45 /* ClientCertificateNativeEngine.swift in Sources */, 11B63B0A2979A07000D908ED /* AssistIntentHandler.swift in Sources */, 42196ACE2DA5A49600BD501E /* Bonjour.swift in Sources */, 1133F59C25F1DA5D00AD776F /* CLLocation+Sanitize.swift in Sources */, @@ -10112,6 +10118,8 @@ 4206DE5A2E25055E00142E85 /* WebsiteDataStoreHandler.swift in Sources */, 11B38EE8275C54A200205C7B /* WidgetActionsIntentHandler.swift in Sources */, B6D3B4ED225B26900082BB4F /* SensorContainer.swift in Sources */, + 2923061B2F296D8D0063FEEE /* ClientCertificateManager.swift in Sources */, + 2923061C2F296D8D0063FEEE /* ClientCertificate.swift in Sources */, 11B38EEE275C54A200205C7B /* FocusStatusIntentHandler.swift in Sources */, 427A7CD92EBDFB1700D17841 /* AppArea.swift in Sources */, 11C4628E24B128EF00031902 /* WebhookResponseUnhandled.swift in Sources */, @@ -10219,10 +10227,10 @@ 11AF4D2C249D965C006C74C0 /* BatterySensor.test.swift in Sources */, 11F2F2B8258728B200F61F7C /* NotificationAttachmentParserURL.test.swift in Sources */, 11883CC724C131EE0036A6C6 /* RealmZone.test.swift in Sources */, - DA6F4C18D66EDBA5DCEAE833 /* Database/DatabaseMigration.test.swift in Sources */, - A2F3A140CDD1EF1AEA6DFAB9 /* Database/DatabaseTableProtocol.test.swift in Sources */, - 692BCBBA4EEEABCC76DBBECA /* Database/GRDB+Initialization.test.swift in Sources */, - BECCC152A4E3F69A8EF5A6F3 /* Database/TableSchemaTests.test.swift in Sources */, + DA6F4C18D66EDBA5DCEAE833 /* DatabaseMigration.test.swift in Sources */, + A2F3A140CDD1EF1AEA6DFAB9 /* DatabaseTableProtocol.test.swift in Sources */, + 692BCBBA4EEEABCC76DBBECA /* GRDB+Initialization.test.swift in Sources */, + BECCC152A4E3F69A8EF5A6F3 /* TableSchemaTests.test.swift in Sources */, 11267D0925BBA9FE00F28E5C /* Updater.test.swift in Sources */, 11A3F08C24ECE88C0018D84F /* WebhookUpdateLocation.test.swift in Sources */, 42FDCA272F0C7EB900C92958 /* EntityRegistry.test.swift in Sources */, @@ -11028,6 +11036,7 @@ GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; @@ -11089,6 +11098,7 @@ GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; @@ -11153,6 +11163,7 @@ GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; @@ -12103,7 +12114,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */ = { + 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */ = { isa = XCLocalSwiftPackageReference; relativePath = Sources/SharedPush; }; @@ -12159,7 +12170,7 @@ }; 4273F7DF2E258827000629F7 /* SharedPush */ = { isa = XCSwiftPackageProductDependency; - package = 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */; + package = 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */; productName = SharedPush; }; 427692E22B98B82500F24321 /* SharedPush */ = { diff --git a/Podfile.lock b/Podfile.lock index bf4bcbe71d..2467f0ea2b 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -270,7 +270,7 @@ SPEC CHECKSUMS: GoogleDataTransport: ea169759df570f4e37bdee1623ec32a7e64e67c4 GoogleUtilities: c2bdc4cf2ce786c4d2e6b3bcfd599a25ca78f06f GRDB.swift: 682e07f771a9100f0bdf40fd0bed57b83ca08e29 - HAKit: 2e0570970efe11fa54ad5cceb5d4c4c3fca4c603 + HAKit: 8628e7a8f87fc30e2b3b67b10920dc846f33a77b Improv-iOS: 8973990c1b1f3e3aed7fc600c8efce95359cadd0 KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51 MBProgressHUD: 3ee5efcc380f6a79a7cc9b363dd669c5e1ae7406 @@ -297,4 +297,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 3e51f6f88d22cb69fd187779f567da9019ee707a -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/Sources/App/Onboarding/API/OnboardingAuth.swift b/Sources/App/Onboarding/API/OnboardingAuth.swift index 55808eea68..a3de5e1085 100644 --- a/Sources/App/Onboarding/API/OnboardingAuth.swift +++ b/Sources/App/Onboarding/API/OnboardingAuth.swift @@ -122,6 +122,9 @@ class OnboardingAuth { promise = promise.recover { [self] originalError -> Promise in let authDetails = try OnboardingAuthDetails(baseURL: url) + // Note: Certificate selection happens during mTLS challenge handling + // User must explicitly import and select certificates in settings + return firstly { performPreSteps(checkPoint: .beforeAuth, authDetails: authDetails, sender: sender) }.then { [self] in @@ -200,7 +203,8 @@ private extension ConnectionInfo { internalHardwareAddresses: nil, isLocalPushEnabled: false, securityExceptions: authDetails.exceptions, - connectionAccessSecurityLevel: .undefined + connectionAccessSecurityLevel: .undefined, + clientCertificate: authDetails.clientCertificate ) // default cloud to on diff --git a/Sources/App/Onboarding/API/OnboardingAuthDetails.swift b/Sources/App/Onboarding/API/OnboardingAuthDetails.swift index 5cca1611c4..9859a41ef8 100644 --- a/Sources/App/Onboarding/API/OnboardingAuthDetails.swift +++ b/Sources/App/Onboarding/API/OnboardingAuthDetails.swift @@ -5,6 +5,7 @@ class OnboardingAuthDetails: Equatable { var url: URL var scheme: String var exceptions: SecurityExceptions = .init() + var clientCertificate: ClientCertificate? init(baseURL: URL) throws { guard var components = URLComponents(url: baseURL.sanitized(), resolvingAgainstBaseURL: false) else { @@ -47,6 +48,6 @@ class OnboardingAuthDetails: Equatable { } static func == (lhs: OnboardingAuthDetails, rhs: OnboardingAuthDetails) -> Bool { - lhs.url == rhs.url && lhs.scheme == rhs.scheme && lhs.exceptions == rhs.exceptions + lhs.url == rhs.url && lhs.scheme == rhs.scheme && lhs.exceptions == rhs.exceptions && lhs.clientCertificate == rhs.clientCertificate } } diff --git a/Sources/App/Onboarding/API/OnboardingAuthLoginViewController.swift b/Sources/App/Onboarding/API/OnboardingAuthLoginViewController.swift index d898eb91ea..09f72e2bf1 100644 --- a/Sources/App/Onboarding/API/OnboardingAuthLoginViewController.swift +++ b/Sources/App/Onboarding/API/OnboardingAuthLoginViewController.swift @@ -84,6 +84,14 @@ class OnboardingAuthLoginViewControllerImpl: UIViewController, OnboardingAuthLog didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { + if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate { + if let cert = authDetails.clientCertificate, + let credential = ClientCertificateManager.shared.credential(for: cert) { + completionHandler(.useCredential, credential) + return + } + } + let result = authDetails.exceptions.evaluate(challenge) completionHandler(result.0, result.1) } diff --git a/Sources/App/Onboarding/API/Steps/OnboardingAuthStepConnectivity.swift b/Sources/App/Onboarding/API/Steps/OnboardingAuthStepConnectivity.swift index 5df3924a3a..7485ae6b01 100644 --- a/Sources/App/Onboarding/API/Steps/OnboardingAuthStepConnectivity.swift +++ b/Sources/App/Onboarding/API/Steps/OnboardingAuthStepConnectivity.swift @@ -145,7 +145,7 @@ class OnboardingAuthStepConnectivity: NSObject, OnboardingAuthPreStep, URLSessio _ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { guard let pendingResolver = taskIdentifierToResolver[task.taskIdentifier] else { completionHandler(.cancelAuthenticationChallenge, nil) @@ -164,8 +164,13 @@ class OnboardingAuthStepConnectivity: NSObject, OnboardingAuthPreStep, URLSessio pendingResolver.reject(OnboardingAuthError(kind: .basicAuth)) completionHandler(.cancelAuthenticationChallenge, nil) case NSURLAuthenticationMethodClientCertificate: - clientCertificateErrorOccurred[task.taskIdentifier] = true - completionHandler(.performDefaultHandling, nil) + if let cert = authDetails.clientCertificate, + let credential = ClientCertificateManager.shared.credential(for: cert) { + completionHandler(.useCredential, credential) + } else { + clientCertificateErrorOccurred[task.taskIdentifier] = true + completionHandler(.performDefaultHandling, nil) + } default: pendingResolver .reject(OnboardingAuthError(kind: .authenticationUnsupported( diff --git a/Sources/App/Onboarding/Steps/Servers/OnboardingServersListView.swift b/Sources/App/Onboarding/Steps/Servers/OnboardingServersListView.swift index 3835b56eb6..5be6b70415 100644 --- a/Sources/App/Onboarding/Steps/Servers/OnboardingServersListView.swift +++ b/Sources/App/Onboarding/Steps/Servers/OnboardingServersListView.swift @@ -1,6 +1,7 @@ import Combine import Shared import SwiftUI +import UniformTypeIdentifiers struct OnboardingServersListView: View { enum Constants { @@ -17,6 +18,13 @@ struct OnboardingServersListView: View { @State private var showDocumentation = false @State private var showManualInput = false + @State private var showCertificateImport = false + @State private var certificateImportData: Data? + @State private var certificateImportName = "" + @State private var certificateImportPassword = "" + @State private var certificateImportError: String? + @State private var certificateImportSuccessMessage: String? + @State private var showCertificatePasswordSheet = false @State private var screenLoaded = false @State private var autoConnectWorkItem: DispatchWorkItem? @State private var autoConnectInstance: DiscoveredHomeAssistant? @@ -97,6 +105,26 @@ struct OnboardingServersListView: View { viewModel.selectInstance(.init(manualURL: connectURL), presentingController: presentingViewController) } } + .fileImporter( + isPresented: $showCertificateImport, + allowedContentTypes: [.pkcs12], + onCompletion: handleCertificateFileImport + ) + .sheet(isPresented: $showCertificatePasswordSheet) { + certificatePasswordSheet + } + .alert( + item: Binding( + get: { + certificateImportSuccessMessage.map { AlertItem(id: $0, title: $0) } + }, + set: { + certificateImportSuccessMessage = $0?.title + } + ) + ) { item in + Alert(title: Text(item.title), dismissButton: .default(Text(L10n.okLabel))) + } .fullScreenCover(isPresented: .init(get: { viewModel.showPermissionsFlow && viewModel.onboardingServer != nil }, set: { newValue in @@ -182,7 +210,16 @@ struct OnboardingServersListView: View { } } + @ToolbarContentBuilder private var toolbarItems: some ToolbarContent { + ToolbarItem(placement: .topBarLeading) { + Button { + showCertificateImport = true + } label: { + Image(systemSymbol: .lock) + } + .accessibilityLabel(L10n.Settings.ConnectionSection.ClientCertificate.title) + } ToolbarItem(placement: .topBarTrailing) { if prefillURL != nil { CloseButton { @@ -389,6 +426,91 @@ struct OnboardingServersListView: View { .frame(height: 1) .foregroundStyle(Color(uiColor: .secondaryLabel)) } + + // MARK: - Certificate Import + + private var certificatePasswordSheet: some View { + NavigationView { + Form { + Section { + TextField( + L10n.Settings.ConnectionSection.ClientCertificate.Import.name, + text: $certificateImportName + ) + SecureField( + L10n.Settings.ConnectionSection.ClientCertificate.Import.password, + text: $certificateImportPassword + ) + } footer: { + if let error = certificateImportError { + Text(error) + .foregroundStyle(.red) + } + } + + Section { + Button(L10n.Settings.ConnectionSection.ClientCertificate.Import.importButton) { + performCertificateImport() + } + .disabled(certificateImportName.isEmpty) + } + } + .navigationTitle(L10n.Settings.ConnectionSection.ClientCertificate.Import.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Settings.ConnectionSection.ClientCertificate.Import.cancel) { + resetCertificateImportState() + } + } + } + } + } + + private func handleCertificateFileImport(_ result: Result) { + do { + let selectedFile = try result.get() + guard selectedFile.startAccessingSecurityScopedResource() else { + Current.Log.error("Cannot access security scoped resource for certificate") + return + } + defer { selectedFile.stopAccessingSecurityScopedResource() } + + certificateImportData = try Data(contentsOf: selectedFile) + certificateImportName = selectedFile.deletingPathExtension().lastPathComponent + certificateImportPassword = "" + certificateImportError = nil + showCertificatePasswordSheet = true + } catch { + Current.Log.error("Error importing certificate file: \(error)") + } + } + + private func performCertificateImport() { + guard let data = certificateImportData else { return } + + do { + try ClientCertificateManager.shared.importP12( + data: data, + password: certificateImportPassword, + name: certificateImportName + ) + resetCertificateImportState() + certificateImportSuccessMessage = "Imported \(certificateImportName)" + } catch let error as ClientCertificateError { + certificateImportError = error.localizedDescription + } catch { + certificateImportError = error.localizedDescription + } + } + + private func resetCertificateImportState() { + showCertificatePasswordSheet = false + certificateImportData = nil + certificateImportName = "" + certificateImportPassword = "" + certificateImportError = nil + } } #Preview { @@ -396,3 +518,8 @@ struct OnboardingServersListView: View { OnboardingServersListView(prefillURL: nil, onboardingStyle: .secondary) } } + +struct AlertItem: Identifiable { + var id: String + var title: String +} diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 5233986e79..6f0647f6a0 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -532,7 +532,7 @@ Tags will work on any device with Home Assistant installed which has hardware su "onboarding.connection_test_result.certificate_error.action_dont_trust" = "Don't Trust"; "onboarding.connection_test_result.certificate_error.action_trust" = "Trust Certificate"; "onboarding.connection_test_result.certificate_error.title" = "Failed to connect securely"; -"onboarding.connection_test_result.client_certificate.description" = "Client Certificate Authentication is not supported."; +"onboarding.connection_test_result.client_certificate.description" = "This server requires a client certificate. Select a certificate installed on this device in Settings."; "onboarding.connection_test_result.error_code" = "Error Code:"; "onboarding.connection_test_result.local_network_permission.description" = "\"Local Network\" privacy permission may have been denied. You can change this in the system Settings app."; "onboarding.device_name_check.error.prompt" = "What device name should be used instead?"; @@ -728,6 +728,18 @@ Home Assistant is open source, advocates for privacy and runs locally in your ho "settings.connection_section.websocket.status.disconnected.title" = "Disconnected"; "settings.connection_section.websocket.status.rejected.title" = "Rejected"; "settings.connection_section.websocket.title" = "WebSocket"; +"settings.connection_section.client_certificate.title" = "Client Certificate"; +"settings.connection_section.client_certificate.none" = "None"; +"settings.connection_section.client_certificate.configured" = "Configured"; +"settings.connection_section.client_certificate.no_certificates" = "No certificates imported"; +"settings.connection_section.client_certificate.import_instructions" = "Tap + to import a .p12 certificate file"; +"settings.connection_section.client_certificate.details.header" = "Selected Certificate"; +"settings.connection_section.client_certificate.details.name" = "Name"; +"settings.connection_section.client_certificate.import.title" = "Import Certificate"; +"settings.connection_section.client_certificate.import.name" = "Name"; +"settings.connection_section.client_certificate.import.password" = "Password"; +"settings.connection_section.client_certificate.import.import_button" = "Import"; +"settings.connection_section.client_certificate.import.cancel" = "Cancel"; "settings.database_explorer.more_fields" = "+%li more fields"; "settings.database_explorer.no_entries" = "No entries found"; "settings.database_explorer.row_detail" = "Row Details"; diff --git a/Sources/App/Settings/Connection/ClientCertificateSettingsView.swift b/Sources/App/Settings/Connection/ClientCertificateSettingsView.swift new file mode 100644 index 0000000000..ddaba06d1c --- /dev/null +++ b/Sources/App/Settings/Connection/ClientCertificateSettingsView.swift @@ -0,0 +1,233 @@ +import Shared +import SwiftUI +import UniformTypeIdentifiers + +struct ClientCertificateSettingsView: View { + let server: Server + @State private var certificates: [ClientCertificate] = [] + @State private var selectedCertificate: ClientCertificate? + @State private var showingImport = false + @State private var importData: Data? + @State private var importName = "" + @State private var importPassword = "" + @State private var showingPasswordSheet = false + @State private var importError: String? + + init(server: Server) { + self.server = server + self._selectedCertificate = State(initialValue: server.info.connection.clientCertificate) + } + + var body: some View { + List { + certificateSection + if selectedCertificate != nil { + selectedCertificateSection + } + } + .navigationTitle(L10n.Settings.ConnectionSection.ClientCertificate.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showingImport = true + } label: { + Image(systemSymbol: .plus) + } + } + } + .fileImporter( + isPresented: $showingImport, + allowedContentTypes: [UTType.pkcs12], + allowsMultipleSelection: false + ) { result in + handleFileImport(result) + } + .sheet(isPresented: $showingPasswordSheet) { + passwordSheet + } + .onAppear { + loadCertificates() + } + } + + private var certificateSection: some View { + Section { + if certificates.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Settings.ConnectionSection.ClientCertificate.noCertificates) + .foregroundStyle(.secondary) + Text(L10n.Settings.ConnectionSection.ClientCertificate.importInstructions) + .font(.caption) + .foregroundStyle(.tertiary) + } + } else { + noneOption + ForEach(certificates, id: \.name) { cert in + certificateRow(for: cert) + } + .onDelete { indexSet in + deleteCertificates(at: indexSet) + } + } + } header: { + Text(L10n.Settings.ConnectionSection.ClientCertificate.title) + } + } + + private var selectedCertificateSection: some View { + Section { + if let cert = selectedCertificate { + HStack { + Text(L10n.Settings.ConnectionSection.ClientCertificate.Details.name) + Spacer() + Text(cert.name) + .foregroundStyle(.secondary) + } + } + } header: { + Text(L10n.Settings.ConnectionSection.ClientCertificate.Details.header) + } + } + + private var noneOption: some View { + Button { + selectCertificate(nil) + } label: { + HStack { + Text(L10n.Settings.ConnectionSection.ClientCertificate.none) + Spacer() + if selectedCertificate == nil { + Image(systemSymbol: .checkmark) + .foregroundStyle(.tint) + } + } + } + .foregroundStyle(.primary) + } + + private func certificateRow(for cert: ClientCertificate) -> some View { + Button { + selectCertificate(cert) + } label: { + HStack { + Text(cert.name) + Spacer() + if selectedCertificate?.name == cert.name { + Image(systemSymbol: .checkmark) + .foregroundStyle(.tint) + } + } + } + .foregroundStyle(.primary) + } + + private var passwordSheet: some View { + NavigationView { + Form { + Section { + TextField( + L10n.Settings.ConnectionSection.ClientCertificate.Import.name, + text: $importName + ) + SecureField( + L10n.Settings.ConnectionSection.ClientCertificate.Import.password, + text: $importPassword + ) + } footer: { + if let error = importError { + Text(error) + .foregroundStyle(.red) + } + } + + Section { + Button(L10n.Settings.ConnectionSection.ClientCertificate.Import.importButton) { + performImport() + } + .disabled(importName.isEmpty) + } + } + .navigationTitle(L10n.Settings.ConnectionSection.ClientCertificate.Import.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Settings.ConnectionSection.ClientCertificate.Import.cancel) { + resetImportState() + } + } + } + } + } + + private func handleFileImport(_ result: Result<[URL], Error>) { + do { + guard let selectedFile = try result.get().first else { return } + guard selectedFile.startAccessingSecurityScopedResource() else { + Current.Log.error("Cannot access security scoped resource") + return + } + defer { selectedFile.stopAccessingSecurityScopedResource() } + + importData = try Data(contentsOf: selectedFile) + importName = selectedFile.deletingPathExtension().lastPathComponent + importPassword = "" + importError = nil + showingPasswordSheet = true + } catch { + Current.Log.error("Error importing file: \(error)") + } + } + + private func performImport() { + guard let data = importData else { return } + + do { + try ClientCertificateManager.shared.importP12( + data: data, + password: importPassword, + name: importName + ) + resetImportState() + loadCertificates() + } catch let error as ClientCertificateError { + importError = error.localizedDescription + } catch { + importError = error.localizedDescription + } + } + + private func resetImportState() { + showingPasswordSheet = false + importData = nil + importName = "" + importPassword = "" + importError = nil + } + + private func loadCertificates() { + certificates = ClientCertificateManager.shared.availableCertificates() + } + + private func selectCertificate(_ certificate: ClientCertificate?) { + selectedCertificate = certificate + server.update { info in + info.connection.clientCertificate = certificate + } + } + + private func deleteCertificates(at indexSet: IndexSet) { + for index in indexSet { + let cert = certificates[index] + do { + try ClientCertificateManager.shared.deleteIdentity(name: cert.name) + if selectedCertificate?.name == cert.name { + selectCertificate(nil) + } + } catch { + Current.Log.error("Error deleting certificate: \(error)") + } + } + loadCertificates() + } +} diff --git a/Sources/App/Settings/Connection/ConnectionSettingsView.swift b/Sources/App/Settings/Connection/ConnectionSettingsView.swift index 13b4e7f352..b91501761c 100644 --- a/Sources/App/Settings/Connection/ConnectionSettingsView.swift +++ b/Sources/App/Settings/Connection/ConnectionSettingsView.swift @@ -276,6 +276,16 @@ struct ConnectionSettingsView: View { } .buttonStyle(.plain) + NavigationLink { + ClientCertificateSettingsView(server: viewModel.server) + } label: { + NavigationRow( + title: L10n.Settings.ConnectionSection.ClientCertificate.title, + value: viewModel.clientCertificateStatus, + valueColor: .secondary + ) + } + Button { viewModel.updateAppDatabase() } label: { diff --git a/Sources/App/Settings/Connection/ConnectionSettingsViewModel.swift b/Sources/App/Settings/Connection/ConnectionSettingsViewModel.swift index b7f5217525..c6f4c2d19f 100644 --- a/Sources/App/Settings/Connection/ConnectionSettingsViewModel.swift +++ b/Sources/App/Settings/Connection/ConnectionSettingsViewModel.swift @@ -47,6 +47,13 @@ final class ConnectionSettingsViewModel: ObservableObject { server.info.version <= .updateLocationGPSOptional } + var clientCertificateStatus: String { + if let cert = server.info.connection.clientCertificate { + return cert.name + } + return L10n.Settings.ConnectionSection.ClientCertificate.none + } + // MARK: - Initialization init(server: Server) { diff --git a/Sources/App/WebView/ConnectivityCheck/ConnectivityChecker.swift b/Sources/App/WebView/ConnectivityCheck/ConnectivityChecker.swift index 93ad627de6..de6619ef5e 100644 --- a/Sources/App/WebView/ConnectivityCheck/ConnectivityChecker.swift +++ b/Sources/App/WebView/ConnectivityCheck/ConnectivityChecker.swift @@ -140,10 +140,10 @@ class ConnectivityChecker { using: .tcp ) - var completed = false + let isCompleted = AtomicBool(false) + let timeoutTask = DispatchWorkItem { - if !completed { - completed = true + if !isCompleted.getAndSet(true) { connection.cancel() continuation.resume(throwing: NSError( domain: "ConnectivityChecker", @@ -154,24 +154,27 @@ class ConnectivityChecker { } connection.stateUpdateHandler = { state in - guard !completed else { return } + if isCompleted.value { return } switch state { case .ready: - completed = true - timeoutTask.cancel() - connection.cancel() - continuation.resume() + if !isCompleted.getAndSet(true) { + timeoutTask.cancel() + connection.cancel() + continuation.resume() + } case let .failed(error): - completed = true - timeoutTask.cancel() - connection.cancel() - continuation.resume(throwing: error) + if !isCompleted.getAndSet(true) { + timeoutTask.cancel() + connection.cancel() + continuation.resume(throwing: error) + } case let .waiting(error): - completed = true - timeoutTask.cancel() - connection.cancel() - continuation.resume(throwing: error) + if !isCompleted.getAndSet(true) { + timeoutTask.cancel() + connection.cancel() + continuation.resume(throwing: error) + } default: break } @@ -182,6 +185,32 @@ class ConnectivityChecker { } } + /// Thread-safe boolean wrapper for NWConnection state handling. + /// NWConnection callbacks can fire from different threads, and we need to ensure + /// that continuation is only resumed once (timeout vs state change race condition). + private class AtomicBool: @unchecked Sendable { + private var _value: Bool + private let lock = NSLock() + + init(_ value: Bool) { + self._value = value + } + + var value: Bool { + lock.lock() + defer { lock.unlock() } + return _value + } + + func getAndSet(_ newValue: Bool) -> Bool { + lock.lock() + defer { lock.unlock() } + let oldValue = _value + _value = newValue + return oldValue + } + } + // MARK: - TLS Certificate Check private func checkTLS(url: URL) async { diff --git a/Sources/Shared/API/Authentication/AuthenticationAPI.swift b/Sources/Shared/API/Authentication/AuthenticationAPI.swift index decfe7d066..cced52b89b 100644 --- a/Sources/Shared/API/Authentication/AuthenticationAPI.swift +++ b/Sources/Shared/API/Authentication/AuthenticationAPI.swift @@ -80,9 +80,17 @@ public class AuthenticationAPI { public static func fetchToken( authorizationCode: String, baseURL: URL, - exceptions: SecurityExceptions + exceptions: SecurityExceptions, + clientCertificate: ClientCertificate? = nil ) -> Promise { - let session = Session(serverTrustManager: CustomServerTrustManager(exceptions: exceptions)) + let delegate = AuthenticationSessionDelegate( + exceptions: exceptions, + clientCertificate: clientCertificate + ) + let session = Session( + delegate: delegate, + serverTrustManager: CustomServerTrustManager(exceptions: exceptions) + ) return Promise { seal in let routeInfo = RouteInfo( @@ -107,6 +115,39 @@ public class AuthenticationAPI { } } +private final class AuthenticationSessionDelegate: SessionDelegate, @unchecked Sendable { + private let exceptions: SecurityExceptions + private let clientCertificate: ClientCertificate? + + init(exceptions: SecurityExceptions, clientCertificate: ClientCertificate?) { + self.exceptions = exceptions + self.clientCertificate = clientCertificate + super.init() + } + + override func urlSession( + _ session: URLSession, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate { + Current.Log.verbose("AuthenticationAPI: Client certificate challenge received") + if let cert = clientCertificate, + let credential = ClientCertificateManager.shared.credential(for: cert) { + Current.Log.info("AuthenticationAPI: Using client certificate: \(cert.name)") + completionHandler(.useCredential, credential) + return + } else { + Current.Log.warning("AuthenticationAPI: No client certificate available") + } + completionHandler(.performDefaultHandling, nil) + } else { + super.urlSession(session, task: task, didReceive: challenge, completionHandler: completionHandler) + } + } +} + extension DataRequest { @discardableResult func validateAuth() -> Self { diff --git a/Sources/Shared/API/Authentication/TokenManager.swift b/Sources/Shared/API/Authentication/TokenManager.swift index d8546acfaa..b63d870dd4 100644 --- a/Sources/Shared/API/Authentication/TokenManager.swift +++ b/Sources/Shared/API/Authentication/TokenManager.swift @@ -60,7 +60,8 @@ public class TokenManager { return AuthenticationAPI.fetchToken( authorizationCode: code, baseURL: url, - exceptions: connectionInfo.securityExceptions + exceptions: connectionInfo.securityExceptions, + clientCertificate: connectionInfo.clientCertificate ) } diff --git a/Sources/Shared/API/ClientCertificate.swift b/Sources/Shared/API/ClientCertificate.swift new file mode 100644 index 0000000000..67db138c8d --- /dev/null +++ b/Sources/Shared/API/ClientCertificate.swift @@ -0,0 +1,9 @@ +import Foundation + +public struct ClientCertificate: Codable, Equatable, Hashable, Sendable { + public let name: String + + public init(name: String) { + self.name = name + } +} diff --git a/Sources/Shared/API/ClientCertificateManager.swift b/Sources/Shared/API/ClientCertificateManager.swift new file mode 100644 index 0000000000..fd98c5c7f1 --- /dev/null +++ b/Sources/Shared/API/ClientCertificateManager.swift @@ -0,0 +1,155 @@ +import Foundation +import Security + +public enum ClientCertificateError: LocalizedError { + case wrongPassword + case noIdentity + case importFailed(OSStatus) + case saveFailed(OSStatus) + case deleteFailed(OSStatus) + case readFailed(OSStatus) + + 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))." + } + } +} + +public final class ClientCertificateManager { + public static let shared = ClientCertificateManager() + + /// Keychain access group for sharing certificates across app extensions (Watch, Widgets) + /// Uses the app's bundle ID to match entitlements configuration + private var accessGroup: String { + AppConstants.BundleID + } + + private init() {} + + public func importP12(data: Data, password: String, name: String) throws { + let options: NSDictionary = [kSecImportExportPassphrase as NSString: password] + var items: CFArray? + let status = SecPKCS12Import(data as NSData, options, &items) + + guard status == errSecSuccess else { + if status == errSecAuthFailed { + throw ClientCertificateError.wrongPassword + } + throw ClientCertificateError.importFailed(status) + } + + guard let itemsArray = items as? [[String: Any]], + let identityDict = itemsArray.first, + let identityRef = identityDict[kSecImportItemIdentity as String], + CFGetTypeID(identityRef as CFTypeRef) == SecIdentityGetTypeID(), + let identity = (identityRef as CFTypeRef) as? SecIdentity else { + throw ClientCertificateError.noIdentity + } + + try saveIdentity(identity, name: name) + } + + public func validateP12(data: Data, password: String) -> Bool { + let options: NSDictionary = [kSecImportExportPassphrase as NSString: password] + var items: CFArray? + let status = SecPKCS12Import(data as NSData, options, &items) + return status == errSecSuccess + } + + private func saveIdentity(_ identity: SecIdentity, name: String) throws { + let existingIdentity = readIdentity(name: name) + if existingIdentity != nil { + try deleteIdentity(name: name) + } + + let attributes: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecValueRef as String: identity, + kSecAttrLabel as String: name, + kSecAttrAccessGroup as String: accessGroup + ] + + let status = SecItemAdd(attributes as CFDictionary, nil) + guard status == errSecSuccess else { + throw ClientCertificateError.saveFailed(status) + } + } + + public func availableCertificates() -> [ClientCertificate] { + let query: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true, + kSecReturnRef as String: true, + kSecAttrAccessGroup as String: accessGroup + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, let items = result as? [[String: Any]] else { + return [] + } + + return items.compactMap { item -> ClientCertificate? in + guard let name = item[kSecAttrLabel as String] as? String else { + return nil + } + return ClientCertificate(name: name) + } + } + + public func readIdentity(name: String) -> SecIdentity? { + let query: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: name, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnRef as String: true, + kSecAttrAccessGroup as String: accessGroup + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess else { + return nil + } + + return result as? SecIdentity + } + + public func credential(for certificate: ClientCertificate) -> URLCredential? { + Current.Log.verbose("Looking up identity for certificate: \(certificate.name)") + guard let identity = readIdentity(name: certificate.name) else { + Current.Log.error("Identity not found in keychain for: \(certificate.name)") + return nil + } + Current.Log.info("Successfully found identity for: \(certificate.name)") + return URLCredential(identity: identity, certificates: nil, persistence: .forSession) + } + + public func deleteIdentity(name: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: name, + kSecAttrAccessGroup as String: accessGroup + ] + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw ClientCertificateError.deleteFailed(status) + } + } +} diff --git a/Sources/Shared/API/ClientCertificateNativeEngine.swift b/Sources/Shared/API/ClientCertificateNativeEngine.swift new file mode 100644 index 0000000000..9cff327ae7 --- /dev/null +++ b/Sources/Shared/API/ClientCertificateNativeEngine.swift @@ -0,0 +1,48 @@ +import Foundation +import HAKit +import Starscream + +/// App-specific wrapper that bridges ClientCertificate to HAClientCertificateEngine. +@available(iOS 13.0, watchOS 6.0, *) +public final class ClientCertificateNativeEngine: NSObject, Engine { + private let haEngine: HAClientCertificateEngine + + public init(clientCertificate: ClientCertificate?, securityExceptions: SecurityExceptions) { + let identity: SecIdentity? = { + guard let cert = clientCertificate else { return nil } + return ClientCertificateManager.shared.readIdentity(name: cert.name) + }() + + self.haEngine = HAClientCertificateEngine( + clientIdentity: identity, + evaluateServerTrust: { serverTrust in + try securityExceptions.evaluate(serverTrust) + } + ) + super.init() + } + + public func register(delegate: EngineDelegate) { + haEngine.register(delegate: delegate) + } + + public func start(request: URLRequest) { + haEngine.start(request: request) + } + + public func stop(closeCode: UInt16) { + haEngine.stop(closeCode: closeCode) + } + + public func forceStop() { + haEngine.forceStop() + } + + public func write(string: String, completion: (() -> Void)?) { + haEngine.write(string: string, completion: completion) + } + + public func write(data: Data, opcode: FrameOpCode, completion: (() -> Void)?) { + haEngine.write(data: data, opcode: opcode, completion: completion) + } +} diff --git a/Sources/Shared/API/ConnectionInfo.swift b/Sources/Shared/API/ConnectionInfo.swift index a4892ee81f..360b9c3d76 100644 --- a/Sources/Shared/API/ConnectionInfo.swift +++ b/Sources/Shared/API/ConnectionInfo.swift @@ -33,6 +33,7 @@ public struct ConnectionInfo: Codable, Equatable { public var webhookSecret: String? public var useCloud: Bool = false public var cloudhookURL: URL? + public var clientCertificate: ClientCertificate? public var connectionAccessSecurityLevel: ConnectionSecurityLevel = .undefined public var internalSSIDs: [String]? { didSet { @@ -84,7 +85,23 @@ public struct ConnectionInfo: Codable, Equatable { public var securityExceptions: SecurityExceptions = .init() public func evaluate(_ challenge: URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) { - securityExceptions.evaluate(challenge) + Current.Log.verbose("evaluate challenge: \(challenge.protectionSpace.authenticationMethod)") + if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate { + Current.Log.info("Client certificate challenge received, clientCertificate: \(String(describing: clientCertificate))") + if let cert = clientCertificate { + Current.Log.info("Using client certificate: \(cert.name)") + if let credential = ClientCertificateManager.shared.credential(for: cert) { + Current.Log.info("Created credential for certificate: \(cert.name)") + return (.useCredential, credential) + } else { + Current.Log.error("Failed to create credential for certificate: \(cert.name)") + } + } else { + Current.Log.error("No client certificate configured for this connection") + } + return (.performDefaultHandling, nil) + } + return securityExceptions.evaluate(challenge) } public init( @@ -98,7 +115,8 @@ public struct ConnectionInfo: Codable, Equatable { internalHardwareAddresses: [String]?, isLocalPushEnabled: Bool, securityExceptions: SecurityExceptions, - connectionAccessSecurityLevel: ConnectionSecurityLevel + connectionAccessSecurityLevel: ConnectionSecurityLevel, + clientCertificate: ClientCertificate? = nil ) { self.externalURL = externalURL self.internalURL = internalURL @@ -111,6 +129,7 @@ public struct ConnectionInfo: Codable, Equatable { self.isLocalPushEnabled = isLocalPushEnabled self.securityExceptions = securityExceptions self.connectionAccessSecurityLevel = connectionAccessSecurityLevel + self.clientCertificate = clientCertificate } public init(from decoder: Decoder) throws { @@ -134,6 +153,10 @@ public struct ConnectionInfo: Codable, Equatable { SecurityExceptions.self, forKey: .securityExceptions ) ?? .init() + self.clientCertificate = try container.decodeIfPresent( + ClientCertificate.self, + forKey: .clientCertificate + ) } public enum URLType: Int, Codable, CaseIterable, CustomStringConvertible, CustomDebugStringConvertible { diff --git a/Sources/Shared/API/HAAPI.swift b/Sources/Shared/API/HAAPI.swift index 21075a554f..4b68d6f8a9 100644 --- a/Sources/Shared/API/HAAPI.swift +++ b/Sources/Shared/API/HAAPI.swift @@ -70,6 +70,17 @@ public class HomeAssistantAPI { connectionInfo: { do { if let activeURL = server.info.connection.activeURL() { + // Create custom engine for mTLS if client certificate is configured + let engine: ClientCertificateNativeEngine? = { + if server.info.connection.clientCertificate != nil { + return ClientCertificateNativeEngine( + clientCertificate: server.info.connection.clientCertificate, + securityExceptions: server.info.connection.securityExceptions + ) + } + return nil + }() + return try .init( url: activeURL, userAgent: HomeAssistantAPI.userAgent, @@ -79,7 +90,8 @@ public class HomeAssistantAPI { try server.info.connection.securityExceptions.evaluate(secTrust) } ) - } + }, + engine: engine ) } else { Current.clientEventStore.addEvent(.init( @@ -105,6 +117,7 @@ public class HomeAssistantAPI { let manager = HomeAssistantAPI.configureSessionManager( urlConfig: urlConfig, + delegate: HAAPISessionDelegate(server: server), interceptor: newInterceptor(), trustManager: newServerTrustManager() ) @@ -115,6 +128,43 @@ public class HomeAssistantAPI { Current.sensors.register(observer: self) } + private final class HAAPISessionDelegate: SessionDelegate, @unchecked Sendable { + private let server: Server + init(server: Server) { + self.server = server + super.init() + } + + override func urlSession( + _ session: URLSession, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate { + Current.Log.verbose("Client certificate challenge received for task \(task.taskIdentifier)") + let (disposition, credential) = server.info.connection.evaluate(challenge) + completionHandler(disposition, credential) + } else { + super.urlSession(session, task: task, didReceive: challenge, completionHandler: completionHandler) + } + } + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate { + Current.Log.verbose("Client certificate challenge received for session") + let (disposition, credential) = server.info.connection.evaluate(challenge) + completionHandler(disposition, credential) + } else { + completionHandler(.performDefaultHandling, nil) + } + } + } + convenience init?() { if let server = Current.servers.all.first { self.init(server: server, urlConfig: .default) diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 2329bf40cc..c77de62240 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -2162,7 +2162,7 @@ public enum L10n { public static var title: String { return L10n.tr("Localizable", "onboarding.connection_test_result.certificate_error.title") } } public enum ClientCertificate { - /// Client Certificate Authentication is not supported. + /// This server requires a client certificate. Select a certificate installed on this device in Settings. public static var description: String { return L10n.tr("Localizable", "onboarding.connection_test_result.client_certificate.description") } } public enum LocalNetworkPermission { @@ -2612,6 +2612,36 @@ public enum L10n { public static var title: String { return L10n.tr("Localizable", "settings.connection_section.always_fallback_internal.confirmation.title") } } } + public enum ClientCertificate { + /// Configured + public static var configured: String { return L10n.tr("Localizable", "settings.connection_section.client_certificate.configured") } + /// Tap + to import a .p12 certificate file + public static var importInstructions: String { return L10n.tr("Localizable", "settings.connection_section.client_certificate.import_instructions") } + /// No certificates imported + public static var noCertificates: String { return L10n.tr("Localizable", "settings.connection_section.client_certificate.no_certificates") } + /// None + public static var `none`: String { return L10n.tr("Localizable", "settings.connection_section.client_certificate.none") } + /// Client Certificate + public static var title: String { return L10n.tr("Localizable", "settings.connection_section.client_certificate.title") } + public enum Details { + /// Selected Certificate + public static var header: String { return L10n.tr("Localizable", "settings.connection_section.client_certificate.details.header") } + /// Name + public static var name: String { return L10n.tr("Localizable", "settings.connection_section.client_certificate.details.name") } + } + public enum Import { + /// Cancel + public static var cancel: String { return L10n.tr("Localizable", "settings.connection_section.client_certificate.import.cancel") } + /// Import + public static var importButton: String { return L10n.tr("Localizable", "settings.connection_section.client_certificate.import.import_button") } + /// Name + public static var name: String { return L10n.tr("Localizable", "settings.connection_section.client_certificate.import.name") } + /// Password + public static var password: String { return L10n.tr("Localizable", "settings.connection_section.client_certificate.import.password") } + /// Import Certificate + public static var title: String { return L10n.tr("Localizable", "settings.connection_section.client_certificate.import.title") } + } + } public enum ConnectionAccessSecurityLevel { /// Connection security level public static var title: String { return L10n.tr("Localizable", "settings.connection_section.connection_access_security_level.title") } diff --git a/Tests/Shared/ClientCertificateManager.test.swift b/Tests/Shared/ClientCertificateManager.test.swift new file mode 100644 index 0000000000..cb75ed2ce3 --- /dev/null +++ b/Tests/Shared/ClientCertificateManager.test.swift @@ -0,0 +1,169 @@ +@testable import Shared +import Security +import XCTest + +class ClientCertificateManagerTests: XCTestCase { + private let testCertName = "unit_test_cert_\(UUID().uuidString)" + + override func tearDownWithError() throws { + try super.tearDownWithError() + // Clean up test certificate if it exists + try? ClientCertificateManager.shared.deleteIdentity(name: testCertName) + } + + // MARK: - P12 Validation Tests + + func testValidateP12WithCorrectPassword() { + // Given a valid P12 file and correct password + guard let p12Data = loadTestP12() else { + // Skip test if no test certificate available + throw XCTSkip("No test P12 certificate available") + } + + // When validating with correct password + let isValid = ClientCertificateManager.shared.validateP12(data: p12Data, password: "test") + + // Then validation should succeed + XCTAssertTrue(isValid) + } + + func testValidateP12WithWrongPassword() { + // Given a valid P12 file + guard let p12Data = loadTestP12() else { + throw XCTSkip("No test P12 certificate available") + } + + // When validating with wrong password + let isValid = ClientCertificateManager.shared.validateP12(data: p12Data, password: "wrong_password") + + // Then validation should fail + XCTAssertFalse(isValid) + } + + func testValidateP12WithInvalidData() { + // Given invalid data + let invalidData = Data("not a p12 file".utf8) + + // When validating + let isValid = ClientCertificateManager.shared.validateP12(data: invalidData, password: "any") + + // Then validation should fail + XCTAssertFalse(isValid) + } + + // MARK: - Certificate Lifecycle Tests + + func testAvailableCertificatesReturnsArray() { + // When querying available certificates + let certificates = ClientCertificateManager.shared.availableCertificates() + + // Then should return an array (may be empty) + XCTAssertNotNil(certificates) + } + + func testReadIdentityForNonExistentCertificateReturnsNil() { + // Given a name that doesn't exist + let nonExistentName = "non_existent_cert_\(UUID().uuidString)" + + // When reading identity + let identity = ClientCertificateManager.shared.readIdentity(name: nonExistentName) + + // Then should return nil + XCTAssertNil(identity) + } + + func testCredentialForNonExistentCertificateReturnsNil() { + // Given a certificate reference that doesn't exist + let cert = ClientCertificate(name: "non_existent_\(UUID().uuidString)") + + // When getting credential + let credential = ClientCertificateManager.shared.credential(for: cert) + + // Then should return nil + XCTAssertNil(credential) + } + + func testDeleteNonExistentCertificateSucceeds() { + // Given a name that doesn't exist + let nonExistentName = "non_existent_cert_\(UUID().uuidString)" + + // When deleting (should not throw - errSecItemNotFound is acceptable) + XCTAssertNoThrow(try ClientCertificateManager.shared.deleteIdentity(name: nonExistentName)) + } + + // MARK: - Error Cases + + func testImportP12WithWrongPasswordThrowsError() { + guard let p12Data = loadTestP12() else { + throw XCTSkip("No test P12 certificate available") + } + + // When importing with wrong password + // Then should throw wrongPassword error + XCTAssertThrowsError( + try ClientCertificateManager.shared.importP12(data: p12Data, password: "wrong", name: testCertName) + ) { error in + guard let certError = error as? ClientCertificateError else { + XCTFail("Expected ClientCertificateError") + return + } + if case .wrongPassword = certError { + // Expected error + } else { + XCTFail("Expected wrongPassword error, got: \(certError)") + } + } + } + + func testImportInvalidDataThrowsError() { + // Given invalid data + let invalidData = Data("not a p12 file".utf8) + + // When importing + // Then should throw importFailed error + XCTAssertThrowsError( + try ClientCertificateManager.shared.importP12(data: invalidData, password: "any", name: testCertName) + ) + } + + // MARK: - Helper Methods + + private func loadTestP12() -> Data? { + // Look for test certificate in test bundle + guard let url = Bundle(for: type(of: self)).url(forResource: "test_cert", withExtension: "p12") else { + return nil + } + return try? Data(contentsOf: url) + } +} + +// MARK: - ClientCertificateError Tests + +class ClientCertificateErrorTests: XCTestCase { + func testErrorDescriptions() { + // Verify all error cases have descriptions + let errors: [ClientCertificateError] = [ + .wrongPassword, + .noIdentity, + .importFailed(-25291), + .saveFailed(-25299), + .deleteFailed(-25300), + .readFailed(-25301) + ] + + for error in errors { + XCTAssertNotNil(error.errorDescription, "Error \(error) should have a description") + XCTAssertFalse(error.errorDescription!.isEmpty, "Error \(error) description should not be empty") + } + } + + func testWrongPasswordDescription() { + let error = ClientCertificateError.wrongPassword + XCTAssertEqual(error.errorDescription, "The password is incorrect.") + } + + func testNoIdentityDescription() { + let error = ClientCertificateError.noIdentity + XCTAssertEqual(error.errorDescription, "The file does not contain a valid identity.") + } +} diff --git a/Tests/Shared/ClientCertificateNativeEngine.test.swift b/Tests/Shared/ClientCertificateNativeEngine.test.swift new file mode 100644 index 0000000000..7cf7b9474e --- /dev/null +++ b/Tests/Shared/ClientCertificateNativeEngine.test.swift @@ -0,0 +1,108 @@ +@testable import Shared +import Starscream +import XCTest + +@available(iOS 13.0, watchOS 6.0, *) +class ClientCertificateNativeEngineTests: XCTestCase { + + // MARK: - Initialization Tests + + func testInitWithCertificate() { + // Given a client certificate + let cert = ClientCertificate(name: "test_cert") + let exceptions = SecurityExceptions() + + // When creating engine + let engine = ClientCertificateNativeEngine(clientCertificate: cert, securityExceptions: exceptions) + + // Then engine should be created + XCTAssertNotNil(engine) + } + + func testInitWithoutCertificate() { + // Given no client certificate + let exceptions = SecurityExceptions() + + // When creating engine + let engine = ClientCertificateNativeEngine(clientCertificate: nil, securityExceptions: exceptions) + + // Then engine should be created + XCTAssertNotNil(engine) + } + + // MARK: - Delegate Registration Tests + + func testRegisterDelegate() { + // Given an engine + let engine = ClientCertificateNativeEngine(clientCertificate: nil, securityExceptions: SecurityExceptions()) + let delegate = MockEngineDelegate() + + // When registering delegate + engine.register(delegate: delegate) + + // Then delegate should be registered (implicit - no crash) + XCTAssertTrue(true) + } + + // MARK: - Stop Tests + + func testStopWithNormalClosure() { + // Given an engine + let engine = ClientCertificateNativeEngine(clientCertificate: nil, securityExceptions: SecurityExceptions()) + + // When stopping with normal closure code + engine.stop(closeCode: 1000) + + // Then should not crash + XCTAssertTrue(true) + } + + func testForceStop() { + // Given an engine + let engine = ClientCertificateNativeEngine(clientCertificate: nil, securityExceptions: SecurityExceptions()) + + // When force stopping + engine.forceStop() + + // Then should not crash + XCTAssertTrue(true) + } +} + +// MARK: - Mock Engine Delegate + +private class MockEngineDelegate: EngineDelegate { + var receivedEvents: [WebSocketEvent] = [] + + func didReceive(event: WebSocketEvent) { + receivedEvents.append(event) + } +} + +// MARK: - Authentication Challenge Handling Tests + +@available(iOS 13.0, watchOS 6.0, *) +class ClientCertificateNativeEngineChallengeTests: XCTestCase { + + func testEngineHandlesClientCertificateChallengeType() { + // This test verifies the engine is configured to handle client certificate challenges + // Actual challenge handling requires a live URLSession which is integration-level testing + + let cert = ClientCertificate(name: "test_cert") + let engine = ClientCertificateNativeEngine(clientCertificate: cert, securityExceptions: SecurityExceptions()) + + // Engine should accept certificate configuration + XCTAssertNotNil(engine) + } + + func testEngineHandlesServerTrustChallengeType() { + // This test verifies the engine can be configured with security exceptions + var exceptions = SecurityExceptions() + // Add a mock exception if possible + + let engine = ClientCertificateNativeEngine(clientCertificate: nil, securityExceptions: exceptions) + + // Engine should accept security exceptions configuration + XCTAssertNotNil(engine) + } +}