diff --git a/Config/Luma-Info.plist b/Config/Luma-Info.plist index 0d31df7..55ccf24 100644 --- a/Config/Luma-Info.plist +++ b/Config/Luma-Info.plist @@ -24,6 +24,8 @@ $(CURRENT_PROJECT_VERSION) NSCameraUsageDescription Luma использует камеру для фотографий, видеосообщений и видеозвонков. + NSFaceIDUsageDescription + Luma использует Face ID для разблокировки приложения. NSLocalNetworkUsageDescription Luma использует локальную сеть для прямого соединения аудио- и видеозвонков. NSLocationWhenInUseUsageDescription diff --git a/Config/LumaMac-Info.plist b/Config/LumaMac-Info.plist index 8bc023e..3530fb8 100644 --- a/Config/LumaMac-Info.plist +++ b/Config/LumaMac-Info.plist @@ -26,6 +26,8 @@ public.app-category.social-networking NSCameraUsageDescription Luma использует камеру для фотографий, видеосообщений и видеозвонков. + NSFaceIDUsageDescription + Luma использует Touch ID для разблокировки приложения. NSHighResolutionCapable NSLocationUsageDescription diff --git a/Docs/SECURITY.md b/Docs/SECURITY.md index 4be86c3..403d918 100644 --- a/Docs/SECURITY.md +++ b/Docs/SECURITY.md @@ -7,6 +7,9 @@ - TLS trust оценивается системным Apple Security framework как сертификат сервера (`SecPolicyCreateSSL(true, ...)`) для домена из JID; hostname и цепочка доверия обязательны даже при ручном адресе подключения. +- Приложение можно заблокировать паролем (хранится в Keychain, выключено по + умолчанию): блокировка срабатывает при запуске и при уходе в фон, а + разблокировка возможна по Face ID/Touch ID. - Пароль для SCRAM нормализуется по RFC 4013 (SASLprep), чтобы совпадать с серверной нормализацией; для PLAIN пароль отправляется как введён. - SCRAM доступен в вариантах SHA-512 (предпочтительный, реализация Luma), @@ -14,11 +17,6 @@ - Канал шифруется STARTTLS/direct TLS через SecureTransport с уровнем `negotiatedSSL` (исключает TLS ниже 1.2) и ALPN «xmpp-client»; на системах с поддержкой SecureTransport договаривается TLS 1.3. -- SCRAM доступен в вариантах SHA-512 (предпочтительный, реализация Luma), - SHA-256 и SHA-1 (Martin); механизм выбирается по рекламе сервера. -- Канал шифруется STARTTLS/direct TLS через SecureTransport с уровнем - `negotiatedSSL` (исключает TLS ниже 1.2) и ALPN «xmpp-client»; на системах - с поддержкой SecureTransport договаривается TLS 1.3. - Когда OMEMO включено глобально или для конкретного чата, исходящие сообщения не откатываются на plaintext при ошибке: ошибка показывается пользователю. - Пользователь может осознанно отключить OMEMO глобально или для отдельного diff --git a/Luma.xcodeproj/project.pbxproj b/Luma.xcodeproj/project.pbxproj index 6f360c0..02632b8 100644 --- a/Luma.xcodeproj/project.pbxproj +++ b/Luma.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 127C4B7A5083081D22FD21CD /* LumaScramSha512Mechanism.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C16CFC03E7F478838A704FD /* LumaScramSha512Mechanism.swift */; }; 12D3BEB345B65FB73DD3F560 /* ChatScrollPositionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F0A8A1646FDEE9C07FD9F0 /* ChatScrollPositionPolicyTests.swift */; }; 137816B417A865C97F935A00 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = EFCAF9B0DE67466AF22E76C3 /* WebRTC */; }; + 14059C4EF59FBBDCC5338420 /* AppLockPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47680B7C615374A8C245818F /* AppLockPolicy.swift */; }; 1655A2B7ACF4003EF41A1AB8 /* ConversationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 758B448979C845F56168A51F /* ConversationRow.swift */; }; 18606BEB0A2ABB7FDD995645 /* AvatarCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32DBAB8D25603892C7D67A23 /* AvatarCache.swift */; }; 18627D523DEC6B1A3064906D /* AttachmentPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78E78CC8582C0399901D8A27 /* AttachmentPreviewView.swift */; }; @@ -51,6 +52,7 @@ 2822384FC6AF9685C4D014C1 /* MediaMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 278FC97C7F6D41B638436EA7 /* MediaMetadata.swift */; }; 28D758624032D813C607DE5B /* MediaSendActivityTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A69C6820279A445E9CA59FB3 /* MediaSendActivityTracker.swift */; }; 2987B54F6E358AAE2EE19993 /* AppAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 184902980B7EE4B8DDEA907D /* AppAssets.xcassets */; }; + 2BC210A585AAA9D50ECC9A4B /* AppLockPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47680B7C615374A8C245818F /* AppLockPolicy.swift */; }; 2C5BAAEF4EC7AAE5FE0FEEDB /* MediaViewerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F7B01C59E9667ADAFABBAC /* MediaViewerItem.swift */; }; 2C963DA7FDA2A2BCA075507F /* CallSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6079CE75BAB995515A8D5460 /* CallSnapshot.swift */; }; 2D32E57F5CE1BF2EAE078492 /* DeviceResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 876CDF98063A4C948B00C7CE /* DeviceResource.swift */; }; @@ -87,6 +89,7 @@ 4A3844CC51129576741753E7 /* MediaPlaybackCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26ADAEFB7CB8441CA1A50C44 /* MediaPlaybackCoordinator.swift */; }; 4B2CFA4008D1234DCE35ADDE /* MediaSendActivityTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 247FBE680C61D12AFB583862 /* MediaSendActivityTrackerTests.swift */; }; 4CD33DF0D193A7EED84A673F /* CallSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6079CE75BAB995515A8D5460 /* CallSnapshot.swift */; }; + 4DE3B27CFA3D524590FB751A /* AppLockPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFFA3AEE3D9FB450B77AEC10 /* AppLockPolicyTests.swift */; }; 4E1B116771922F92FEEB079B /* ArchiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */; }; 4EFADFE085B42E0B0593E501 /* ArchiveSyncCheckpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */; }; 51AE33BE56ED4543014119D0 /* ConversationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 758B448979C845F56168A51F /* ConversationRow.swift */; }; @@ -96,6 +99,7 @@ 5459CF1808474747AF46A366 /* LumaConnectionStatsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */; }; 5875F7D2870C985287B36AFA /* MessageReplyFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCAACA105F4B9699F59805F9 /* MessageReplyFallbackTests.swift */; }; 58A606B016094F1845CB0119 /* LocationMessagePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96E7970930AB7F09CB5DA9C /* LocationMessagePreview.swift */; }; + 5D80C9CBCE79DE69A7A6ABA4 /* AppLockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */; }; 60CE79BB4D0FB53E126354AA /* GroupConversationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */; }; 6156698A5A514CA01411489D /* VideoAttachmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */; }; 61D41048E5021EEAC434DD90 /* CallSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.swift */; }; @@ -132,6 +136,7 @@ 7F211C3BBA81E5D43D3A88AE /* LumaCallEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */; }; 7F4C00E98574C8E5193F442E /* LumaRosterStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */; }; 7F7BA6805C1D1EBCEAFDD3AD /* ChatTimelineEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13E35F7F0CB0243311FCDD61 /* ChatTimelineEntry.swift */; }; + 7FBD6C44C2CA9D50E28E823A /* AppLockVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF29003DFA43387FC2FB6F4 /* AppLockVault.swift */; }; 814FE6C4CB53207F236E9236 /* LocationPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2786A15415F65305F78D892C /* LocationPickerView.swift */; }; 822F3F8A5C87C493A8974D6B /* ArchiveMessageBatchPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D93B7CC521421C23459A9A4 /* ArchiveMessageBatchPolicyTests.swift */; }; 8629497F7160C95747886EE4 /* ForwardMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81213335D275609C38056220 /* ForwardMessageView.swift */; }; @@ -143,6 +148,7 @@ 8DE9617B5B0CBCA875C299F9 /* WatchVoiceRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A35C9A7B52458996513382 /* WatchVoiceRecorder.swift */; }; 902C6AD49443720ED4E4E43C /* ComposerRecordingGestureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F5960729C94DC995374CFD6 /* ComposerRecordingGestureTests.swift */; }; 9089C00D2A0766A75B1EB93D /* CryptoKitAESGCMEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F69AB3930D5E93CB77EA9C7D /* CryptoKitAESGCMEngineTests.swift */; }; + 920CB33C235AF4BDB4107BB7 /* AppLockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */; }; 9393464C4C3BE5F9546FE8BB /* EncryptionPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D187FF75D77DB6CCC811600 /* EncryptionPreference.swift */; }; 939ABD4DED652588957491CF /* ServerInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */; }; 94FB3597E01FC508F872861E /* MessageReplySwipePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC7DFD3F92530B411DEEC1F /* MessageReplySwipePolicy.swift */; }; @@ -181,6 +187,7 @@ BE4D5853DAC0D44A6132D800 /* AudioMessagePlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D724713196AD2063C7FAD08F /* AudioMessagePlayer.swift */; }; BEF48E72571CF850AAA7F17F /* EmojiCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */; }; C1AB954660804F7925607038 /* LumaSaslFailureModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E21A7D359AF7A7AF97D8DE /* LumaSaslFailureModule.swift */; }; + C23C74AEDA88885A209C9BB1 /* AppLockVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF29003DFA43387FC2FB6F4 /* AppLockVault.swift */; }; C2CC9BABAE68FA9258694766 /* MessageReplyFallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */; }; C7AE34FF0A0A25D0C81D3271 /* RTCVideoRendererView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */; }; C7B799E6A90CA49208328F15 /* ArchiveSyncCheckpointTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2678D358CD6B7DD331AABD1 /* ArchiveSyncCheckpointTests.swift */; }; @@ -316,11 +323,13 @@ 33F90AC63C822D9DF95D7B5C /* VideoNoteStopPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteStopPolicyTests.swift; sourceTree = ""; }; 375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSnapshotTests.swift; sourceTree = ""; }; 37C24C432306C3FDF7F7DB7D /* GeoLocationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoLocationTests.swift; sourceTree = ""; }; + 3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockView.swift; sourceTree = ""; }; 3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveStore.swift; sourceTree = ""; }; 3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewProcessor.swift; sourceTree = ""; }; 3CDA85EA65BC449733C67084 /* ArchiveMessageBatchPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMessageBatchPolicy.swift; sourceTree = ""; }; 4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewer.swift; sourceTree = ""; }; 43A4CE3E678096BA76F2988C /* NewChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = ""; }; + 47680B7C615374A8C245818F /* AppLockPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockPolicy.swift; sourceTree = ""; }; 5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCVideoRendererView.swift; sourceTree = ""; }; 5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMSHA512Tests.swift; sourceTree = ""; }; 515DD6F1F80A848C39EBAE84 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; @@ -339,6 +348,7 @@ 6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReplyFallback.swift; sourceTree = ""; }; 6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPolicy.swift; sourceTree = ""; }; 6EA8DF39BD360C0F8BA5F62F /* AvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarView.swift; sourceTree = ""; }; + 6EF29003DFA43387FC2FB6F4 /* AppLockVault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockVault.swift; sourceTree = ""; }; 72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaCallEngine.swift; sourceTree = ""; }; 758B448979C845F56168A51F /* ConversationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationRow.swift; sourceTree = ""; }; 76FEB73D4B67CB98C93F6244 /* MediaTimeFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaTimeFormatterTests.swift; sourceTree = ""; }; @@ -415,6 +425,7 @@ F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPolicyTests.swift; sourceTree = ""; }; F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticDNSSrvResolver.swift; sourceTree = ""; }; F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRosterStore.swift; sourceTree = ""; }; + FFFA3AEE3D9FB450B77AEC10 /* AppLockPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockPolicyTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -464,6 +475,7 @@ isa = PBXGroup; children = ( 2006464EAC88C6E4E2B08AC8 /* AccountConfiguration.swift */, + 47680B7C615374A8C245818F /* AppLockPolicy.swift */, 2F36BB344D0FDF23127DF069 /* AppModel.swift */, 3CDA85EA65BC449733C67084 /* ArchiveMessageBatchPolicy.swift */, D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */, @@ -547,6 +559,7 @@ isa = PBXGroup; children = ( 101587DDD2BB1B2C2073B926 /* AccountConfigurationTests.swift */, + FFFA3AEE3D9FB450B77AEC10 /* AppLockPolicyTests.swift */, 0D93B7CC521421C23459A9A4 /* ArchiveMessageBatchPolicyTests.swift */, ABB0729C69630AE38C2D7BBF /* ArchiveStoreTests.swift */, D2678D358CD6B7DD331AABD1 /* ArchiveSyncCheckpointTests.swift */, @@ -590,6 +603,7 @@ 50062B606E5AC6EF7E5AE47F /* Security */ = { isa = PBXGroup; children = ( + 6EF29003DFA43387FC2FB6F4 /* AppLockVault.swift */, 5886E1E233C129B9317D6059 /* CertificateTrustEvaluator.swift */, CCE381301A55B1C4F333B523 /* CredentialVault.swift */, ); @@ -599,6 +613,7 @@ 6BC9FCFF61F5366ED010F59A /* UI */ = { isa = PBXGroup; children = ( + 3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */, 78E78CC8582C0399901D8A27 /* AttachmentPreviewView.swift */, 9B11FED2D58621C071C3AD14 /* CallView.swift */, 60A97545E6E481D45E0BB20F /* ChatView.swift */, @@ -868,6 +883,9 @@ files = ( 00FCAB7210D005D2C30747CB /* AccountConfiguration.swift in Sources */, 32790D95A577DCE99937B539 /* AccountPreferences.swift in Sources */, + 2BC210A585AAA9D50ECC9A4B /* AppLockPolicy.swift in Sources */, + 7FBD6C44C2CA9D50E28E823A /* AppLockVault.swift in Sources */, + 920CB33C235AF4BDB4107BB7 /* AppLockView.swift in Sources */, B492852FD89322434B0914AE /* AppModel.swift in Sources */, F057C0C84B541A5564D0E7E7 /* ArchiveMessageBatchPolicy.swift in Sources */, 7C89D0095051D751FA245324 /* ArchiveMetadataRecord.swift in Sources */, @@ -977,6 +995,9 @@ files = ( D551F2AA5DCCCCDD8D8FB9BB /* AccountConfiguration.swift in Sources */, 3245372B071227092B8594C0 /* AccountPreferences.swift in Sources */, + 14059C4EF59FBBDCC5338420 /* AppLockPolicy.swift in Sources */, + C23C74AEDA88885A209C9BB1 /* AppLockVault.swift in Sources */, + 5D80C9CBCE79DE69A7A6ABA4 /* AppLockView.swift in Sources */, 21AAF3FB5846B1B54BBEE7ED /* AppModel.swift in Sources */, 9E71E1DC6BAA1E9CA32C5355 /* ArchiveMessageBatchPolicy.swift in Sources */, AF45DBB2E59284475C6F6585 /* ArchiveMetadataRecord.swift in Sources */, @@ -1074,6 +1095,7 @@ buildActionMask = 2147483647; files = ( 6B0B8F3CAC1E5854C6F81E70 /* AccountConfigurationTests.swift in Sources */, + 4DE3B27CFA3D524590FB751A /* AppLockPolicyTests.swift in Sources */, 822F3F8A5C87C493A8974D6B /* ArchiveMessageBatchPolicyTests.swift in Sources */, F320BBE2BE143D15398C46F9 /* ArchiveStoreTests.swift in Sources */, C7B799E6A90CA49208328F15 /* ArchiveSyncCheckpointTests.swift in Sources */, diff --git a/Scripts/verify.sh b/Scripts/verify.sh index e79f1e3..5507466 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -85,7 +85,11 @@ required=( Tests/SaslFailureMessageTests.swift Tests/MediaPreviewProcessorVideoTests.swift Tests/SCRAMSHA512Tests.swift + Tests/AppLockPolicyTests.swift Sources/Shared/XMPP/LumaScramSha512Mechanism.swift + Sources/Shared/Security/AppLockVault.swift + Sources/Shared/Models/AppLockPolicy.swift + Sources/Shared/UI/AppLockView.swift Sources/Shared/XMPP/SASLprep.swift Sources/Shared/XMPP/LumaSaslFailureModule.swift Sources/Shared/XMPP/SaslFailureMessage.swift @@ -331,6 +335,22 @@ grep -q 'addMechanism(LumaScramSha512Mechanism' Sources/Shared/XMPP/XMPPService. echo "SCRAM-SHA-512 must be registered ahead of Martin's mechanisms" exit 1 } +grep -q 'NSFaceIDUsageDescription' project.yml || { + echo "Biometric unlock must declare its usage description" + exit 1 +} +grep -q 'unlockWithBiometrics' Sources/Shared/Models/AppModel.swift || { + echo "The app lock must support biometric unlock" + exit 1 +} +grep -q 'AppLockView' Sources/Shared/UI/RootView.swift || { + echo "The lock screen must cover every app layer" + exit 1 +} +grep -q 'Блокировка приложения' Sources/Shared/UI/SettingsView.swift || { + echo "Settings must offer the app lock toggle" + exit 1 +} grep -q 'func deleteGroupChat' Sources/Shared/Models/AppModel.swift || { echo "Group chats must support local deletion" exit 1 diff --git a/Sources/Shared/Models/AppLockPolicy.swift b/Sources/Shared/Models/AppLockPolicy.swift new file mode 100644 index 0000000..77ff377 --- /dev/null +++ b/Sources/Shared/Models/AppLockPolicy.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Pure policy for the app-lock passcode: minimum length and validation. +/// Kept separate from the Keychain storage so it is unit-testable. +enum AppLockPolicy { + static let minimumLength = 4 + + static func isValid(_ passcode: String) -> Bool { + passcode.count >= minimumLength + } +} + diff --git a/Sources/Shared/Models/AppModel.swift b/Sources/Shared/Models/AppModel.swift index 0190fd7..7003b79 100644 --- a/Sources/Shared/Models/AppModel.swift +++ b/Sources/Shared/Models/AppModel.swift @@ -1,6 +1,7 @@ import AVFoundation import Combine import Foundation +import LocalAuthentication import SwiftData import UniformTypeIdentifiers import WebRTC @@ -56,6 +57,9 @@ final class AppModel: ObservableObject { @Published var errorMessage: String? @Published var informationalMessage: String? @Published var previewURL: URL? + @Published private(set) var isAppLocked = false + @Published private(set) var appLockIsEnabled = false + @Published private(set) var appLockBiometricIsEnabled = false @Published private(set) var mediaViewerItem: MediaViewerItem? @Published private(set) var mediaPreviewURLs: [String: URL] = [:] @Published private(set) var mediaThumbnailData: [String: Data] = [:] @@ -68,6 +72,7 @@ final class AppModel: ObservableObject { private let xmpp: XMPPService private let credentials: CredentialVault private let preferences: AccountPreferences + private let appLock = AppLockVault() private let notifications: NotificationCoordinator private let watchBridge: PhoneWatchBridge private let avatarCache: AvatarCache @@ -129,6 +134,13 @@ final class AppModel: ObservableObject { self.watchBridge = watchBridge self.avatarCache = avatarCache + // The app lock must be initialised before any view reads it; the + // locked state persists only in memory and is re-applied from the + // stored preferences on launch. + appLockIsEnabled = appLock.isEnabled + appLockBiometricIsEnabled = appLock.biometricUnlockEnabled + isAppLocked = appLock.isEnabled && !RuntimeEnvironment.isRunningTests + xmpp.eventHandler = { [weak self] event in self?.consume(event) } @@ -454,12 +466,89 @@ final class AppModel: ObservableObject { if !active { resetTypingState() syncWatch(immediate: true) + if appLockIsEnabled { + isAppLocked = true + } } if active, case .disconnected(_) = connectionStatus { Task { await reconnect() } } } + // MARK: - App lock + + func unlockWithPasscode(_ passcode: String) -> Bool { + guard appLock.verify(passcode: passcode) else { return false } + isAppLocked = false + return true + } + + func unlockWithBiometrics() async -> Bool { + guard appLockIsEnabled, appLockBiometricIsEnabled else { return false } + let context = LAContext() + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) + else { + return false + } + do { + let granted = try await context.evaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + localizedReason: "Разблокируйте Luma" + ) + guard granted else { return false } + isAppLocked = false + return true + } catch { + return false + } + } + + func enableAppLock(passcode: String) -> Bool { + guard AppLockPolicy.isValid(passcode) else { return false } + do { + try appLock.save(passcode: passcode) + appLock.isEnabled = true + appLockIsEnabled = true + isAppLocked = false + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + func disableAppLock(passcode: String) -> Bool { + guard appLock.verify(passcode: passcode) else { return false } + try? appLock.deletePasscode() + appLock.isEnabled = false + appLock.biometricUnlockEnabled = false + appLockIsEnabled = false + appLockBiometricIsEnabled = false + isAppLocked = false + return true + } + + func setAppLockBiometricUnlock(_ enabled: Bool, passcode: String) -> Bool { + guard appLock.verify(passcode: passcode) else { return false } + appLock.biometricUnlockEnabled = enabled + appLockBiometricIsEnabled = enabled + return true + } + + func changeAppLockPasscode(from oldPasscode: String, to newPasscode: String) -> Bool { + guard AppLockPolicy.isValid(newPasscode), appLock.verify(passcode: oldPasscode) else { + return false + } + do { + try appLock.save(passcode: newPasscode) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + func setVideoNoteCaptureActive(_ active: Bool) { videoNoteCaptureIsActive = active updateArchiveSyncSuspensionForMedia() diff --git a/Sources/Shared/Security/AppLockVault.swift b/Sources/Shared/Security/AppLockVault.swift new file mode 100644 index 0000000..3a7ad1f --- /dev/null +++ b/Sources/Shared/Security/AppLockVault.swift @@ -0,0 +1,103 @@ +import Foundation +import Security + +/// Stores the app-lock passcode in the Keychain and the lock preferences in +/// UserDefaults. The lock is a UI-level gate: the passcode is kept only in +/// the Keychain and is never mirrored into memory beyond verification. +final class AppLockVault { + private let service = "app.luma.chat.applock" + private let account = "applock-passcode" + private let enabledKey = "appLockEnabled" + private let biometricKey = "appLockBiometricUnlock" + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + var isEnabled: Bool { + get { defaults.bool(forKey: enabledKey) } + set { defaults.set(newValue, forKey: enabledKey) } + } + + var biometricUnlockEnabled: Bool { + get { defaults.bool(forKey: biometricKey) } + set { defaults.set(newValue, forKey: biometricKey) } + } + + func save(passcode: String) throws { + let data = Data(passcode.utf8) + let baseQuery: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + ] + let updateStatus = SecItemUpdate( + baseQuery as CFDictionary, + [kSecValueData: data] as CFDictionary + ) + if updateStatus == errSecSuccess { + return + } + guard updateStatus == errSecItemNotFound else { + throw AppLockVaultError.unhandled(updateStatus) + } + var insertion = baseQuery + insertion[kSecValueData] = data +#if !os(macOS) + insertion[kSecAttrAccessible] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly +#endif + let addStatus = SecItemAdd(insertion as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw AppLockVaultError.unhandled(addStatus) + } + } + + func deletePasscode() throws { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw AppLockVaultError.unhandled(status) + } + } + + func verify(passcode: String) -> Bool { + guard let stored = storedPasscode() else { return false } + return passcode == stored + } + + private func storedPasscode() -> String? { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, + let data = item as? Data, + let value = String(data: data, encoding: .utf8) else { + return nil + } + return value + } +} + +enum AppLockVaultError: LocalizedError { + case unhandled(OSStatus) + + var errorDescription: String? { + switch self { + case .unhandled(let status): + let message = SecCopyErrorMessageString(status, nil) as String? + return message ?? "Не удалось обратиться к Keychain (\(status))." + } + } +} + diff --git a/Sources/Shared/UI/AppLockView.swift b/Sources/Shared/UI/AppLockView.swift new file mode 100644 index 0000000..fc7a735 --- /dev/null +++ b/Sources/Shared/UI/AppLockView.swift @@ -0,0 +1,314 @@ +import LocalAuthentication +import SwiftUI + +@MainActor +struct AppLockView: View { + @ObservedObject var model: AppModel + @State private var passcode = "" + @State private var attemptFailed = false + @FocusState private var isFocused: Bool + + var body: some View { + ZStack { + LinearGradient( + colors: [ + Color(red: 0.08, green: 0.38, blue: 0.78), + Color(red: 0.12, green: 0.62, blue: 0.96), + Color(red: 0.44, green: 0.84, blue: 0.96), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + VStack(spacing: 22) { + Image(systemName: "lock.fill") + .font(.system(size: 44, weight: .semibold)) + .foregroundStyle(.white) + + Text("Luma заблокирован") + .font(.title2.weight(.bold)) + .foregroundStyle(.white) + + SecureField("Пароль", text: $passcode) + .focused($isFocused) + .textContentType(.password) + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + .autocorrectionDisabled() + .textFieldStyle(.roundedBorder) + .multilineTextAlignment(.center) + .frame(maxWidth: 260) + .onSubmit(submit) + + if attemptFailed { + Text("Неверный пароль") + .font(.caption.weight(.medium)) + .foregroundStyle(.white.opacity(0.95)) + } + + Button(action: submit) { + Text("Разблокировать") + .fontWeight(.semibold) + .frame(maxWidth: 260) + .frame(height: 34) + } + .buttonStyle(.borderedProminent) + .disabled(passcode.isEmpty) + + if biometricAvailable, model.appLockBiometricIsEnabled { + Button { + Task { await biometricUnlock() } + } label: { + Label("Войти по \(biometricName)", systemImage: biometricIcon) + .foregroundStyle(.white) + } + } + } + .padding(28) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + .padding(24) + } + .onAppear { + isFocused = true + } + .task { + // Auto-prompt biometrics once when the lock screen appears. + guard biometricAvailable, model.appLockBiometricIsEnabled else { return } + await biometricUnlock() + } + } + + private var biometricContext: LAContext { + LAContext() + } + + private var biometricAvailable: Bool { + var error: NSError? + return biometricContext.canEvaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + error: &error + ) + } + + private var biometryType: LABiometryType { + biometricContext.biometryType + } + + private var biometricIcon: String { + biometryType == .faceID ? "faceid" : "touchid" + } + + private var biometricName: String { + biometryType == .faceID ? "Face ID" : "Touch ID" + } + + private func submit() { + guard !passcode.isEmpty else { return } + if model.unlockWithPasscode(passcode) { + attemptFailed = false + passcode = "" + } else { + attemptFailed = true + passcode = "" + } + } + + private func biometricUnlock() async { + _ = await model.unlockWithBiometrics() + } +} + +/// Configurable passcode sheet: setting up a new passcode, changing it, or +/// verifying the current one to disable the lock / toggle biometrics. +@MainActor +struct AppLockPasscodeSheet: View { + enum Mode: Identifiable { + case setup + case change + case verify(VerificationAction) + + enum VerificationAction: String { + case disableLock + case enableBiometrics + case disableBiometrics + } + + var id: String { + switch self { + case .setup: return "setup" + case .change: return "change" + case .verify(let action): return "verify-\(action.rawValue)" + } + } + } + + let model: AppModel + let mode: Mode + @Environment(\.dismiss) private var dismiss + + @State private var oldPasscode = "" + @State private var passcode = "" + @State private var repeatedPasscode = "" + @State private var errorText: String? + + var body: some View { + NavigationStack { + Form { + if needsOldPasscode { + Section("Текущий пароль") { + SecureField("Текущий пароль", text: $oldPasscode) + .textContentType(.password) + } + } + Section(title) { + SecureField(fieldLabel, text: $passcode) + .textContentType(.password) + if needsRepeatedPasscode { + SecureField("Повторите пароль", text: $repeatedPasscode) + .textContentType(.password) + } + } + if let errorText { + Section { + Text(errorText) + .font(.caption) + .foregroundStyle(.red) + } + } + } + .navigationTitle(navigationTitle) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Отмена") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Готово", action: submit) + .disabled(!canSubmit) + } + } + } +#if os(macOS) + .frame(minWidth: 420, minHeight: 320) +#endif + } + + private var needsOldPasscode: Bool { + switch mode { + case .change, .verify: + return true + case .setup: + return false + } + } + + private var needsRepeatedPasscode: Bool { + switch mode { + case .setup, .change: + return true + case .verify: + return false + } + } + + private var title: String { + switch mode { + case .setup: return "Новый пароль" + case .change: return "Новый пароль" + case .verify: return "Пароль" + } + } + + private var navigationTitle: String { + switch mode { + case .setup: return "Включить блокировку" + case .change: return "Сменить пароль" + case .verify(let action): + switch action { + case .disableLock: return "Выключить блокировку" + case .enableBiometrics, .disableBiometrics: return "Подтвердите пароль" + } + } + } + + private var fieldLabel: String { + switch mode { + case .setup, .change: return "Пароль (минимум \(AppLockPolicy.minimumLength) символа)" + case .verify: return "Пароль" + } + } + + private var canSubmit: Bool { + switch mode { + case .setup: + return !passcode.isEmpty && !repeatedPasscode.isEmpty + case .change: + return !oldPasscode.isEmpty && !passcode.isEmpty && !repeatedPasscode.isEmpty + case .verify: + return !oldPasscode.isEmpty + } + } + + private func submit() { + errorText = nil + switch mode { + case .setup: + guard AppLockPolicy.isValid(passcode) else { + errorText = "Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа." + return + } + guard passcode == repeatedPasscode else { + errorText = "Пароли не совпадают." + repeatedPasscode = "" + return + } + if model.enableAppLock(passcode: passcode) { + dismiss() + } else { + errorText = model.errorMessage ?? "Не удалось включить блокировку." + } + case .change: + guard AppLockPolicy.isValid(passcode) else { + errorText = "Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа." + return + } + guard passcode == repeatedPasscode else { + errorText = "Пароли не совпадают." + repeatedPasscode = "" + return + } + if model.changeAppLockPasscode(from: oldPasscode, to: passcode) { + dismiss() + } else { + errorText = "Неверный текущий пароль." + oldPasscode = "" + } + case .verify(let action): + switch action { + case .disableLock: + if model.disableAppLock(passcode: oldPasscode) { + dismiss() + } else { + errorText = "Неверный пароль." + oldPasscode = "" + } + case .enableBiometrics: + if model.setAppLockBiometricUnlock(true, passcode: oldPasscode) { + dismiss() + } else { + errorText = "Неверный пароль." + oldPasscode = "" + } + case .disableBiometrics: + if model.setAppLockBiometricUnlock(false, passcode: oldPasscode) { + dismiss() + } else { + errorText = "Неверный пароль." + oldPasscode = "" + } + } + } + } +} + diff --git a/Sources/Shared/UI/RootView.swift b/Sources/Shared/UI/RootView.swift index 66fc817..a239c22 100644 --- a/Sources/Shared/UI/RootView.swift +++ b/Sources/Shared/UI/RootView.swift @@ -7,25 +7,31 @@ struct RootView: View { var body: some View { ZStack { - Group { - if model.account == nil { - LoginView(model: model) - } else { - MainChatView(model: model) - .environment(\.modelContext, model.modelContext) + if model.isAppLocked { + // The lock covers every layer: chat list, media viewer and + // calls stay hidden until the passcode or biometrics succeed. + AppLockView(model: model) + } else { + Group { + if model.account == nil { + LoginView(model: model) + } else { + MainChatView(model: model) + .environment(\.modelContext, model.modelContext) + } } - } - if let item = model.mediaViewerItem { - MediaViewer(item: item, onClose: model.closeMediaViewer) - .transition(.opacity.combined(with: .scale(scale: 0.98))) - .zIndex(100) - } + if let item = model.mediaViewerItem { + MediaViewer(item: item, onClose: model.closeMediaViewer) + .transition(.opacity.combined(with: .scale(scale: 0.98))) + .zIndex(100) + } - if let call = model.activeCall { - CallView(model: model, call: call) - .transition(.opacity.combined(with: .scale(scale: 1.015))) - .zIndex(200) + if let call = model.activeCall { + CallView(model: model, call: call) + .transition(.opacity.combined(with: .scale(scale: 1.015))) + .zIndex(200) + } } } .animation(.easeInOut(duration: 0.22), value: model.account?.id) diff --git a/Sources/Shared/UI/SettingsView.swift b/Sources/Shared/UI/SettingsView.swift index 962d654..16c3e69 100644 --- a/Sources/Shared/UI/SettingsView.swift +++ b/Sources/Shared/UI/SettingsView.swift @@ -1,3 +1,4 @@ +import LocalAuthentication import PhotosUI import SwiftUI @@ -7,6 +8,7 @@ struct SettingsView: View { @State private var showingSignOutConfirmation = false @State private var forgetHistory = false @State private var avatarItem: PhotosPickerItem? + @State private var passcodeSheetMode: AppLockPasscodeSheet.Mode? var body: some View { NavigationStack { @@ -98,6 +100,36 @@ struct SettingsView: View { .foregroundStyle(.secondary) } + Section("Блокировка приложения") { + Toggle("Заблокировать приложение", isOn: Binding( + get: { model.appLockIsEnabled }, + set: { enabled in + if enabled { + passcodeSheetMode = .setup + } else { + passcodeSheetMode = .verify(.disableLock) + } + } + )) + if model.appLockIsEnabled { + Toggle("Вход по \(biometricName)", isOn: Binding( + get: { model.appLockBiometricIsEnabled }, + set: { enabled in + passcodeSheetMode = .verify( + enabled ? .enableBiometrics : .disableBiometrics + ) + } + )) + .disabled(!biometricAvailable) + Button("Сменить пароль") { + passcodeSheetMode = .change + } + } + Text("При включённой блокировке Luma запрашивает пароль при запуске и после сворачивания. Пароль хранится в Keychain.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Приватность чата") { Toggle("Показывать статус набора", isOn: Binding( get: { model.typingIndicatorsEnabled }, @@ -153,12 +185,31 @@ struct SettingsView: View { } } } + .sheet(item: $passcodeSheetMode) { mode in + AppLockPasscodeSheet(model: model, mode: mode) + } } #if os(macOS) .frame(minWidth: 500, minHeight: 560) #endif } + private var biometricContext: LAContext { + LAContext() + } + + private var biometricAvailable: Bool { + var error: NSError? + return biometricContext.canEvaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + error: &error + ) + } + + private var biometricName: String { + biometricContext.biometryType == .faceID ? "Face ID" : "Touch ID" + } + private var connectionTitle: String { switch model.connectionStatus { case .connected: return "Подключено" diff --git a/Tests/AppLockPolicyTests.swift b/Tests/AppLockPolicyTests.swift new file mode 100644 index 0000000..39d7ed3 --- /dev/null +++ b/Tests/AppLockPolicyTests.swift @@ -0,0 +1,21 @@ +import XCTest +@testable import Luma + +final class AppLockPolicyTests: XCTestCase { + func testMinimumLengthIsFour() { + XCTAssertEqual(AppLockPolicy.minimumLength, 4) + } + + func testValidationRejectsShortPasscodes() { + XCTAssertFalse(AppLockPolicy.isValid("")) + XCTAssertFalse(AppLockPolicy.isValid("1")) + XCTAssertFalse(AppLockPolicy.isValid("123")) + } + + func testValidationAcceptsFourOrMoreCharacters() { + XCTAssertTrue(AppLockPolicy.isValid("1234")) + XCTAssertTrue(AppLockPolicy.isValid("пароль")) + XCTAssertTrue(AppLockPolicy.isValid("long-passcode")) + } +} + diff --git a/project.yml b/project.yml index a192300..e1a1cae 100644 --- a/project.yml +++ b/project.yml @@ -72,6 +72,7 @@ targets: NSMicrophoneUsageDescription: Luma использует микрофон для голосовых сообщений и звонков. NSLocalNetworkUsageDescription: Luma использует локальную сеть для прямого соединения аудио- и видеозвонков. NSLocationWhenInUseUsageDescription: Luma использует геопозицию только когда вы выбираете отправку точки в чате. + NSFaceIDUsageDescription: Luma использует Face ID для разблокировки приложения. entitlements: path: Config/Luma.entitlements properties: {} @@ -114,6 +115,7 @@ targets: NSCameraUsageDescription: Luma использует камеру для фотографий, видеосообщений и видеозвонков. NSMicrophoneUsageDescription: Luma использует микрофон для голосовых сообщений и звонков. NSLocationUsageDescription: Luma использует геопозицию только когда вы выбираете отправку точки в чате. + NSFaceIDUsageDescription: Luma использует Touch ID для разблокировки приложения. entitlements: path: Config/LumaMac.entitlements properties: