#8 add app lock with passcode and biometric unlock
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

- Gate every layer behind AppLockView when the lock is enabled and
  lock on launch and on backgrounding; the passcode lives only in the
  Keychain (AppLockVault) and the enable state defaults to off.
- Add the lock section to Settings: enable/setup, change passcode and
  disable all require the current passcode via a shared passcode
  sheet; Face ID / Touch ID unlock prompts automatically and can be
  toggled with passcode confirmation.
- Add NSFaceIDUsageDescription to both app targets, cover the passcode
  policy with tests, guard the feature in verify.sh and document it in
  SECURITY.md.
This commit is contained in:
wt committed 2026-08-29 05:59:55 +07:00
1 parent b0eb44d7a7
commit 0d78e02d2b
13 files changed
+663 -21

No files matched your search

+2
View File
@@ -24,6 +24,8 @@
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSCameraUsageDescription</key>
<string>Luma использует камеру для фотографий, видеосообщений и видеозвонков.</string>
<key>NSFaceIDUsageDescription</key>
<string>Luma использует Face ID для разблокировки приложения.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Luma использует локальную сеть для прямого соединения аудио- и видеозвонков.</string>
<key>NSLocationWhenInUseUsageDescription</key>
+2
View File
@@ -26,6 +26,8 @@
<string>public.app-category.social-networking</string>
<key>NSCameraUsageDescription</key>
<string>Luma использует камеру для фотографий, видеосообщений и видеозвонков.</string>
<key>NSFaceIDUsageDescription</key>
<string>Luma использует Touch ID для разблокировки приложения.</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSLocationUsageDescription</key>
+3 -5
View File
@@ -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 глобально или для отдельного
+22
View File
@@ -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 = "<group>"; };
375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSnapshotTests.swift; sourceTree = "<group>"; };
37C24C432306C3FDF7F7DB7D /* GeoLocationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoLocationTests.swift; sourceTree = "<group>"; };
3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockView.swift; sourceTree = "<group>"; };
3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveStore.swift; sourceTree = "<group>"; };
3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewProcessor.swift; sourceTree = "<group>"; };
3CDA85EA65BC449733C67084 /* ArchiveMessageBatchPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMessageBatchPolicy.swift; sourceTree = "<group>"; };
4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewer.swift; sourceTree = "<group>"; };
43A4CE3E678096BA76F2988C /* NewChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = "<group>"; };
47680B7C615374A8C245818F /* AppLockPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockPolicy.swift; sourceTree = "<group>"; };
5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCVideoRendererView.swift; sourceTree = "<group>"; };
5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMSHA512Tests.swift; sourceTree = "<group>"; };
515DD6F1F80A848C39EBAE84 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
@@ -339,6 +348,7 @@
6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReplyFallback.swift; sourceTree = "<group>"; };
6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPolicy.swift; sourceTree = "<group>"; };
6EA8DF39BD360C0F8BA5F62F /* AvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarView.swift; sourceTree = "<group>"; };
6EF29003DFA43387FC2FB6F4 /* AppLockVault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockVault.swift; sourceTree = "<group>"; };
72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaCallEngine.swift; sourceTree = "<group>"; };
758B448979C845F56168A51F /* ConversationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationRow.swift; sourceTree = "<group>"; };
76FEB73D4B67CB98C93F6244 /* MediaTimeFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaTimeFormatterTests.swift; sourceTree = "<group>"; };
@@ -415,6 +425,7 @@
F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPolicyTests.swift; sourceTree = "<group>"; };
F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticDNSSrvResolver.swift; sourceTree = "<group>"; };
F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRosterStore.swift; sourceTree = "<group>"; };
FFFA3AEE3D9FB450B77AEC10 /* AppLockPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockPolicyTests.swift; sourceTree = "<group>"; };
/* 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 */,
+20
View File
@@ -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
+12
View File
@@ -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
}
}
+89
View File
@@ -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()
+103
View File
@@ -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))."
}
}
}
+314
View File
@@ -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 = ""
}
}
}
}
}
+22 -16
View File
@@ -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)
+51
View File
@@ -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 "Подключено"
+21
View File
@@ -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"))
}
}
+2
View File
@@ -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: