From d4b4c92759d011e9fdb21f15ebf004ea31d0554d Mon Sep 17 00:00:00 2001 From: TolaMironcenko Date: Wed, 2 Sep 2026 11:46:11 +0700 Subject: [PATCH] tls sasl --- Config/Luma-Info.plist | 2 + Config/LumaMac.entitlements | 2 + Docs/SECURITY.md | 38 +- Luma.xcodeproj/project.pbxproj | 83 +++- .../xcshareddata/swiftpm/Package.resolved | 12 +- Scripts/verify.sh | 2 +- Sources/Shared/Models/AppModel.swift | 32 +- Sources/Shared/Models/OMEMODevice.swift | 8 +- .../Models/SASLMechanismPreference.swift | 45 ++ Sources/Shared/Models/ServerInformation.swift | 8 + .../Services/MediaDownloadService.swift | 247 ++++++++++ .../UI/Components/AudioMessagePlayer.swift | 21 + .../Shared/UI/Components/MediaViewer.swift | 37 ++ .../Shared/UI/Components/MessageBubble.swift | 7 + Sources/Shared/UI/EncryptionDevicesView.swift | 9 + Sources/Shared/UI/RootView.swift | 39 ++ Sources/Shared/UI/ServerInfoView.swift | 25 +- Sources/Shared/XMPP/LumaOMEMO2Module.swift | 36 +- Sources/Shared/XMPP/LumaOMEMOStore.swift | 29 +- .../Shared/XMPP/LumaScramPlusMechanism.swift | 271 +++++++++++ .../Shared/XMPP/LumaTLSNetworkProcessor.swift | 443 ++++++++++++++++++ Sources/Shared/XMPP/TLSVersionProbe.swift | 304 ++++++++++++ Sources/Shared/XMPP/XMPPService.swift | 240 +++++++++- THIRD_PARTY_NOTICES.md | 3 + Tests/LumaOMEMO2Tests.swift | 61 ++- Tests/SASLMechanismPreferenceTests.swift | 104 ++++ Tests/SCRAMPlusTests.swift | 106 +++++ UITests/LayoutCheckUITests.swift | 7 +- project.yml | 17 +- 29 files changed, 2177 insertions(+), 61 deletions(-) create mode 100644 Sources/Shared/Models/SASLMechanismPreference.swift create mode 100644 Sources/Shared/Services/MediaDownloadService.swift create mode 100644 Sources/Shared/XMPP/LumaScramPlusMechanism.swift create mode 100644 Sources/Shared/XMPP/LumaTLSNetworkProcessor.swift create mode 100644 Sources/Shared/XMPP/TLSVersionProbe.swift create mode 100644 Tests/SASLMechanismPreferenceTests.swift create mode 100644 Tests/SCRAMPlusTests.swift diff --git a/Config/Luma-Info.plist b/Config/Luma-Info.plist index 55ccf24..68eb2c3 100644 --- a/Config/Luma-Info.plist +++ b/Config/Luma-Info.plist @@ -32,6 +32,8 @@ Luma использует геопозицию только когда вы выбираете отправку точки в чате. NSMicrophoneUsageDescription Luma использует микрофон для голосовых сообщений и звонков. + NSPhotoLibraryAddUsageDescription + Luma сохраняет фотографии и видео в вашу медиатеку. UIApplicationSupportsIndirectInputEvents UIBackgroundModes diff --git a/Config/LumaMac.entitlements b/Config/LumaMac.entitlements index 5c62a47..1dbabeb 100644 --- a/Config/LumaMac.entitlements +++ b/Config/LumaMac.entitlements @@ -10,6 +10,8 @@ com.apple.security.files.user-selected.read-only + com.apple.security.files.user-selected.read-write + com.apple.security.device.camera com.apple.security.device.audio-input diff --git a/Docs/SECURITY.md b/Docs/SECURITY.md index 769a32c..1996fcb 100644 --- a/Docs/SECURITY.md +++ b/Docs/SECURITY.md @@ -12,14 +12,28 @@ разблокировка возможна по Face ID/Touch ID. - Пароль для SCRAM нормализуется по RFC 4013 (SASLprep), чтобы совпадать с серверной нормализацией; для PLAIN пароль отправляется как введён. -- SCRAM доступен в вариантах SHA-512 (предпочтительный, реализация Luma), - SHA-256 и SHA-1 (Martin); механизм выбирается по рекламе сервера. +- SCRAM доступен в вариантах SHA-512, SHA-256 и SHA-1 (SHA-512 — + реализация Luma), а также в PLUS-вариантах с канальной привязкой + (SCRAM-SHA-512-PLUS > SHA-256-PLUS > SHA-1-PLUS): gs2-заголовок + «p=tls-exporter,» (RFC 9266) или «p=tls-server-end-point,» доказывает, + что SASL-обмен идёт по тому же TLS-каналу, который видит клиент, и + ломает MITM-ретрансляцию. PLUS выбирается только когда сервер + рекламирует соответствующий channel-binding тип, иначе — обычный SCRAM; + механизм выбирается по рекламе сервера. - OMEMO работает в legacy-неймспейсе `eu.siacs.conversations.axolotl` (так - умеет закреплённая версия MartinOMEMO); сообщения OMEMO 2 - (`urn:xmpp:omemo:2`) показываются как нерасшифрованные, а не пустые. -- Канал шифруется STARTTLS/direct TLS через SecureTransport с уровнем - `negotiatedSSL` (исключает TLS ниже 1.2) и ALPN «xmpp-client»; на системах - с поддержкой SecureTransport договаривается TLS 1.3. + умеет закреплённая версия MartinOMEMO) и в OMEMO 2 (`urn:xmpp:omemo:2`, + payload AES-256-CBC + HMAC-SHA-256 в конверте SCE `urn:xmpp:sce:0`). Luma + публикует omemo:2 bundle и список устройств, делит Double Ratchet-сессии с + legacy-модулем и помечает протокол каждого ключа на экране шифрования. +- Канал шифруется STARTTLS/direct TLS собственным TLS-процессором Luma на + OpenSSL 3 (SecureTransport ограничивает потоковый TLS версией 1.2 и не + имеет keying exporter): минимум TLS 1.2, максимум TLS 1.3, ALPN + «xmpp-client», SNI по домену XMPP. Сертификат проверяется через + SecTrust с серверной политикой домена из JID (та же логика + CertificateTrustEvaluator). Фактическая версия TLS, шифр и выбранный + SASL-механизм (самый стойкий из рекламируемых сервером, с канальной + привязкой при её доступности) показываются на экране информации о + сервере. - Когда OMEMO включено глобально или для конкретного чата, исходящие сообщения не откатываются на plaintext при ошибке: ошибка показывается пользователю. - Пользователь может осознанно отключить OMEMO глобально или для отдельного @@ -65,6 +79,16 @@ - На iOS файл OMEMO-состояния и база локальной истории (SwiftData `.store`) получают data-protection `completeUntilFirstUserAuthentication`. +- Архивные эхо собственных служебных станз (receipts, chat markers, + повторно доставленные копии) и собственные сообщения, которые невозможно + расшифровать обратно (нет encrypt-to-self ключа или self-сессия устарела), + не превращаются в пузыри «Не удалось расшифровать сообщение»: они + отбрасываются, как это делают другие клиенты, чтобы не засорять историю + фантомами, которых нет в виде сообщений на сервере. +- Сохранение медиа выполняется только по явному действию пользователя: + фото/видео/видеосообщения — в медиатеку, файлы и голосовые — в «Файлы» + (Загрузки). Расшифрованное содержимое при этом покидает sandbox приложения. + ## Известные ограничения - На macOS история (SwiftData SQLite в Application Support) и OMEMO state diff --git a/Luma.xcodeproj/project.pbxproj b/Luma.xcodeproj/project.pbxproj index 53c6cb2..56c8258 100644 --- a/Luma.xcodeproj/project.pbxproj +++ b/Luma.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ 0ECAB3E52DD12AD78AABE5F5 /* StaticDNSSrvResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */; }; 10273AF92A952B707C58AEE6 /* ArchiveSyncWorkBudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22A045770C3C55BC5ADC409B /* ArchiveSyncWorkBudgetTests.swift */; }; 10C5382981A87A2963DD397C /* MediaMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A127BF01F5C488CD3E053379 /* MediaMetadataTests.swift */; }; + 120BD1C8A163015AF27FFA42 /* SASLMechanismPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F8D047FE7E9BA4DAA3DAF80 /* SASLMechanismPreference.swift */; }; 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 */; }; @@ -47,11 +48,13 @@ 230E0DAA5396EA507D6F6D47 /* MessageBubble.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AC6BC7FC7CFFD69A20DA17 /* MessageBubble.swift */; }; 235ED577A9FB1D2A97A4749F /* LumaWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0A53D1B25A1923ADF96C35 /* LumaWatchApp.swift */; }; 23AE47B738853110F72C0580 /* Martin in Frameworks */ = {isa = PBXBuildFile; productRef = 4BD4324EE01A6DD46EBE7FDA /* Martin */; }; + 24E3C4EEC4EB5116982D2F4E /* OpenSSL in Frameworks */ = {isa = PBXBuildFile; productRef = 01363A8EB6E9A3C97C1B316D /* OpenSSL */; }; 25A9ADB0FF1496A4D3EF431D /* ForwardMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81213335D275609C38056220 /* ForwardMessageView.swift */; }; 275501728E0597A8E53F1D4D /* VideoNoteRecordingCompletionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */; }; 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 */; }; + 2A728A647EDD4F098802C64C /* TLSVersionProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 186BB225C4C5E528B3D7C9CE /* TLSVersionProbe.swift */; }; 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 */; }; @@ -66,6 +69,7 @@ 32528D1AA72FB1B1A72389E9 /* MartinOMEMO in Frameworks */ = {isa = PBXBuildFile; productRef = 7A670E49149C9C7E13F0A6A2 /* MartinOMEMO */; }; 32790D95A577DCE99937B539 /* AccountPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */; }; 3375874DFCE22B22879112DD /* SystemPhotoCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */; }; + 39C3E7AC663AB6C8DACFFAA8 /* OpenSSL in Frameworks */ = {isa = PBXBuildFile; productRef = A99B4C1B1459E1C34F214164 /* OpenSSL */; }; 3BA375F1E0934FB4DD2148F9 /* SASLprep.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2446F6A1D00ADAD261D4CCD /* SASLprep.swift */; }; 3BEF3895B4C84880B9103058 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A4CE3E678096BA76F2988C /* NewChatView.swift */; }; 3BF0521699C170F8411386CD /* SCRAMSHA512Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */; }; @@ -94,12 +98,14 @@ 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 */; }; + 523D54621DD4DDD54EA8D510 /* MediaDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB613D3331D85529B5F989D0 /* MediaDownloadService.swift */; }; 52F75F60459DA50E1815479A /* MediaPreviewProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */; }; 5409F5C90FBE0817F4AC8176 /* ChatTimelineEntryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90C426133ECC0319483A084 /* ChatTimelineEntryTests.swift */; }; 5415AF453AB8FCDA55FB59AD /* LumaCallEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */; }; 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 */; }; + 5A5C811D7D19AE7C694273FC /* SASLMechanismPreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D79A5AC35479F474FF3F44 /* SASLMechanismPreferenceTests.swift */; }; 5D80C9CBCE79DE69A7A6ABA4 /* AppLockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */; }; 6000FF9931D3399B53C8506C /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F26F5B024C5B4BF74CF242 /* MainTabView.swift */; }; 60CE79BB4D0FB53E126354AA /* GroupConversationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */; }; @@ -108,9 +114,12 @@ 6216C6AA3787BBF776D78E91 /* PhoneWatchBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0BE69D306AF1C207A30517D /* PhoneWatchBridge.swift */; }; 640C30ABB02E6486E6B7665E /* VideoNoteCaptureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0142FF4A1B05C373DAB4CDBE /* VideoNoteCaptureView.swift */; }; 64C8B9876278B711CC6479E0 /* LocationMessagePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96E7970930AB7F09CB5DA9C /* LocationMessagePreview.swift */; }; + 64C9A8E42046AC0FCB701613 /* TLSVersionProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 186BB225C4C5E528B3D7C9CE /* TLSVersionProbe.swift */; }; 64F4AD83D86B8900DE69F117 /* CredentialVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE381301A55B1C4F333B523 /* CredentialVault.swift */; }; + 662B8E72F00F5A77DE28819A /* SCRAMPlusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D03F8C05843DD139C1A4CE /* SCRAMPlusTests.swift */; }; 66489D4D15366683D3095B3D /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12C6A6B83DA076867481CCF3 /* ChatMessage.swift */; }; 66613FF7E056788222552D6C /* NotificationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */; }; + 670970182A27A26B8E44E687 /* LumaTLSNetworkProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 569F2BFFD578A23A99D73248 /* LumaTLSNetworkProcessor.swift */; }; 67143F44FF20A87E8875CFFB /* MediaPreviewProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */; }; 67438C8911F9B7362073B591 /* ReplyThreadOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19C556A41E640592AF6376B6 /* ReplyThreadOverlay.swift */; }; 6950D2E35897C3A6C537AE08 /* CallHistorySyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3F58259FA8A8DB43464FA0D /* CallHistorySyncTests.swift */; }; @@ -200,6 +209,7 @@ C824389F95739F79F3E075C8 /* MessageInteractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C97DDD3DA8B45C3F2085661 /* MessageInteractionTests.swift */; }; C92B463DCD052A3215CB7099 /* Conversation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7846C2EEE2551C6690A4FDC6 /* Conversation.swift */; }; C9CB8C50E85BD8F69ED0105F /* PhotoAttachmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2294F0FE44B2268A0620BB /* PhotoAttachmentPreview.swift */; }; + CA39FA78E8BFE26BEBF976E5 /* MediaDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB613D3331D85529B5F989D0 /* MediaDownloadService.swift */; }; CB66B3FCB7036E9AE50B6BF3 /* VideoNoteRecordingLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */; }; CBED8565C1313DC03D21A105 /* LumaRoomStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */; }; CC152AA73AC9347A300BBECC /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A338A8EB30206647BDD922F /* SettingsView.swift */; }; @@ -213,6 +223,7 @@ D551F2AA5DCCCCDD8D8FB9BB /* AccountConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2006464EAC88C6E4E2B08AC8 /* AccountConfiguration.swift */; }; D5E6B1859101ABBD33D90C3C /* ChatScrollPositionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C37AAE886AA3B8782B89E1A0 /* ChatScrollPositionPolicy.swift */; }; D63F32533880B0D715ECFC79 /* NotificationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */; }; + D6718F8B1359CD06F44A91A8 /* SASLMechanismPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F8D047FE7E9BA4DAA3DAF80 /* SASLMechanismPreference.swift */; }; D6D0A798487AE98AE03A579C /* NewGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99BE34DC082D19007241AA62 /* NewGroupView.swift */; }; D6ED224CC13439A99A388676 /* GeoLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD13FA20B6C2CD8B6718BF86 /* GeoLocation.swift */; }; D7179E7F8127A702FC58D3D4 /* DeviceResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 876CDF98063A4C948B00C7CE /* DeviceResource.swift */; }; @@ -234,6 +245,7 @@ EBAF4861DD93FBEAA4B37D1C /* MediaViewerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F7B01C59E9667ADAFABBAC /* MediaViewerItem.swift */; }; ECAC4761F04316A71EC3C716 /* AttachmentDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C04D749B7CA93CB7C59127D6 /* AttachmentDraft.swift */; }; ECCD95DD4E16E0B393C707A9 /* MessageReaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D414B62940A9234C51C3D14E /* MessageReaction.swift */; }; + ED1C54A2FB844226AA284CCC /* LumaScramPlusMechanism.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050B83F6DA3A04B661FFA3B1 /* LumaScramPlusMechanism.swift */; }; ED3869D5658BAA59F55B1E3D /* ArchiveSyncCheckpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */; }; EE2BF07D86C00AB6358EA5AE /* CredentialVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE381301A55B1C4F333B523 /* CredentialVault.swift */; }; EE4DFDBA97562C53C53FBF51 /* CryptoKitAESGCMEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A017D8B164B1107299F6781 /* CryptoKitAESGCMEngine.swift */; }; @@ -242,6 +254,7 @@ F1713D1CD4E019540BFA78FE /* LumaRosterStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */; }; F27ACACEF60AFAB5AF96EF98 /* MediaTimeFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76FEB73D4B67CB98C93F6244 /* MediaTimeFormatterTests.swift */; }; F320BBE2BE143D15398C46F9 /* ArchiveStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB0729C69630AE38C2D7BBF /* ArchiveStoreTests.swift */; }; + F5121F29EF3BEFD4FD54DD12 /* LumaTLSNetworkProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 569F2BFFD578A23A99D73248 /* LumaTLSNetworkProcessor.swift */; }; F558F58835D6E887DBF09EA0 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A97545E6E481D45E0BB20F /* ChatView.swift */; }; F80493F220991FF8A7CFF681 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05EEEE8A125347F7C5014A09 /* LoginView.swift */; }; F80767EC81E8CAFAA280DD6A /* SaslFailureMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */; }; @@ -251,6 +264,7 @@ FB1052104B90994CF03FE6A2 /* LumaOMEMOStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24A68154C2D2B3E9AB0C145F /* LumaOMEMOStore.swift */; }; FC5986CE50C84FA77983767C /* BubbleShapes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0386C2687C1C60EC734FDBB0 /* BubbleShapes.swift */; }; FC60F19740E3D0C75205DB61 /* ArchiveSyncWorkBudget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D15DA1B85B68AD2419670C8 /* ArchiveSyncWorkBudget.swift */; }; + FCC5133038CDBEC39B5B42D3 /* LumaScramPlusMechanism.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050B83F6DA3A04B661FFA3B1 /* LumaScramPlusMechanism.swift */; }; FCD330D04ED5B87E28883E58 /* EncryptionDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 185E3C916B72D36733DE15AF /* EncryptionDevicesView.swift */; }; FD15BE18C54AE2693518CB4A /* AudioMessagePlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D724713196AD2063C7FAD08F /* AudioMessagePlayer.swift */; }; FD9F8640841236BB78563573 /* MessageReaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D414B62940A9234C51C3D14E /* MessageReaction.swift */; }; @@ -302,6 +316,7 @@ 0142FF4A1B05C373DAB4CDBE /* VideoNoteCaptureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteCaptureView.swift; sourceTree = ""; }; 0386C2687C1C60EC734FDBB0 /* BubbleShapes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BubbleShapes.swift; sourceTree = ""; }; 03AC6BC7FC7CFFD69A20DA17 /* MessageBubble.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBubble.swift; sourceTree = ""; }; + 050B83F6DA3A04B661FFA3B1 /* LumaScramPlusMechanism.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaScramPlusMechanism.swift; sourceTree = ""; }; 05EEEE8A125347F7C5014A09 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; 096748EF4DC92889BCAB3260 /* MessageCorrectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCorrectionTests.swift; sourceTree = ""; }; 0B5359E7A89ABF58B78CE036 /* TimelineUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineUITests.swift; sourceTree = ""; }; @@ -314,6 +329,7 @@ 13EFA01112297641B9B0EBC5 /* SASLprepTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SASLprepTests.swift; sourceTree = ""; }; 184902980B7EE4B8DDEA907D /* AppAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = AppAssets.xcassets; sourceTree = ""; }; 185E3C916B72D36733DE15AF /* EncryptionDevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionDevicesView.swift; sourceTree = ""; }; + 186BB225C4C5E528B3D7C9CE /* TLSVersionProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSVersionProbe.swift; sourceTree = ""; }; 19C556A41E640592AF6376B6 /* ReplyThreadOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyThreadOverlay.swift; sourceTree = ""; }; 19D4E9404712E8643E31CB93 /* LumaWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LumaWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 1A017D8B164B1107299F6781 /* CryptoKitAESGCMEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoKitAESGCMEngine.swift; sourceTree = ""; }; @@ -323,6 +339,7 @@ 1DCD865B7E746D20B142FCE7 /* WatchVoiceMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchVoiceMessageTests.swift; sourceTree = ""; }; 1F7B2B37E9E8E6A46ED7547A /* SaslFailureMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaslFailureMessageTests.swift; sourceTree = ""; }; 2006464EAC88C6E4E2B08AC8 /* AccountConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountConfiguration.swift; sourceTree = ""; }; + 21D03F8C05843DD139C1A4CE /* SCRAMPlusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMPlusTests.swift; sourceTree = ""; }; 22A045770C3C55BC5ADC409B /* ArchiveSyncWorkBudgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncWorkBudgetTests.swift; sourceTree = ""; }; 22A1DBD88AF90D3892A02E1A /* ArchiveSyncPagination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncPagination.swift; sourceTree = ""; }; 235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPreferences.swift; sourceTree = ""; }; @@ -339,6 +356,7 @@ 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 = ""; }; + 37D79A5AC35479F474FF3F44 /* SASLMechanismPreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SASLMechanismPreferenceTests.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 = ""; }; @@ -351,6 +369,7 @@ 5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMSHA512Tests.swift; sourceTree = ""; }; 515DD6F1F80A848C39EBAE84 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemPhotoCameraView.swift; sourceTree = ""; }; + 569F2BFFD578A23A99D73248 /* LumaTLSNetworkProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaTLSNetworkProcessor.swift; sourceTree = ""; }; 5886E1E233C129B9317D6059 /* CertificateTrustEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateTrustEvaluator.swift; sourceTree = ""; }; 5BB168F9AD341C35EADACF10 /* VideoNoteRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecorder.swift; sourceTree = ""; }; 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMetadataRecord.swift; sourceTree = ""; }; @@ -369,6 +388,7 @@ 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 = ""; }; + 6F8D047FE7E9BA4DAA3DAF80 /* SASLMechanismPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SASLMechanismPreference.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 = ""; }; @@ -403,6 +423,7 @@ A862822F2507303EBB27EFCC /* ArchiveSyncPaginationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncPaginationTests.swift; sourceTree = ""; }; A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAttachmentPreview.swift; sourceTree = ""; }; A8C051C2A7014E8B5E825477 /* LumaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaApp.swift; sourceTree = ""; }; + AB613D3331D85529B5F989D0 /* MediaDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDownloadService.swift; sourceTree = ""; }; ABB0729C69630AE38C2D7BBF /* ArchiveStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveStoreTests.swift; sourceTree = ""; }; B1E6875B522254FFB5FA880E /* MediaPickerSelectionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPickerSelectionPolicy.swift; sourceTree = ""; }; B2446F6A1D00ADAD261D4CCD /* SASLprep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SASLprep.swift; sourceTree = ""; }; @@ -435,12 +456,12 @@ D8C81B9255E9886FB86E0785 /* InlineVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineVideoPlayer.swift; sourceTree = ""; }; D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncCheckpoint.swift; sourceTree = ""; }; D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycleTests.swift; sourceTree = ""; }; - DF8C531E464C9C4EDE2648C6 /* Luma.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DF8C531E464C9C4EDE2648C6 /* Luma.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; }; E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingCompletionPolicy.swift; sourceTree = ""; }; E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInformation.swift; sourceTree = ""; }; E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRoomStore.swift; sourceTree = ""; }; EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyArchiveImporter.swift; sourceTree = ""; }; - EA54340DD785335237158BEF /* Luma.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; }; + EA54340DD785335237158BEF /* LumaMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LumaMac.app; sourceTree = BUILT_PRODUCTS_DIR; }; F0A35C9A7B52458996513382 /* WatchVoiceRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchVoiceRecorder.swift; sourceTree = ""; }; F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionBanner.swift; sourceTree = ""; }; F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalog.swift; sourceTree = ""; }; @@ -459,6 +480,7 @@ 23AE47B738853110F72C0580 /* Martin in Frameworks */, 992195D6AE64DF2C24A52956 /* MartinOMEMO in Frameworks */, 137816B417A865C97F935A00 /* WebRTC in Frameworks */, + 24E3C4EEC4EB5116982D2F4E /* OpenSSL in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -469,6 +491,7 @@ 86D39FA124355A8B53903164 /* Martin in Frameworks */, 32528D1AA72FB1B1A72389E9 /* MartinOMEMO in Frameworks */, 6B311EF7CF11B52E75D929F0 /* WebRTC in Frameworks */, + 39C3E7AC663AB6C8DACFFAA8 /* OpenSSL in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -487,10 +510,13 @@ E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */, F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */, C3E21A7D359AF7A7AF97D8DE /* LumaSaslFailureModule.swift */, + 050B83F6DA3A04B661FFA3B1 /* LumaScramPlusMechanism.swift */, 7C16CFC03E7F478838A704FD /* LumaScramSha512Mechanism.swift */, + 569F2BFFD578A23A99D73248 /* LumaTLSNetworkProcessor.swift */, 69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */, B2446F6A1D00ADAD261D4CCD /* SASLprep.swift */, F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */, + 186BB225C4C5E528B3D7C9CE /* TLSVersionProbe.swift */, 0DE298382FC70ECF1513BD4A /* XMPPService.swift */, ); path = XMPP; @@ -527,6 +553,7 @@ 9CC7DFD3F92530B411DEEC1F /* MessageReplySwipePolicy.swift */, 6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */, A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */, + 6F8D047FE7E9BA4DAA3DAF80 /* SASLMechanismPreference.swift */, E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */, E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */, 96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */, @@ -626,7 +653,9 @@ 77D3F72BE63D8F360ECA0419 /* MessageReplySwipeTests.swift */, F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */, 1F7B2B37E9E8E6A46ED7547A /* SaslFailureMessageTests.swift */, + 37D79A5AC35479F474FF3F44 /* SASLMechanismPreferenceTests.swift */, 13EFA01112297641B9B0EBC5 /* SASLprepTests.swift */, + 21D03F8C05843DD139C1A4CE /* SCRAMPlusTests.swift */, 5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */, 9C8B12EFBC83F9235B6AF992 /* VideoNoteRecordingCompletionPolicyTests.swift */, D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */, @@ -700,6 +729,7 @@ D5746D1E21ABBBCFC6CD9694 /* AvatarImageProcessor.swift */, A438A50BC08940CA1410B4FA /* ChatMediaImageCache.swift */, 1C67A8A07F9DC9DFB7FB35DA /* LocationProvider.swift */, + AB613D3331D85529B5F989D0 /* MediaDownloadService.swift */, C86EBBD15843C6FB110C798E /* MediaFileIO.swift */, 26ADAEFB7CB8441CA1A50C44 /* MediaPlaybackCoordinator.swift */, 3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */, @@ -714,7 +744,7 @@ isa = PBXGroup; children = ( DF8C531E464C9C4EDE2648C6 /* Luma.app */, - EA54340DD785335237158BEF /* Luma.app */, + EA54340DD785335237158BEF /* LumaMac.app */, 6317FAA29F22A3EA8450208F /* LumaTests.xctest */, 96720381802141EBA1DA9772 /* LumaUITests.xctest */, 19D4E9404712E8643E31CB93 /* LumaWatch.app */, @@ -783,6 +813,7 @@ 4BD4324EE01A6DD46EBE7FDA /* Martin */, 371134B6F3E284082E28FE08 /* MartinOMEMO */, EFCAF9B0DE67466AF22E76C3 /* WebRTC */, + 01363A8EB6E9A3C97C1B316D /* OpenSSL */, ); productName = Luma; productReference = DF8C531E464C9C4EDE2648C6 /* Luma.app */; @@ -805,9 +836,10 @@ 6EDEC6EEE315B77491214398 /* Martin */, 7A670E49149C9C7E13F0A6A2 /* MartinOMEMO */, 2A745A82DD6F34F0CB46B52B /* WebRTC */, + A99B4C1B1459E1C34F214164 /* OpenSSL */, ); productName = LumaMac; - productReference = EA54340DD785335237158BEF /* Luma.app */; + productReference = EA54340DD785335237158BEF /* LumaMac.app */; productType = "com.apple.product-type.application"; }; A086B6D25E7969CB643F1C8A /* LumaUITests */ = { @@ -898,6 +930,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 277642F04D20E1E2B6BE565F /* XCRemoteSwiftPackageReference "Martin" */, + CCC36972B3D047152DDB4A95 /* XCRemoteSwiftPackageReference "OpenSSL" */, 251B06A6CDEEC0297E9E6836 /* XCRemoteSwiftPackageReference "WebRTC" */, 3B2A3CB97916A28030D0A4E0 /* XCLocalSwiftPackageReference "ThirdParty/MartinOMEMO" */, ); @@ -1008,8 +1041,11 @@ CBED8565C1313DC03D21A105 /* LumaRoomStore.swift in Sources */, F1713D1CD4E019540BFA78FE /* LumaRosterStore.swift in Sources */, 0BD15E9B1C488AA2B7B56AF7 /* LumaSaslFailureModule.swift in Sources */, + ED1C54A2FB844226AA284CCC /* LumaScramPlusMechanism.swift in Sources */, E6B279889E62AB9AD0C5BCE1 /* LumaScramSha512Mechanism.swift in Sources */, + 670970182A27A26B8E44E687 /* LumaTLSNetworkProcessor.swift in Sources */, D12F19AE38C13C6F37A36614 /* MainTabView.swift in Sources */, + CA39FA78E8BFE26BEBF976E5 /* MediaDownloadService.swift in Sources */, 0E4CE634E1568EE121B8AF75 /* MediaFileIO.swift in Sources */, 2822384FC6AF9685C4D014C1 /* MediaMetadata.swift in Sources */, 7D93BF350FBBF6F9EB29BA23 /* MediaPickerSelectionPolicy.swift in Sources */, @@ -1032,6 +1068,7 @@ 067D121E9C8D8EA076D974FB /* RTCVideoRendererView.swift in Sources */, 67438C8911F9B7362073B591 /* ReplyThreadOverlay.swift in Sources */, ABAFC50EF551861779FFD9A1 /* RootView.swift in Sources */, + D6718F8B1359CD06F44A91A8 /* SASLMechanismPreference.swift in Sources */, 9F288ACC92CD62A2E0CCE157 /* SASLprep.swift in Sources */, A4A742F3BBEFCAFD29975968 /* SaslFailureMessage.swift in Sources */, 01E4131ED4DBED0BEAD465A2 /* ServerInfoView.swift in Sources */, @@ -1039,6 +1076,7 @@ CC152AA73AC9347A300BBECC /* SettingsView.swift in Sources */, D3777F3C0A81F08A9983048B /* StaticDNSSrvResolver.swift in Sources */, 3375874DFCE22B22879112DD /* SystemPhotoCameraView.swift in Sources */, + 64C9A8E42046AC0FCB701613 /* TLSVersionProbe.swift in Sources */, E67E0D2C3FBA2C3739B692E0 /* VideoAttachmentPreview.swift in Sources */, 3C2727D165D2EF1CC742EEF7 /* VideoNoteCaptureView.swift in Sources */, 44AF85896EE023775B91DE8B /* VideoNotePreview.swift in Sources */, @@ -1122,8 +1160,11 @@ 03C65C3DAA1D8776AF496EFA /* LumaRoomStore.swift in Sources */, 7F4C00E98574C8E5193F442E /* LumaRosterStore.swift in Sources */, C1AB954660804F7925607038 /* LumaSaslFailureModule.swift in Sources */, + FCC5133038CDBEC39B5B42D3 /* LumaScramPlusMechanism.swift in Sources */, 127C4B7A5083081D22FD21CD /* LumaScramSha512Mechanism.swift in Sources */, + F5121F29EF3BEFD4FD54DD12 /* LumaTLSNetworkProcessor.swift in Sources */, 6000FF9931D3399B53C8506C /* MainTabView.swift in Sources */, + 523D54621DD4DDD54EA8D510 /* MediaDownloadService.swift in Sources */, EFC29F3543B1371E63D48510 /* MediaFileIO.swift in Sources */, AFCBFB59F729DE95E453A7D3 /* MediaMetadata.swift in Sources */, D80DC91DBAE924BE47EFBD6F /* MediaPickerSelectionPolicy.swift in Sources */, @@ -1146,6 +1187,7 @@ C7AE34FF0A0A25D0C81D3271 /* RTCVideoRendererView.swift in Sources */, 3CFEAC0C613CC9248DA8E35E /* ReplyThreadOverlay.swift in Sources */, 3E3A82A737D9009D9BDC04E0 /* RootView.swift in Sources */, + 120BD1C8A163015AF27FFA42 /* SASLMechanismPreference.swift in Sources */, 3BA375F1E0934FB4DD2148F9 /* SASLprep.swift in Sources */, F80767EC81E8CAFAA280DD6A /* SaslFailureMessage.swift in Sources */, 3D3DDA2292673CE4FF397174 /* ServerInfoView.swift in Sources */, @@ -1153,6 +1195,7 @@ E2245416CB055ECEF0C5F81D /* SettingsView.swift in Sources */, 0ECAB3E52DD12AD78AABE5F5 /* StaticDNSSrvResolver.swift in Sources */, 46924A3275BFEFD2218AD748 /* SystemPhotoCameraView.swift in Sources */, + 2A728A647EDD4F098802C64C /* TLSVersionProbe.swift in Sources */, 6156698A5A514CA01411489D /* VideoAttachmentPreview.swift in Sources */, 640C30ABB02E6486E6B7665E /* VideoNoteCaptureView.swift in Sources */, 03629CA43EB6D367498E90E8 /* VideoNotePreview.swift in Sources */, @@ -1200,7 +1243,9 @@ 5875F7D2870C985287B36AFA /* MessageReplyFallbackTests.swift in Sources */, 0535722A2D4FC893F16D78F1 /* MessageReplySwipeTests.swift in Sources */, 9E98F99F3FBFC9DAB99A3650 /* NotificationPolicyTests.swift in Sources */, + 5A5C811D7D19AE7C694273FC /* SASLMechanismPreferenceTests.swift in Sources */, 1B7C9F60AA7BFF6A2C0FE45D /* SASLprepTests.swift in Sources */, + 662B8E72F00F5A77DE28819A /* SCRAMPlusTests.swift in Sources */, 3BF0521699C170F8411386CD /* SCRAMSHA512Tests.swift in Sources */, 3DDB1B15BCE5B58D12B60558 /* SaslFailureMessageTests.swift in Sources */, FEB3A38F302E95303F0E8773 /* VideoNoteRecordingCompletionPolicyTests.swift in Sources */, @@ -1323,7 +1368,7 @@ SDKROOT = watchos; SKIP_INSTALL = YES; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 10.0; + WATCHOS_DEPLOYMENT_TARGET = 26.0; }; name = Debug; }; @@ -1332,7 +1377,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1350,7 +1395,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1368,7 +1413,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1462,7 +1507,7 @@ SDKROOT = watchos; SKIP_INSTALL = YES; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 10.0; + WATCHOS_DEPLOYMENT_TARGET = 26.0; }; name = Release; }; @@ -1530,7 +1575,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1650,9 +1695,22 @@ version = 3.2.4; }; }; + CCC36972B3D047152DDB4A95 /* XCRemoteSwiftPackageReference "OpenSSL" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/krzyzanowskim/OpenSSL.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.4.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 01363A8EB6E9A3C97C1B316D /* OpenSSL */ = { + isa = XCSwiftPackageProductDependency; + package = CCC36972B3D047152DDB4A95 /* XCRemoteSwiftPackageReference "OpenSSL" */; + productName = OpenSSL; + }; 2A745A82DD6F34F0CB46B52B /* WebRTC */ = { isa = XCSwiftPackageProductDependency; package = 251B06A6CDEEC0297E9E6836 /* XCRemoteSwiftPackageReference "WebRTC" */; @@ -1676,6 +1734,11 @@ isa = XCSwiftPackageProductDependency; productName = MartinOMEMO; }; + A99B4C1B1459E1C34F214164 /* OpenSSL */ = { + isa = XCSwiftPackageProductDependency; + package = CCC36972B3D047152DDB4A95 /* XCRemoteSwiftPackageReference "OpenSSL" */; + productName = OpenSSL; + }; EFCAF9B0DE67466AF22E76C3 /* WebRTC */ = { isa = XCSwiftPackageProductDependency; package = 251B06A6CDEEC0297E9E6836 /* XCRemoteSwiftPackageReference "WebRTC" */; diff --git a/Luma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Luma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 59f32d7..d0b6f4e 100644 --- a/Luma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Luma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,4 @@ { - "originHash" : "477b2c6d92d18b2c389859e6415d1fb6bb6cc275812c439725cc2ce4fa0b02a7", "pins" : [ { "identity" : "libsignal", @@ -19,6 +18,15 @@ "version" : "3.2.4" } }, + { + "identity" : "openssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzyzanowskim/OpenSSL.git", + "state" : { + "revision" : "4b1188ba947ddc23d03b13096b9e44b804e146c9", + "version" : "3.6.3000" + } + }, { "identity" : "tigase-logging.swift", "kind" : "remoteSourceControl", @@ -38,5 +46,5 @@ } } ], - "version" : 3 + "version" : 2 } diff --git a/Scripts/verify.sh b/Scripts/verify.sh index 61cc885..181f139 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -314,7 +314,7 @@ grep -q 'decodeOmemo2OffMain' Sources/Shared/XMPP/XMPPService.swift || { echo "Incoming messages must route OMEMO 2 payloads to the OMEMO 2 module" exit 1 } -grep -q 'package = .*XCLocalSwiftPackageReference .ThirdParty/MartinOMEMO.' Luma.xcodeproj/project.pbxproj || { +grep -q 'XCLocalSwiftPackageReference "ThirdParty/MartinOMEMO"' Luma.xcodeproj/project.pbxproj || { echo "The vendored MartinOMEMO package must stay linked in the generated project (run make project)" exit 1 } diff --git a/Sources/Shared/Models/AppModel.swift b/Sources/Shared/Models/AppModel.swift index f9d770d..e763344 100644 --- a/Sources/Shared/Models/AppModel.swift +++ b/Sources/Shared/Models/AppModel.swift @@ -1913,6 +1913,10 @@ final class AppModel: ObservableObject { errorMessage = nil } + func clearInformationalMessage() { + informationalMessage = nil + } + private func beginMediaSendActivity() -> UUID { let token = mediaSendActivity.begin() isSendingAttachment = mediaSendActivity.isActive @@ -3100,14 +3104,32 @@ final class AppModel: ObservableObject { cursor: lastSuccessfulMAMCursor ) } + // One-time cleanup of phantom «Не удалось расшифровать сообщение» + // placeholders: outgoing echoes the client could not decrypt back + // (broken encrypt-to-self sessions) used to be rendered as + // undecryptable bubbles. XMPPService now drops those echoes, so purge + // the leftovers from the local store instead of keeping the noise. + let phantomPlaceholderIDs = Set( + snapshot.messages + .filter { $0.direction == .outgoing && $0.security == .decryptionFailed } + .map(\.clientID) + ) + for message in snapshot.messages + where phantomPlaceholderIDs.contains(message.clientID) { + modelContext.delete(message) + } messages = snapshot.messages.filter { - !locallyDeletedMessageIDs.contains( - Self.localDeletionKey( - messageID: $0.clientID, - conversationID: $0.conversationID - )) + !phantomPlaceholderIDs.contains($0.clientID) + && !locallyDeletedMessageIDs.contains( + Self.localDeletionKey( + messageID: $0.clientID, + conversationID: $0.conversationID + )) } rebuildMessageIndex() + if !phantomPlaceholderIDs.isEmpty { + schedulePersist() + } let avatarJIDs = Set(snapshot.conversations.filter { !$0.isGroup }.map(\.jid) + [jid]) for avatarJID in avatarJIDs { if let data = await avatarCache.data(for: avatarJID) { diff --git a/Sources/Shared/Models/OMEMODevice.swift b/Sources/Shared/Models/OMEMODevice.swift index be68db1..ff218f9 100644 --- a/Sources/Shared/Models/OMEMODevice.swift +++ b/Sources/Shared/Models/OMEMODevice.swift @@ -14,8 +14,14 @@ struct OMEMODevice: Identifiable, Hashable, Sendable { let trust: Trust let isActive: Bool let isOwn: Bool + let isOMEMO2: Bool - var id: String { "\(jid)|\(deviceID)" } + var id: String { "\(jid)|\(deviceID)|\(isOMEMO2 ? "omemo2" : "legacy")" } + + /// Short protocol label for the encryption screen badge. + var protocolName: String { + isOMEMO2 ? "OMEMO 2" : "OMEMO" + } var formattedFingerprint: String { fingerprint.chunked(every: 8).joined(separator: " ") diff --git a/Sources/Shared/Models/SASLMechanismPreference.swift b/Sources/Shared/Models/SASLMechanismPreference.swift new file mode 100644 index 0000000..e243354 --- /dev/null +++ b/Sources/Shared/Models/SASLMechanismPreference.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Order in which Luma prefers SASL mechanisms during authentication. +/// Channel-binding SCRAM first (tls-exporter / tls-server-end-point prove +/// the exchange runs over the client's own TLS channel, defeating MITM +/// relays), then the strongest plain SCRAM variant, and PLAIN as the +/// last-resort fallback. The server must advertise a mechanism for it to be +/// eligible — Martin's `SaslModule.guessSaslMechanism` walks exactly this +/// order, and the PLUS mechanisms additionally gate on available binding +/// data in `isAllowedToUse`. +enum SASLMechanismPreference { + static let order: [String] = [ + "SCRAM-SHA-512-PLUS", + "SCRAM-SHA-256-PLUS", + "SCRAM-SHA-1-PLUS", + "SCRAM-SHA-512", + "SCRAM-SHA-256", + "SCRAM-SHA-1", + "PLAIN", + ] + + /// The most secure mechanism from `order` that the server advertises, + /// or nil when no password-based mechanism is offered. When + /// `allowsChannelBinding` is false the -PLUS variants are skipped, since + /// without binding data the exchange would fail. + static func selected( + among offered: [String], + allowsChannelBinding: Bool = true + ) -> String? { + order.first { candidate in + guard offered.contains(candidate) else { return false } + return allowsChannelBinding || !candidate.hasSuffix("-PLUS") + } + } + + /// True when `mechanism` is the strongest one available among + /// `offered`: the server offers nothing better than the chosen one. + static func isStrongestAvailable(_ mechanism: String, among offered: [String]) -> Bool { + guard let chosenRank = order.firstIndex(of: mechanism) else { return false } + for stronger in order[.. Bool { + switch kind { + case .photo, .video, .videoNote: + #if os(iOS) + try await saveToPhotos(url: url, kind: kind) + return true + #else + return try await saveToDownloads(url: url, filename: filename) + #endif + case .attachment, .audio, .voice: + return try await saveToDownloads(url: url, filename: filename) + case .text, .location, .system: + throw SaveError.unsupportedKind + } + } + + // MARK: - Local file resolution + + private static func resolvedLocalURL( + for message: ChatMessage, + model: AppModel + ) async -> URL? { + if let cached = model.mediaPreviewURL(for: message) { + return cached + } + // Inline media (photo/video/audio/voice/video note) is fetched by + // AppModel into its preview cache; files are not auto-previewed and fall + // through to the QuickLook handoff below. + await model.prepareMediaPreview(message) + if let cached = model.mediaPreviewURL(for: message) { + return cached + } + // Attachments are only fetched on demand via previewAttachment(_:), + // which parks the result in the single-shot previewURL property. Clear it + // before and after so the QuickLook sheet does not flash open. + model.previewURL = nil + await model.previewAttachment(message) + let url = model.previewURL + model.previewURL = nil + return url + } + + // MARK: - Photos + + #if os(iOS) + private static func saveToPhotos(url: URL, kind: ChatMessage.Kind) async throws { + try await PHPhotoLibrary.shared().performChanges { + if kind == .photo { + _ = PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: url) + } else { + _ = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url) + } + } + } + #endif + + // MARK: - Downloads + + private static func saveToDownloads(url: URL, filename: String) async throws -> Bool { + #if os(macOS) + return try await saveWithPanel(url: url, filename: filename) + #else + return await FileExportPresenter.shared.present(url: url) + #endif + } + + #if os(macOS) + /// Present a save panel defaulting to ~/Downloads. The app sandbox grants + /// write access to the location the user picks, so no Downloads entitlement + /// is required. + private static func saveWithPanel(url: URL, filename: String) async throws -> Bool { + let panel = NSSavePanel() + panel.nameFieldStringValue = filename + panel.canCreateDirectories = true + if let downloads = FileManager.default.urls( + for: .downloadsDirectory, + in: .userDomainMask + ).first { + panel.directoryURL = downloads + } + guard panel.runModal() == .OK, let destination = panel.url else { + return false + } + try FileManager.default.copyItem(at: url, to: destination) + return true + } + #endif + + // MARK: - Helpers + + private static func defaultFilename(for message: ChatMessage) -> String { + let ext = Self.fileExtension(for: message) + let base: String + switch message.kind { + case .photo: base = "Фото" + case .video: base = "Видео" + case .videoNote: base = "Видеосообщение" + case .voice: base = "Голосовое сообщение" + case .audio: base = "Аудио" + case .attachment: base = "Вложение" + default: base = "Файл" + } + return ext.isEmpty ? base : "\(base).\(ext)" + } + + private static func fileExtension(for message: ChatMessage) -> String { + if let filename = message.localFilename { + let ext = (filename as NSString).pathExtension + if !ext.isEmpty { return ext } + } + if let mime = message.mimeType, + let type = UTType(mimeType: mime), + let ext = type.preferredFilenameExtension + { + return ext + } + switch message.kind { + case .video, .videoNote: return "mp4" + case .voice, .audio: return "m4a" + case .photo: return "jpg" + default: return "" + } + } +} + +#if os(iOS) +/// Presents the system "save to Files" sheet for a cached file and reports +/// whether the user completed the save or cancelled. Held alive by the +/// continuation until the delegate fires. +@MainActor +private final class FileExportPresenter: NSObject, UIDocumentPickerDelegate { + static let shared = FileExportPresenter() + + private var continuation: CheckedContinuation? + + func present(url: URL) async -> Bool { + await withCheckedContinuation { continuation in + self.continuation = continuation + let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true) + picker.delegate = self + Task { @MainActor in + // Let a just-dismissed context menu finish its transition before + // presenting, otherwise the sheet can be rejected. + try? await Task.sleep(nanoseconds: 350_000_000) + guard let presenter = Self.topPresenter(), + presenter.presentedViewController == nil + else { + self.finish(false) + return + } + presenter.present(picker, animated: true) + } + } + } + + func documentPicker( + _ controller: UIDocumentPickerViewController, + didPickDocumentsAt urls: [URL] + ) { + finish(true) + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + finish(false) + } + + private func finish(_ result: Bool) { + continuation?.resume(returning: result) + continuation = nil + } + + private static func topPresenter() -> UIViewController? { + guard let scene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.activationState == .foregroundActive }), + let window = scene.windows.first(where: { $0.isKeyWindow }), + let root = window.rootViewController + else { return nil } + var top = root + while let presented = top.presentedViewController { + top = presented + } + return top + } +} +#endif diff --git a/Sources/Shared/UI/Components/AudioMessagePlayer.swift b/Sources/Shared/UI/Components/AudioMessagePlayer.swift index 0541e5b..ef8e796 100644 --- a/Sources/Shared/UI/Components/AudioMessagePlayer.swift +++ b/Sources/Shared/UI/Components/AudioMessagePlayer.swift @@ -53,6 +53,16 @@ struct AudioMessagePlayer: View { .font(.caption2.monospacedDigit()) .foregroundStyle(secondaryText) Spacer() + Button(action: saveToFiles) { + Image(systemName: "square.and.arrow.down") + .font(.caption.weight(.bold)) + .foregroundStyle(accent) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(accent.opacity(0.14), in: Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel("Сохранить в Файлы") Button(action: playback.cycleVoiceRate) { Text(rateText) .font(.caption2.monospacedDigit().weight(.bold)) @@ -95,6 +105,13 @@ struct AudioMessagePlayer: View { countStyle: .file )) } + Spacer() + Button(action: saveToFiles) { + Image(systemName: "square.and.arrow.down") + .foregroundStyle(accent) + } + .buttonStyle(.plain) + .accessibilityLabel("Сохранить в Файлы") } .font(.caption2.monospacedDigit()) .foregroundStyle(secondaryText) @@ -185,6 +202,10 @@ struct AudioMessagePlayer: View { message.direction == .outgoing ? .white.opacity(0.78) : .secondary } + private func saveToFiles() { + Task { await MediaDownloadService.save(message, model: model) } + } + private func togglePlayback() { guard let localURL else { Task { diff --git a/Sources/Shared/UI/Components/MediaViewer.swift b/Sources/Shared/UI/Components/MediaViewer.swift index 88cf5b3..efad35b 100644 --- a/Sources/Shared/UI/Components/MediaViewer.swift +++ b/Sources/Shared/UI/Components/MediaViewer.swift @@ -45,6 +45,7 @@ struct MediaViewer: View { @State private var isTrackingDismissGesture = false @State private var isCompletingDismissal = false @State private var isPhotoZoomed = false + @State private var saveErrorMessage: String? var body: some View { GeometryReader { geometry in @@ -65,6 +66,16 @@ struct MediaViewer: View { .lineLimit(1) Spacer() + + Button(action: save) { + Image(systemName: "square.and.arrow.down") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 36, height: 36) + .background(.white.opacity(0.16), in: Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Сохранить") } .padding(.horizontal, 16) .padding(.top, 12) @@ -96,6 +107,11 @@ struct MediaViewer: View { .accessibilityAction(.escape) { closeImmediately() } + .alert("Ошибка сохранения", isPresented: saveErrorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(saveErrorMessage ?? "Не удалось сохранить медиа.") + } } #if os(macOS) .onExitCommand { @@ -183,6 +199,27 @@ struct MediaViewer: View { onClose() } + private var saveErrorBinding: Binding { + Binding( + get: { saveErrorMessage != nil }, + set: { visible in if !visible { saveErrorMessage = nil } } + ) + } + + private func save() { + Task { + do { + try await MediaDownloadService.save( + url: item.url, + kind: item.kind, + filename: item.title + ) + } catch { + saveErrorMessage = error.localizedDescription + } + } + } + private func dismissalProgress(in containerHeight: CGFloat) -> CGFloat { min(1, abs(dragOffset) / max(1, containerHeight * 0.62)) } diff --git a/Sources/Shared/UI/Components/MessageBubble.swift b/Sources/Shared/UI/Components/MessageBubble.swift index 4a92fb4..50ef8d6 100644 --- a/Sources/Shared/UI/Components/MessageBubble.swift +++ b/Sources/Shared/UI/Components/MessageBubble.swift @@ -81,6 +81,13 @@ struct MessageBubble: View { Label("Копировать текст", systemImage: "doc.on.doc") } } + if message.kind.isMedia, !message.isRetracted { + Button { + Task { await MediaDownloadService.save(message, model: model) } + } label: { + Label("Сохранить", systemImage: "square.and.arrow.down") + } + } if model.canRetryMediaMessage(message) { Button(action: onRetry) { Label("Повторить отправку", systemImage: "arrow.clockwise") diff --git a/Sources/Shared/UI/EncryptionDevicesView.swift b/Sources/Shared/UI/EncryptionDevicesView.swift index 9ea527b..20316b1 100644 --- a/Sources/Shared/UI/EncryptionDevicesView.swift +++ b/Sources/Shared/UI/EncryptionDevicesView.swift @@ -98,6 +98,15 @@ private struct DeviceRow: View { systemImage: device.isActive ? "iphone" : "iphone.slash" ) .font(.headline) + Text(device.protocolName) + .font(.caption2.weight(.medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .foregroundStyle(device.isOMEMO2 ? Color.indigo : Color.secondary) + .background( + (device.isOMEMO2 ? Color.indigo : Color.secondary).opacity(0.14), + in: Capsule() + ) Spacer() Button(action: toggleVerification) { Label(trustTitle, systemImage: trustIcon) diff --git a/Sources/Shared/UI/RootView.swift b/Sources/Shared/UI/RootView.swift index d3b06a9..f5d3b36 100644 --- a/Sources/Shared/UI/RootView.swift +++ b/Sources/Shared/UI/RootView.swift @@ -4,6 +4,7 @@ import SwiftUI struct RootView: View { @ObservedObject var model: AppModel + @State private var toastDismissTask: Task? var body: some View { ZStack { @@ -32,17 +33,55 @@ struct RootView: View { .transition(.opacity.combined(with: .scale(scale: 1.015))) .zIndex(200) } + + if let info = model.informationalMessage { + VStack { + toast(info) + Spacer() + } + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(300) + } } } .animation(.easeInOut(duration: 0.22), value: model.account?.id) .animation(.easeInOut(duration: 0.2), value: model.mediaViewerItem?.id) .animation(.easeInOut(duration: 0.2), value: model.activeCall?.id) + .animation(.spring(response: 0.3, dampingFraction: 0.85), value: model.informationalMessage) .alert("Luma", isPresented: errorBinding) { Button("OK") { model.clearError() } } message: { Text(model.errorMessage ?? "Неизвестная ошибка") } .quickLookPreview($model.previewURL) + .onChange(of: model.informationalMessage) { _, newValue in + guard newValue != nil else { return } + toastDismissTask?.cancel() + toastDismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 2_500_000_000) + guard !Task.isCancelled else { return } + model.clearInformationalMessage() + } + } + } + + /// Compact Telegram-style confirmation toast shown for a couple of + /// seconds above every layer (lists, media viewer, calls). + private func toast(_ message: String) -> some View { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.accentColor) + Text(message) + .font(.subheadline.weight(.medium)) + .lineLimit(2) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.regularMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(Color.primary.opacity(0.06))) + .shadow(color: .black.opacity(0.18), radius: 12, y: 4) + .padding(.top, 8) + .accessibilityIdentifier("info-toast") } private var errorBinding: Binding { diff --git a/Sources/Shared/UI/ServerInfoView.swift b/Sources/Shared/UI/ServerInfoView.swift index a9611c6..af48595 100644 --- a/Sources/Shared/UI/ServerInfoView.swift +++ b/Sources/Shared/UI/ServerInfoView.swift @@ -55,8 +55,14 @@ struct ServerInfoView: View { Section("Это программное обеспечение, работающее на вашем сервере.") { softwareEntry(info) } - Section("Параметры подключения к вашему серверу.") { - connectionRows() + Section { + connectionRows(info) + } header: { + Text("Параметры подключения к вашему серверу.") + } footer: { + Text( + "Luma автоматически выбирает самый безопасный вариант: при TLS договаривается о максимальной версии (1.3 > 1.2), а при входе — о самом стойком SASL-механизме, который поддерживает сервер." + ) } Section("Современные возможности XMPP, обнаруженные на вашем сервере после входа.") { ForEach(info.capabilities) { capability in @@ -123,7 +129,7 @@ struct ServerInfoView: View { .listRowBackground(Color.gray.opacity(0.16)) } - private func connectionRows() -> some View { + private func connectionRows(_ info: ServerInformation) -> some View { Group { if let account = model.account { if let host = account.manualHost { @@ -138,6 +144,19 @@ struct ServerInfoView: View { } LabeledContent("Ресурс", value: account.effectiveResource) } + if let tlsVersion = info.tlsVersion { + LabeledContent( + "Версия TLS", + value: info.tlsCipher.map { "\(tlsVersion) · \($0)" } ?? tlsVersion + ) + } else { + LabeledContent("Версия TLS", value: "Неизвестно") + } + if let saslMechanism = info.saslMechanism { + LabeledContent("SASL-механизм", value: saslMechanism) + } else { + LabeledContent("SASL-механизм", value: "Неизвестно") + } } } diff --git a/Sources/Shared/XMPP/LumaOMEMO2Module.swift b/Sources/Shared/XMPP/LumaOMEMO2Module.swift index f89ad84..61c365b 100644 --- a/Sources/Shared/XMPP/LumaOMEMO2Module.swift +++ b/Sources/Shared/XMPP/LumaOMEMO2Module.swift @@ -28,7 +28,7 @@ final class LumaOMEMO2Module: AbstractPEPModule, XmppModule { public static let XMLNS = "urn:xmpp:omemo:2" public static let DEVICES_LIST_NODE = "urn:xmpp:omemo:2:devices" public static let BUNDLES_NODE = "urn:xmpp:omemo:2:bundles" - public static let SCE_XMLNS = "urn:xmpp:sce:1" + public static let SCE_XMLNS = "urn:xmpp:sce:0" public static let HKDF_INFO = "OMEMO Payload" public let id: String = ID @@ -75,6 +75,35 @@ final class LumaOMEMO2Module: AbstractPEPModule, XmppModule { return known.filter { !failed.contains($0) } } + /// Returns the cached OMEMO 2 device list for `jid`, fetching it from + /// PEP on demand when `fetching` is true and nothing is cached yet. Used + /// by the send path so the first message to an OMEMO 2-only contact does + /// not fall back to the legacy protocol. + public func deviceIDs(for jid: BareJID, fetching: Bool) async -> [Int32]? { + if let known = devices(for: jid) { return known } + guard fetching, let context else { return nil } + return await withCheckedContinuation { continuation in + context.module(.pubsub).retrieveItems( + from: jid, + for: Self.DEVICES_LIST_NODE, + limit: .lastItems(1) + ) { [weak self] result in + guard let self, + case .success(let items) = result, + let listEl = items.items.first?.payload, + listEl.name == "devices", + listEl.xmlns == Self.XMLNS + else { + continuation.resume(returning: nil) + return + } + let known = listEl.mapChildren(transform: { $0.getAttribute("id").flatMap(Int32.init) }) + self.devicesQueue.async { self.devices[jid] = known } + continuation.resume(returning: known) + } + } + } + public func isAvailable(for jid: BareJID) -> Bool { !(devicesQueue.sync { devices[jid] }?.isEmpty ?? true) || !storage.sessionStore.allDevices(for: jid.stringValue, activeAndTrusted: true).isEmpty @@ -166,6 +195,9 @@ final class LumaOMEMO2Module: AbstractPEPModule, XmppModule { // Track known devices and align identity states with the list. let known = list.mapChildren(transform: { $0.getAttribute("id").flatMap(Int32.init) }) devicesQueue.async { [weak self] in self?.devices[jid] = known } + // Record which device IDs are OMEMO 2 so the encryption screen can + // badge them; legacy-only identities stay unmarked. + (storage as? LumaOMEMOStore)?.setOMEMO2DeviceIDs(Set(known), for: jid.stringValue) let active = storage.sessionStore.allDevices(for: jid.stringValue, activeAndTrusted: true) active.filter { !known.contains($0) }.forEach { _ = storage.identityKeyStore.setStatus(active: false, forIdentity: SignalAddress(name: jid.stringValue, deviceId: $0)) @@ -780,7 +812,7 @@ final class LumaOMEMO2Module: AbstractPEPModule, XmppModule { let alphabet = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") let rpadLength = Int.random(in: 12...96) let rpad = String((0.." + var result = "" + content + "" result += "" + rpad + "" result += "" if let toJID { diff --git a/Sources/Shared/XMPP/LumaOMEMOStore.swift b/Sources/Shared/XMPP/LumaOMEMOStore.swift index 2cb0fca..b073f35 100644 --- a/Sources/Shared/XMPP/LumaOMEMOStore.swift +++ b/Sources/Shared/XMPP/LumaOMEMOStore.swift @@ -86,19 +86,38 @@ final class LumaOMEMOStore: SignalStorage { } func devices(for jid: String) -> [OMEMODevice] { - identityAdapter.identities(forName: jid.lowercased()).map { identity in + let key = jid.lowercased() + let published: Set = Set(repository.read { $0.omemo2DeviceIDs?[key] ?? [] }) + return identityAdapter.identities(forName: key).map { identity in OMEMODevice( jid: identity.address.name, deviceID: identity.address.deviceId, fingerprint: identity.fingerprint, trust: Self.mapTrust(identity.status), isActive: identity.status.isActive, - isOwn: identity.own + isOwn: identity.own, + isOMEMO2: published.contains(identity.address.deviceId) ) } .sorted { lhs, rhs in lhs.deviceID < rhs.deviceID } } + /// Records the set of device IDs a JID currently publishes under + /// `urn:xmpp:omemo:2`. Drives the per-device protocol badge on the + /// encryption screen and persists so the label survives a relaunch. + func setOMEMO2DeviceIDs(_ deviceIDs: Set, for jid: String) { + let key = jid.lowercased() + repository.mutate { state in + var all = state.omemo2DeviceIDs ?? [:] + if deviceIDs.isEmpty { + all.removeValue(forKey: key) + } else { + all[key] = deviceIDs.sorted() + } + state.omemo2DeviceIDs = all + } + } + func hasSession(for address: SignalAddress) -> Bool { sessionAdapter.containsSessionRecord(forAddress: address) } @@ -573,6 +592,9 @@ private struct StoredOMEMOState: Codable { var signedPreKeys: [String: Data] var sessions: [String: Data] var senderKeys: [String: Data] + // Lowercased JID -> device IDs currently published under urn:xmpp:omemo:2. + // Optional for backward compatibility with state written before OMEMO 2. + var omemo2DeviceIDs: [String: [Int32]]? static let empty = StoredOMEMOState( registrationID: 0, @@ -581,7 +603,8 @@ private struct StoredOMEMOState: Codable { preKeys: [:], signedPreKeys: [:], sessions: [:], - senderKeys: [:] + senderKeys: [:], + omemo2DeviceIDs: nil ) } diff --git a/Sources/Shared/XMPP/LumaScramPlusMechanism.swift b/Sources/Shared/XMPP/LumaScramPlusMechanism.swift new file mode 100644 index 0000000..6db3ee2 --- /dev/null +++ b/Sources/Shared/XMPP/LumaScramPlusMechanism.swift @@ -0,0 +1,271 @@ +import CommonCrypto +import Foundation +import Martin + +/// Digest-parameterized SCRAM math (RFC 5802) for the channel-binding +/// PLUS variants. The existing SCRAMSHA512 helper stays SHA-512-only for +/// its reference-vector tests; this core serves SHA-1/256/512 PLUS. +enum SCRAMHash { + case sha1 + case sha256 + case sha512 + + var mechanismName: String { + switch self { + case .sha1: return "SCRAM-SHA-1" + case .sha256: return "SCRAM-SHA-256" + case .sha512: return "SCRAM-SHA-512" + } + } + + var digestLength: Int { + switch self { + case .sha1: return Int(CC_SHA1_DIGEST_LENGTH) + case .sha256: return Int(CC_SHA256_DIGEST_LENGTH) + case .sha512: return Int(CC_SHA512_DIGEST_LENGTH) + } + } + + private var hmacAlgorithm: CCHmacAlgorithm { + switch self { + case .sha1: return CCHmacAlgorithm(kCCHmacAlgSHA1) + case .sha256: return CCHmacAlgorithm(kCCHmacAlgSHA256) + case .sha512: return CCHmacAlgorithm(kCCHmacAlgSHA512) + } + } + + private var pbkdfAlgorithm: CCPseudoRandomAlgorithm { + switch self { + case .sha1: return CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1) + case .sha256: return CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256) + case .sha512: return CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA512) + } + } + + func hmac(key: Data, data: Data) -> Data { + var output = [UInt8](repeating: 0, count: digestLength) + key.withUnsafeBytes { keyBytes in + data.withUnsafeBytes { dataBytes in + CCHmac( + hmacAlgorithm, + keyBytes.baseAddress, + keyBytes.count, + dataBytes.baseAddress, + dataBytes.count, + &output + ) + } + } + return Data(output) + } + + func hash(data: Data) -> Data { + var output = [UInt8](repeating: 0, count: digestLength) + data.withUnsafeBytes { bytes in + switch self { + case .sha1: + CC_SHA1(bytes.baseAddress, CC_LONG(bytes.count), &output) + case .sha256: + CC_SHA256(bytes.baseAddress, CC_LONG(bytes.count), &output) + case .sha512: + CC_SHA512(bytes.baseAddress, CC_LONG(bytes.count), &output) + } + } + return Data(output) + } + + /// PBKDF2 (RFC 5802 Hi function). + func saltedPassword(password: String, salt: Data, iterations: Int) -> Data { + let passwordBytes = Array(password.utf8) + var output = [UInt8](repeating: 0, count: digestLength) + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passwordBytes, + passwordBytes.count, + [UInt8](salt), + salt.count, + pbkdfAlgorithm, + UInt32(iterations), + &output, + output.count + ) + return Data(output) + } +} + +/// Pure SCRAM channel-binding exchange math (RFC 5802 + RFC 9266): given +/// the fixed inputs of one exchange it produces the wire messages and the +/// expected server signature. Kept independent of Martin's SaslMechanism +/// so unit tests can check it against reference vectors. +struct SCRAMPlusExchange { + let hash: SCRAMHash + let username: String + let password: String + let channelBindingType: String + let channelBindingData: Data + let clientNonce: String + + var gs2Header: String { "p=\(channelBindingType),," } + + var clientFirstMessageBare: String { "n=\(username),r=\(clientNonce)" } + + var clientFirstMessage: String { gs2Header + clientFirstMessageBare } + + /// gs2-header || channel-binding-data (RFC 5802 §7). + private var channelBindingInput: Data { + (gs2Header.data(using: .utf8) ?? Data()) + channelBindingData + } + + func clientFinalMessage(serverFirst: String) throws -> String { + let parsed = try SCRAMSHA512.parseServerFirst( + serverFirst, + expectedNoncePrefix: clientNonce + ) + let saltedPassword = hash.saltedPassword( + password: password, + salt: parsed.salt, + iterations: parsed.iterations + ) + let finalWithoutProof = + "c=\(channelBindingInput.base64EncodedString()),r=\(parsed.nonce)" + let authMessage = authMessageString(serverFirst: serverFirst, parsed: parsed) + let clientKey = hash.hmac(key: saltedPassword, data: Data("Client Key".utf8)) + let storedKey = hash.hash(data: clientKey) + let signature = hash.hmac(key: storedKey, data: Data(authMessage.utf8)) + let proof = Data(zip(clientKey, signature).map { $0 ^ $1 }) + return finalWithoutProof + ",p=" + proof.base64EncodedString() + } + + func expectedServerSignature(serverFirst: String) throws -> Data { + let parsed = try SCRAMSHA512.parseServerFirst( + serverFirst, + expectedNoncePrefix: clientNonce + ) + let saltedPassword = hash.saltedPassword( + password: password, + salt: parsed.salt, + iterations: parsed.iterations + ) + let authMessage = authMessageString(serverFirst: serverFirst, parsed: parsed) + let serverKey = hash.hmac(key: saltedPassword, data: Data("Server Key".utf8)) + return hash.hmac(key: serverKey, data: Data(authMessage.utf8)) + } + + private func authMessageString( + serverFirst: String, + parsed: SCRAMSHA512.ServerFirst + ) -> String { + let finalWithoutProof = + "c=\(channelBindingInput.base64EncodedString()),r=\(parsed.nonce)" + return clientFirstMessageBare + "," + serverFirst + "," + finalWithoutProof + } + + /// The base64 value of the server's final `v=` attribute, or nil when + /// the message is an `e=` error instead. + static func verifierValue(in finalMessage: String) -> String? { + guard finalMessage.hasPrefix("v=") else { return nil } + let value = finalMessage.dropFirst(2).split(separator: ",", maxSplits: 1).first ?? Substring() + guard !value.isEmpty else { return nil } + return String(value) + } +} + +/// SCRAM with channel binding (RFC 5802 / RFC 9266, XEP-0474): the +/// tls-exporter or tls-server-end-point binding proves the SASL exchange +/// runs over the same TLS channel the client sees, defeating MITM relays. +/// Registered ahead of the plain SCRAM variants when binding data exists. +final class LumaScramPlusMechanism: SaslMechanism { + let name: String + private(set) var status: SaslMechanismStatus = .new + + private let hash: SCRAMHash + private let channelBindingStore: LumaChannelBindingStore + + private var stage = 0 + private var exchange: SCRAMPlusExchange? + private var serverFirst: String? + + init(hash: SCRAMHash, channelBindingStore: LumaChannelBindingStore) { + self.hash = hash + self.channelBindingStore = channelBindingStore + name = hash.mechanismName + "-PLUS" + } + + func reset(scopes: Set) { + guard scopes.contains(.stream) else { return } + status = .new + stage = 0 + exchange = nil + serverFirst = nil + } + + func isAllowedToUse(_ context: Context) -> Bool { + guard case .password(_, _, _) = context.connectionConfiguration.credentials else { + return false + } + return channelBindingStore.canUseChannelBinding + } + + func evaluateChallenge(_ input: String?, context: Context) throws -> String? { + guard status != .completed else { + guard input == nil else { + throw ClientSaslException.genericError(msg: "Already authorized") + } + return nil + } + switch stage { + case 0: + guard case .password(let password, _, _) = context.connectionConfiguration.credentials, + let bindingType = channelBindingStore.preferredChannelBindingType, + let bindingData = channelBindingStore.channelBindingData(for: bindingType) + else { + throw ClientSaslException.genericError( + msg: "Channel binding unavailable") + } + exchange = SCRAMPlusExchange( + hash: hash, + username: context.userBareJid.localPart ?? "", + password: password, + channelBindingType: bindingType, + channelBindingData: bindingData, + clientNonce: SCRAMSHA512.makeNonce() + ) + stage = 1 + status = .completedExpected + return exchange?.clientFirstMessage.data(using: .utf8)?.base64EncodedString() + + case 1: + guard let exchange, + let input, + let data = Data(base64Encoded: input), + let decodedServerFirst = String(data: data, encoding: .utf8) + else { + throw ClientSaslException.badChallenge(msg: "Invalid challenge") + } + serverFirst = decodedServerFirst + let final = try exchange.clientFinalMessage(serverFirst: decodedServerFirst) + stage = 2 + return final.data(using: .utf8)?.base64EncodedString() + + case 2: + guard let exchange, let serverFirst, + let input, + let data = Data(base64Encoded: input), + let finalMessage = String(data: data, encoding: .utf8), + let verifier = SCRAMPlusExchange.verifierValue(in: finalMessage), + let value = Data(base64Encoded: verifier) + else { + throw ClientSaslException.badChallenge(msg: "Invalid final challenge") + } + let expected = try exchange.expectedServerSignature(serverFirst: serverFirst) + guard value == expected else { + throw ClientSaslException.invalidServerSignature + } + status = .completed + return nil + + default: + throw ClientSaslException.genericError(msg: "Invalid SCRAM stage") + } + } +} diff --git a/Sources/Shared/XMPP/LumaTLSNetworkProcessor.swift b/Sources/Shared/XMPP/LumaTLSNetworkProcessor.swift new file mode 100644 index 0000000..2ff25ed --- /dev/null +++ b/Sources/Shared/XMPP/LumaTLSNetworkProcessor.swift @@ -0,0 +1,443 @@ +import CryptoKit +import Foundation +import Martin +import OpenSSL +import Security + +/// Negotiated TLS parameters of the live connection, captured by the TLS +/// processor and shared with the SASL layer (SCRAM channel binding) and the +/// server-information screen. +struct LumaTLSState: Sendable { + /// "TLSv1.3" / "TLSv1.2" — OpenSSL's SSL_get_version string. + let version: String + /// OpenSSL cipher-suite name, e.g. TLS_AES_256_GCM_SHA384. + let cipher: String + /// DER of the leaf certificate. + let leafCertificateDER: Data + /// RFC 9266 tls-exporter output (32 bytes), when the handshake version + /// supports exporters (TLS 1.3). + let exporter: Data? +} + +/// Thread-safe holder of the negotiated TLS state plus the channel-binding +/// types the server advertised in its stream features. +final class LumaChannelBindingStore: @unchecked Sendable { + private let lock = NSLock() + private var tlsState: LumaTLSState? + private var advertisedTypes: Set = [] + + func setTLSState(_ state: LumaTLSState) { + lock.lock() + tlsState = state + lock.unlock() + } + + func setAdvertisedChannelBindingTypes(_ types: Set) { + lock.lock() + advertisedTypes = types + lock.unlock() + } + + func reset() { + lock.lock() + tlsState = nil + advertisedTypes = [] + lock.unlock() + } + + var negotiatedState: LumaTLSState? { + lock.lock() + defer { lock.unlock() } + return tlsState + } + + /// The strongest channel-binding type both sides support: RFC 9266 + /// tls-exporter first, then tls-server-end-point. + var preferredChannelBindingType: String? { + lock.lock() + defer { lock.unlock() } + guard let tlsState else { return nil } + if advertisedTypes.contains("tls-exporter"), tlsState.exporter != nil { + return "tls-exporter" + } + if advertisedTypes.contains("tls-server-end-point") { + return "tls-server-end-point" + } + return nil + } + + /// Channel-binding data for a concrete type: RFC 9266 §4.2 for + /// tls-exporter and §4.3 for tls-server-end-point (SHA-256 of the leaf + /// certificate; RFC 5929's upgrade rules make this correct for the + /// SHA-256-signed certificates used in practice on TLS 1.2 as well). + func channelBindingData(for type: String) -> Data? { + lock.lock() + defer { lock.unlock() } + guard let tlsState else { return nil } + switch type { + case "tls-exporter": + return tlsState.exporter + case "tls-server-end-point": + return Data(SHA256.hash(data: tlsState.leafCertificateDER)) + default: + return nil + } + } + + var canUseChannelBinding: Bool { + preferredChannelBindingType != nil + } +} + +/// Supplies Luma's OpenSSL-based TLS processor to Martin's legacy +/// SocketConnector. SecureTransport caps stream TLS at 1.2 and has no keying +/// exporter, so Luma runs its own TLS 1.3 stack (like Monal) while Martin +/// keeps owning the socket and the XMPP framing. +final class LumaTLSNetworkProcessorProvider: NetworkProcessorProvider { + var providedFeatures: [ConnectorFeature] = [.TLS] + + private let channelBindingStore: LumaChannelBindingStore + + init(channelBindingStore: LumaChannelBindingStore) { + self.channelBindingStore = channelBindingStore + } + + func supply() -> SocketConnector.NetworkProcessor { + LumaTLSNetworkProcessor(channelBindingStore: channelBindingStore) + } +} + +/// OpenSSL TLS 1.3 client processor driven by Martin's byte stream: +/// encrypted socket bytes go in through read(data:), plaintext goes out to +/// the connector; plaintext from the connector is encrypted and handed to +/// the socket through the write delegate. +final class LumaTLSNetworkProcessor: SocketConnector.NetworkProcessor, SSLNetworkProcessor { + var serverName: String? + var certificateValidation: SSLCertificateValidation = .default + var certificateValidationFailed: ((SecTrust?) -> Void)? + + private let channelBindingStore: LumaChannelBindingStore + private var alpnProtocols: [String] = [] + + private var sslContext: OpaquePointer? + private var ssl: OpaquePointer? + /// C-string copy of the SNI hostname; OpenSSL keeps the pointer, so the + /// storage must outlive the SSL object. + private var storedServerName: [CChar]? + private var readBIO: OpaquePointer? + private var writeBIO: OpaquePointer? + private var handshakeCompleted = false + private var handshakeFailed = false + private var pendingPlaintext: [UInt8] = [] + + init(channelBindingStore: LumaChannelBindingStore) { + self.channelBindingStore = channelBindingStore + super.init() + } + + deinit { + SSL_free(ssl) + SSL_CTX_free(sslContext) + } + + func setALPNProtocols(_ protocols: [String]) { + alpnProtocols = protocols + } + + // MARK: - Byte stream + + /// Encrypted bytes from the socket. + override func read(data: Data) { + guard !handshakeFailed else { return } + ensureTLS() + guard let ssl, let readBIO else { return } + data.withUnsafeBytes { raw in + _ = BIO_write(readBIO, raw.baseAddress, Int32(raw.count)) + } + drive() + } + + /// Plaintext to encrypt and send to the socket. + override func write(data: Data, completion: WriteCompletion) { + guard !handshakeFailed else { + completion.completed(result: .failure(LumaTLSProcessorError.handshakeFailed)) + return + } + pendingPlaintext.append(contentsOf: data) + ensureTLS() + drive() + completion.completed(result: .success(())) + } + + // MARK: - TLS lifecycle + + private func ensureTLS() { + guard ssl == nil, !handshakeFailed else { return } + guard let context = SSL_CTX_new(TLS_client_method()) else { + handshakeFailed = true + return + } + sslContext = context + // SSL_CTX_set_min/max_proto_version are C macros, so the Swift + // importer drops them. Drive the underlying control instead; the + // values come from (TLS1_2_VERSION 0x0303, + // TLS1_3_VERSION 0x0304) and + // (SSL_CTRL_SET_MIN_PROTO_VERSION 123, SSL_CTRL_SET_MAX_PROTO_VERSION + // 124). This pins the connection to TLS 1.2–1.3. + _ = SSL_CTX_ctrl(context, 123, 0x0303, nil) + _ = SSL_CTX_ctrl(context, 124, 0x0304, nil) + // The peer must present a certificate, but OpenSSL's own chain + // verification is disabled: it has no CA store and would reject the + // server mid-handshake with an "unknown ca" alert. Actual trust + // evaluation is performed by Luma after the handshake (SecTrust + + // configured validation), mirroring Martin's custom-validator flow. + SSL_CTX_set_verify(context, SSL_VERIFY_PEER, { _, _ in 1 }) + guard let connection = SSL_new(context) else { + handshakeFailed = true + return + } + // This OpenSSL build does not pre-select the connection role in + // SSL_new; without it SSL_do_handshake fails with + // "connection type not set". + SSL_set_connect_state(connection) + ssl = connection + if let serverName { + // SSL_set_tlsext_host_name is a C macro; use SSL_ctrl with + // SSL_CTRL_SET_TLSEXT_HOSTNAME (55) and TLSEXT_NAMETYPE_host_name + // (0) instead. OpenSSL keeps the pointer, so the C string must + // stay alive for the lifetime of the handshake. + storedServerName = Array(serverName.utf8CString) + storedServerName?.withUnsafeMutableBufferPointer { buffer in + _ = SSL_ctrl(connection, 55, 0, buffer.baseAddress) + } + } + if !alpnProtocols.isEmpty { + var wire: [UInt8] = [] + for proto in alpnProtocols { + let bytes = Array(proto.utf8) + wire.append(UInt8(bytes.count)) + wire.append(contentsOf: bytes) + } + _ = SSL_set_alpn_protos(connection, wire, UInt32(wire.count)) + } + let incoming = BIO_new(BIO_s_mem()) + let outgoing = BIO_new(BIO_s_mem()) + guard let incoming, let outgoing else { + handshakeFailed = true + return + } + readBIO = incoming + writeBIO = outgoing + SSL_set_bio(connection, incoming, outgoing) + } + + private func drive() { + guard let ssl else { return } + if !handshakeCompleted, !handshakeFailed { + driveHandshake() + } + if handshakeCompleted, !pendingPlaintext.isEmpty { + // Copy the pending plaintext before the C call: the array cannot + // be mutated while it is borrowed by withUnsafeBufferPointer. + let buffer = pendingPlaintext + let written = buffer.withUnsafeBufferPointer { raw -> Int32 in + guard let base = raw.baseAddress, !raw.isEmpty else { return 0 } + return SSL_write(ssl, base, Int32(raw.count)) + } + if written > 0 { + pendingPlaintext.removeFirst(Int(written)) + } + } + flushEncryptedOutput() + if handshakeCompleted { + emitDecrypted() + flushEncryptedOutput() + } + } + + private func driveHandshake() { + guard let ssl else { return } + let result = SSL_do_handshake(ssl) + if result == 1 { + handshakeCompleted = true + completeHandshake() + return + } + switch SSL_get_error(ssl, result) { + case SSL_ERROR_WANT_READ, SSL_ERROR_WANT_WRITE: + // Progress made; more bytes needed from either side. + break + default: + handshakeFailed = true + certificateValidationFailed?(nil) + } + } + + private func flushEncryptedOutput() { + guard let writeBIO else { return } + var buffer = [UInt8](repeating: 0, count: 16_384) + while true { + let count = BIO_read(writeBIO, &buffer, Int32(buffer.count)) + guard count > 0 else { break } + super.write( + data: Data(buffer[0.. 0 { + super.read(data: Data(buffer[0.. Data? { + // Encode into a fixed scratch buffer: X.509 certificates are far + // smaller than 16 KiB in practice, and this avoids manual OpenSSL + // memory management in Swift. + let length = i2d_X509(certificate, nil) + guard length > 0, length <= 16_384 else { return nil } + var buffer = [UInt8](repeating: 0, count: Int(length)) + var copied: Int32 = 0 + buffer.withUnsafeMutableBytes { raw in + var cursor = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) + copied = i2d_X509(certificate, &cursor) + } + guard copied == length else { return nil } + return Data(buffer) + } + + private static func matchesFingerprint(_ der: Data, fingerprint: String) -> Bool { + let expected = fingerprint + .replacingOccurrences(of: ":", with: "") + .replacingOccurrences(of: " ", with: "") + .lowercased() + let candidates = [ + Insecure.SHA1.hash(data: der).map { String(format: "%02x", $0) }.joined(), + SHA256.hash(data: der).map { String(format: "%02x", $0) }.joined(), + ] + return candidates.contains(expected) + } +} + +enum LumaTLSProcessorError: Error { + case handshakeFailed +} diff --git a/Sources/Shared/XMPP/TLSVersionProbe.swift b/Sources/Shared/XMPP/TLSVersionProbe.swift new file mode 100644 index 0000000..89121b1 --- /dev/null +++ b/Sources/Shared/XMPP/TLSVersionProbe.swift @@ -0,0 +1,304 @@ +import Foundation +import Security + +/// Result of probing the negotiated TLS parameters of the XMPP server: +/// the protocol version and the cipher suite actually agreed upon. +struct TLSProbeResult: Sendable { + let version: String + let cipher: String +} + +/// Standalone, best-effort probe that negotiates TLS against the same host +/// and port the XMPP connection uses, and reports the negotiated TLS version +/// and cipher suite. It performs the STARTTLS stream handshake (stream +/// header + ) when the account does not use Direct TLS, mirroring +/// what Martin's SocketConnector does, then reads the result from +/// SecureTransport. The app's real connection negotiates TLS the same way, +/// so the probe accurately reflects what the server screen displays. +final class TLSVersionProbe: NSObject, StreamDelegate { + private let host: String + private let port: UInt32 + private let directTLS: Bool + private let expectedDomain: String + private let completion: (TLSProbeResult?) -> Void + + private enum Phase { + case starting + case expectingFeatures + case tlsNegotiating + case finished + } + + private var phase: Phase = .starting + private var inputStream: InputStream? + private var outputStream: OutputStream? + private var receivedData = Data() + private var sentStreamHeader = false + private var sentStartTLS = false + private var sawStartTLSOffer = false + private var sslContext: SSLContext? + private var runLoop: RunLoop? + private var finished = false + + private static let activeLock = NSLock() + private static var activeProbes: Set = [] + + static func run( + host: String, + port: UInt32, + directTLS: Bool, + expectedDomain: String, + completion: @escaping (TLSProbeResult?) -> Void + ) { + let probe = TLSVersionProbe( + host: host, + port: port, + directTLS: directTLS, + expectedDomain: expectedDomain, + completion: completion + ) + activeLock.lock() + activeProbes.insert(probe) + activeLock.unlock() + probe.start() + } + + private init( + host: String, + port: UInt32, + directTLS: Bool, + expectedDomain: String, + completion: @escaping (TLSProbeResult?) -> Void + ) { + self.host = host + self.port = port + self.directTLS = directTLS + self.expectedDomain = expectedDomain + self.completion = completion + super.init() + } + + private func start() { + let thread = Thread { [weak self] in + guard let self else { return } + self.runLoop = RunLoop.current + var readStream: Unmanaged? + var writeStream: Unmanaged? + CFStreamCreatePairWithSocketToHost( + kCFAllocatorDefault, + self.host as CFString, + self.port, + &readStream, + &writeStream + ) + guard let readStream, let writeStream else { + self.finish(nil) + return + } + self.inputStream = readStream.takeRetainedValue() + self.outputStream = writeStream.takeRetainedValue() + self.inputStream?.delegate = self + self.outputStream?.delegate = self + self.inputStream?.schedule(in: .current, forMode: .default) + self.outputStream?.schedule(in: .current, forMode: .default) + self.inputStream?.open() + self.outputStream?.open() + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 12) { [weak self] in + guard let self, !self.finished else { return } + self.finish(nil) + } + RunLoop.current.run() + } + thread.name = "app.luma.tlsProbe" + thread.start() + } + + // MARK: - StreamDelegate + + func stream(_ aStream: Stream, handle eventCode: Stream.Event) { + switch eventCode { + case .openCompleted: + if phase == .tlsNegotiating { + reportTLSResult() + } else if directTLS, phase == .starting { + configureTLS() + } else if !sentStreamHeader { + writeStreamHeader() + } + case .hasSpaceAvailable: + if directTLS { return } + if !sentStreamHeader { + writeStreamHeader() + } else if sawStartTLSOffer, !sentStartTLS { + writeStartTLS() + } + case .hasBytesAvailable: + readAvailableBytes() + case .errorOccurred, .endEncountered: + if phase == .tlsNegotiating { + reportTLSResult() + } else if phase != .finished { + finish(nil) + } + default: + break + } + } + + // MARK: - Steps + + private func writeStreamHeader() { + guard let outputStream else { return } + let header = + "" + + "" + let data = Data(header.utf8) + sentStreamHeader = data.count == outputStream.write( + [UInt8](data), + maxLength: data.count + ) + if sentStreamHeader, !directTLS { + phase = .expectingFeatures + } + } + + private func writeStartTLS() { + guard let outputStream else { return } + let startTLS = "" + let data = Data(startTLS.utf8) + sentStartTLS = data.count == outputStream.write( + [UInt8](data), + maxLength: data.count + ) + } + + private func readAvailableBytes() { + guard let inputStream else { return } + var chunk = [UInt8](repeating: 0, count: 4096) + while inputStream.hasBytesAvailable { + let count = inputStream.read(&chunk, maxLength: chunk.count) + guard count > 0 else { break } + receivedData.append(contentsOf: chunk[0.. String? { + switch protocolVersion.rawValue { + case 10: return "TLS 1.3" + case 8: return "TLS 1.2" + case 7: return "TLS 1.1" + case 4: return "TLS 1.0" + case 2: return "SSL 3.0" + default: return nil + } + } + + static func cipherDescription(_ cipher: SSLCipherSuite) -> String { + switch cipher { + case 0x1301: return "TLS_AES_128_GCM_SHA256" + case 0x1302: return "TLS_AES_256_GCM_SHA384" + case 0x1303: return "TLS_CHACHA20_POLY1305_SHA256" + case 0xC02F: return "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" + case 0xC02B: return "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" + case 0xC030: return "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" + case 0xC02C: return "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384" + case 0xCCA8: return "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256" + case 0xCCA9: return "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256" + case 0xCCAA: return "TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256" + case 0x009E: return "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256" + case 0x009F: return "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384" + case 0x009C: return "TLS_RSA_WITH_AES_128_GCM_SHA256" + case 0x009D: return "TLS_RSA_WITH_AES_256_GCM_SHA384" + case 0xC013: return "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA" + case 0xC014: return "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA" + case 0x003C: return "TLS_RSA_WITH_AES_128_CBC_SHA256" + case 0x002F: return "TLS_RSA_WITH_AES_128_CBC_SHA" + case 0x0035: return "TLS_RSA_WITH_AES_256_CBC_SHA" + default: return String(format: "0x%04X", cipher) + } + } +} diff --git a/Sources/Shared/XMPP/XMPPService.swift b/Sources/Shared/XMPP/XMPPService.swift index db8d439..321b642 100644 --- a/Sources/Shared/XMPP/XMPPService.swift +++ b/Sources/Shared/XMPP/XMPPService.swift @@ -270,6 +270,16 @@ final class XMPPService { private var activePassword: String? private var lastLoginDate: Date? private var smacksSessionEstablishedDate: Date? + private(set) var negotiatedSASLMechanism: String? + private(set) var negotiatedTLSVersion: String? + private(set) var negotiatedTLSCipher: String? + private var tlsProbeTask: Task? + /// TLS parameters and channel-binding data of the live connection, + /// captured by Luma's OpenSSL TLS processor and shared with SCRAM-PLUS. + private let channelBindingStore = LumaChannelBindingStore() + /// Mechanisms advertised in the stream features (classic SASL + SASL2), + /// shown on the server-information screen. + private var advertisedSASLMechanisms: [String] = [] private var cancellables: Set = [] private var account: AccountConfiguration? private var archiveSyncStarted = false @@ -418,6 +428,13 @@ final class XMPPService { roomRealJIDByNickname.removeAll() omemoConfiguredRoomJIDs.removeAll() knownChatStatePeers.removeAll() + negotiatedSASLMechanism = nil + negotiatedTLSVersion = nil + negotiatedTLSCipher = nil + tlsProbeTask?.cancel() + tlsProbeTask = nil + channelBindingStore.reset() + advertisedSASLMechanisms = [] let account = try account.validated() self.account = account @@ -517,6 +534,11 @@ final class XMPPService { saslFailureModule = nil lastLoginDate = nil smacksSessionEstablishedDate = nil + negotiatedSASLMechanism = nil + negotiatedTLSVersion = nil + negotiatedTLSCipher = nil + tlsProbeTask?.cancel() + tlsProbeTask = nil eventHandler?(.connection(.disconnected(reason: nil))) } @@ -861,9 +883,12 @@ final class XMPPService { ) // OMEMO 2 only when every member publishes OMEMO 2 devices; // otherwise the legacy protocol keeps legacy members readable. + // Device lists are fetched on demand so a first message to a + // room never falls back to legacy just because the lists are + // not cached yet. if let omemo2 = client.moduleOrNil(.omemo2), omemo2.isReady, - recipients.allSatisfy({ omemo2.devices(for: $0)?.isEmpty == false }) { + await Self.allRecipientsPublishOMEMO2(recipients, module: omemo2) { let encryptedMessage = try await encrypt( message, forGroupRecipients: recipients, @@ -908,10 +933,13 @@ final class XMPPService { } if encrypted { // Prefer OMEMO 2 when the peer publishes OMEMO 2 devices; legacy - // OMEMO stays as the fallback for legacy-only contacts. + // OMEMO stays as the fallback for legacy-only contacts. The + // peer's device list is fetched on demand, so the very first + // message to an OMEMO 2-only contact no longer falls back to + // legacy. if let omemo2 = client.moduleOrNil(.omemo2), omemo2.isReady, - omemo2.devices(for: peer)?.isEmpty == false { + await !(omemo2.deviceIDs(for: peer, fetching: true) ?? []).isEmpty { let encryptedMessage = try await encrypt(message, using: omemo2) try await chat.send(message: encryptedMessage.message) return encryptedMessage.fingerprint @@ -928,6 +956,32 @@ final class XMPPService { } } + /// OpenSSL version strings to their user-facing form ("TLSv1.3" → + /// "TLS 1.3"). + private static func displayTLSVersion(_ version: String) -> String { + switch version { + case "TLSv1.3": return "TLS 1.3" + case "TLSv1.2": return "TLS 1.2" + case "TLSv1.1": return "TLS 1.1" + case "TLSv1": return "TLS 1.0" + default: return version + } + } + + /// True when every MUC recipient has a non-empty OMEMO 2 device list. + /// Fetches the lists from PEP on demand for recipients that have not been + /// cached yet. + private static func allRecipientsPublishOMEMO2( + _ recipients: [BareJID], + module: LumaOMEMO2Module + ) async -> Bool { + for recipient in recipients { + let devices = await module.deviceIDs(for: recipient, fetching: true) ?? [] + if devices.isEmpty { return false } + } + return true + } + /// Loads one older MAM page for a single conversation. `before` is the /// oldest server/MAM id currently known by the UI. For direct chats the /// query is scoped with XEP-0313 `with`; for MUC the IQ is addressed to the @@ -1439,6 +1493,58 @@ final class XMPPService { ownFingerprint: omemoStorage?.ownFingerprint)) } + /// Negotiated TLS version of the current connection, probing the + /// server with a parallel handshake when the underlying library does not + /// expose it. Single-flight: the first caller starts the probe, later + /// callers (and later screen refreshes) reuse the stored result. + private func currentTLSVersion() async -> String? { + // Prefer the negotiated parameters of the live connection, captured + // by the OpenSSL TLS processor. + if let state = channelBindingStore.negotiatedState { + negotiatedTLSVersion = Self.displayTLSVersion(state.version) + negotiatedTLSCipher = state.cipher + return negotiatedTLSVersion + } + if let existing = tlsProbeTask { + return await existing.value?.version + } + guard let client, client.state == .connected() else { + return negotiatedTLSVersion + } + let endpointHost: String + let endpointPort: Int + if let endpoint = client.connector?.currentEndpoint as? SocketConnector.Endpoint { + endpointHost = endpoint.host + endpointPort = endpoint.port + } else if let account { + endpointHost = account.manualHost ?? account.domain ?? account.normalizedJID + endpointPort = account.manualPort ?? (account.usesDirectTLS ? 5223 : 5222) + } else { + return negotiatedTLSVersion + } + let directTLS = account?.usesDirectTLS ?? (endpointPort == 5223) + let domain = account?.domain ?? client.userBareJid.domain + let task = Task.detached(priority: .utility) { () -> TLSProbeResult? in + await withCheckedContinuation { continuation in + TLSVersionProbe.run( + host: endpointHost, + port: UInt32(endpointPort), + directTLS: directTLS, + expectedDomain: domain + ) { result in + continuation.resume(returning: result) + } + } + } + tlsProbeTask = task + let result = await task.value + if let result { + negotiatedTLSVersion = result.version + negotiatedTLSCipher = result.cipher + } + return negotiatedTLSVersion + } + /// Queries the server's capabilities to populate the "server information" /// settings screen: XEP-0030 disco#info (server + account), XEP-0092 /// software version, conference (MUC) services via disco#items, and @@ -1510,11 +1616,18 @@ final class XMPPService { // SASL mechanisms and Client State Indication are advertised in the raw // stream , not in disco#info, so read them from there. let streamFeaturesElement = client.module(.streamFeatures).streamFeatures.element - let saslMechanisms = streamFeaturesElement? - .findChild(name: "mechanisms", xmlns: "urn:ietf:params:xml:ns:xmpp-sasl")? - .children - .filter { $0.name == "mechanism" } - .compactMap { $0.value } ?? [] + let saslMechanisms: [String] + if !advertisedSASLMechanisms.isEmpty { + // Includes both classic SASL and SASL2 mechanisms when the server + // advertises them in separate stream-feature elements. + saslMechanisms = advertisedSASLMechanisms + } else { + saslMechanisms = streamFeaturesElement? + .findChild(name: "mechanisms", xmlns: "urn:ietf:params:xml:ns:xmpp-sasl")? + .children + .filter { $0.name == "mechanism" } + .compactMap { $0.value } ?? [] + } let supportsCSI = streamFeaturesElement? .findChild(name: "csi", xmlns: "urn:xmpp:csi:0") != nil let supportsRosterVersioning = streamFeaturesElement? @@ -1529,6 +1642,23 @@ final class XMPPService { let stats = connectionStatsModule?.snapshot ?? LumaConnectionStatsModule.Snapshot(sent: 0, acknowledged: 0, received: 0) + let tlsVersion = await currentTLSVersion() + // Show the channel-binding type alongside a PLUS mechanism, so the + // screen reflects how the exchange was actually bound to the TLS + // channel (tls-exporter preferred, tls-server-end-point fallback). + let displaySASLMechanism: String? + if let negotiatedSASLMechanism { + if negotiatedSASLMechanism.hasSuffix("-PLUS"), + let bindingType = channelBindingStore.preferredChannelBindingType + { + displaySASLMechanism = negotiatedSASLMechanism + " · " + bindingType + } else { + displaySASLMechanism = negotiatedSASLMechanism + } + } else { + displaySASLMechanism = nil + } + return ServerInformation( serverDomain: serverDomain, software: ServerInformation.Software( @@ -1555,6 +1685,9 @@ final class XMPPService { received: stats.received ), saslMethods: saslMechanisms, + tlsVersion: tlsVersion, + tlsCipher: negotiatedTLSCipher, + saslMechanism: displaySASLMechanism, supportsStreamManagement: client.module(.streamManagement).isAvailable, supportsCarbons: client.module(.messageCarbons).isAvailable, supportsClientState: supportsCSI, @@ -1649,10 +1782,24 @@ final class XMPPService { // Registered before SaslModule so the raw RFC 6120 failure condition // is captured before Martin collapses it into SaslError. saslFailureModule = client.modulesManager.register(LumaSaslFailureModule()) - // SCRAM-SHA-512 first: Martin only ships SHA-1/SHA-256, while modern - // servers prefer SHA-512. The server must advertise it or Martin's - // mechanism selection skips it. + // Channel-binding SCRAM first (tls-exporter / tls-server-end-point + // over the live TLS 1.3 connection), then SCRAM-SHA-512: Martin only + // ships SHA-1/SHA-256, while modern servers prefer SHA-512. A + // mechanism is only eligible when the server advertises it, and the + // PLUS variants additionally gate on available binding data. let sasl = client.modulesManager.register(SaslModule()) + sasl.addMechanism( + LumaScramPlusMechanism(hash: .sha1, channelBindingStore: channelBindingStore), + first: true + ) + sasl.addMechanism( + LumaScramPlusMechanism(hash: .sha256, channelBindingStore: channelBindingStore), + first: true + ) + sasl.addMechanism( + LumaScramPlusMechanism(hash: .sha512, channelBindingStore: channelBindingStore), + first: true + ) sasl.addMechanism(LumaScramSha512Mechanism(), first: true) _ = client.modulesManager.register(ResourceBinderModule()) _ = client.modulesManager.register(SessionEstablishmentModule()) @@ -1754,6 +1901,13 @@ final class XMPPService { expectedDomain: expectedDomain ) } + // SecureTransport caps stream TLS at 1.2 and has no keying + // exporter, so Luma supplies its own OpenSSL TLS 1.3 processor + // (TLS 1.3 + STARTTLS + channel binding). Certificate trust is + // still evaluated through the custom validator above. + options.networkProcessorProviders = [ + LumaTLSNetworkProcessorProvider(channelBindingStore: channelBindingStore), + ] if let host = account.manualHost { let defaultPort = account.usesDirectTLS ? 5223 : 5222 options.dnsResolver = StaticDNSSrvResolver( @@ -2067,7 +2221,26 @@ final class XMPPService { security = .plaintext fingerprint = nil contentMessage = message + case .duplicateMessage: + // The Double Ratchet message key is single-use: `.duplicateMessage` + // means this exact stanza was already decrypted on an earlier + // delivery (carbon, SMACK resend or a previous MAM pass), and its + // content was handled then. Emitting another bubble here turned the + // archived echoes of our own receipts, chat markers and resent + // copies into phantom "Не удалось расшифровать сообщение" + // placeholders that do not exist on the server. Mirrors the group + // message path. + return default: + if outgoing { + // Our own outgoing echo cannot be decrypted back (no + // encrypt-to-self key, or a stale self-session). The + // optimistic local copy already carries the content, and + // messages sent from other devices have nothing we could + // render. Other clients never surface these stanzas as + // messages, so dropping them matches the server history. + return + } // Some clients include a plaintext fallback alongside the // OMEMO payload. When decryption fails, prefer that // fallback so a readable message is never shown as undecryptable. @@ -2076,13 +2249,6 @@ final class XMPPService { fingerprint = nil contentMessage = message } else { - // Includes `.duplicateMessage` (our own message without an - // encrypt-to-self key, or a key already consumed by a - // previous session): the body can no longer be recovered - // here. Still emit the message (as an undecryptable bubble) - // instead of dropping it, so history does not silently - // stop. Genuine duplicates are filtered later in AppModel - // by stanza/origin ID. security = .decryptionFailed fingerprint = nil contentMessage = nil @@ -4001,6 +4167,12 @@ final class XMPPService { condition: saslFailureModule?.lastFailure?.condition, serverText: saslFailureModule?.lastFailure?.text ) + case .sslCertError: + return "Сертификат сервера не прошёл проверку доверия." + case .timeout: + return "Сервер не ответил вовремя." + case .noRouteToServer: + return "Сервер недоступен по сети." default: return reason.localizedDescription } @@ -4014,13 +4186,43 @@ final class XMPPService { } private func applySASLprepIfNeeded(client: XMPPClient, features: Element) { - guard let activePassword, !activePassword.isEmpty else { return } + // Classic RFC 6120 mechanisms, SASL2 (RFC 9050) mechanisms and the + // XEP-0440 channel-binding types are three separate stream-feature + // elements; collect all three so the selection and the server screen + // see the complete picture. let mechanisms = features .findChild(name: "mechanisms", xmlns: "urn:ietf:params:xml:ns:xmpp-sasl")? .children .filter { $0.name == "mechanism" } .compactMap { $0.value } ?? [] + let sasl2Mechanisms = + features + .findChild(name: "authentication", xmlns: "urn:xmpp:sasl:2")? + .children + .filter { $0.name == "mechanism" } + .compactMap { $0.value } ?? [] + let channelBindingTypes = Set( + features + .findChild(name: "sasl-channel-binding", xmlns: "urn:xmpp:sasl-cb:0")? + .children + .filter { $0.name == "channel-binding" } + .compactMap { $0.getAttribute("type") } ?? [] + ) + channelBindingStore.setAdvertisedChannelBindingTypes(channelBindingTypes) + advertisedSASLMechanisms = Array(Set(mechanisms + sasl2Mechanisms)) + // Record the mechanism the SASL layer picks for this connection: the + // strongest password-based one the server advertises + // (SCRAM-*-PLUS > SCRAM-SHA-512 > … > PLAIN). Keep the previous value + // when the server sends features without mechanisms (e.g. stream + // resumption after SMACK), so the screen does not flip to unknown. + if let selected = SASLMechanismPreference.selected( + among: mechanisms, + allowsChannelBinding: channelBindingStore.canUseChannelBinding + ) { + negotiatedSASLMechanism = selected + } + guard let activePassword, !activePassword.isEmpty else { return } guard mechanisms.contains(where: { $0.hasPrefix("SCRAM-") }) else { return } guard case .password(let currentPassword, _, _) = client.connectionConfiguration.credentials, let prepared = try? SASLprep.prepare(activePassword), diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2811ef0..e78e8c5 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,6 +5,9 @@ Luma links the following Swift Package Manager dependencies at build time: - Martin 3.2.4 by Tigase, Inc. — AGPL-3.0. - MartinOMEMO 2.2.3 by Tigase, Inc. — GPL-3.0. - WebRTC M150 binary package by the WebRTC project/stasel — BSD-style license. +- OpenSSL 3.x binary XCFramework package by krzyzanowskim — Apache-2.0. Used for + the TLS layer (TLS 1.3, STARTTLS) and SCRAM channel binding + (tls-exporter / tls-server-end-point). - tigase-logging.swift, pulled transitively by Martin — AGPL-3.0. - Tigase's libsignal Swift package, pulled by MartinOMEMO — GPL-3.0. diff --git a/Tests/LumaOMEMO2Tests.swift b/Tests/LumaOMEMO2Tests.swift index 8f6d248..8e8146b 100644 --- a/Tests/LumaOMEMO2Tests.swift +++ b/Tests/LumaOMEMO2Tests.swift @@ -72,7 +72,7 @@ final class LumaOMEMO2Tests: XCTestCase { ) let envelope = try XCTUnwrap(Element.from(string: xml)) XCTAssertEqual(envelope.name, "envelope") - XCTAssertEqual(envelope.xmlns, "urn:xmpp:sce:1") + XCTAssertEqual(envelope.xmlns, "urn:xmpp:sce:0") let content = try XCTUnwrap(envelope.findChild(name: "content")) let body = try XCTUnwrap(content.findChild(name: "body")) @@ -102,6 +102,16 @@ final class LumaOMEMO2Tests: XCTestCase { ) } + func testSCEEnvelopeUsesSCE0Namespace() throws { + // XEP-0420 Stanza Content Encryption uses urn:xmpp:sce:0; OMEMO 2 + // relies on that exact namespace for the encrypted payload envelope. + XCTAssertEqual(LumaOMEMO2Module.SCE_XMLNS, "urn:xmpp:sce:0") + let xml = LumaOMEMO2Module.envelopeXML(body: "hi", from: "a@b.c", to: nil) + let envelope = try XCTUnwrap(Element.from(string: xml)) + XCTAssertEqual(envelope.name, "envelope") + XCTAssertEqual(envelope.xmlns, "urn:xmpp:sce:0") + } + // MARK: - Bundle parsing func testBundleParsing() throws { @@ -126,6 +136,55 @@ final class LumaOMEMO2Tests: XCTestCase { XCTAssertNil(OMEMO2Bundle(from: bundle)) } + // MARK: - OMEMODevice protocol label & store wiring + + func testOMEMODeviceProtocolLabel() { + let legacy = OMEMODevice( + jid: "bob@example.org", + deviceID: 5, + fingerprint: "aa", + trust: .trusted, + isActive: true, + isOwn: false, + isOMEMO2: false + ) + let omemo2 = OMEMODevice( + jid: "bob@example.org", + deviceID: 5, + fingerprint: "bb", + trust: .trusted, + isActive: true, + isOwn: false, + isOMEMO2: true + ) + + XCTAssertEqual(legacy.protocolName, "OMEMO") + XCTAssertEqual(omemo2.protocolName, "OMEMO 2") + // Same JID/deviceID across protocols must not collide as list rows. + XCTAssertEqual(legacy.id, "bob@example.org|5|legacy") + XCTAssertEqual(omemo2.id, "bob@example.org|5|omemo2") + XCTAssertNotEqual(legacy.id, omemo2.id) + } + + func testStoreMarksOMEMO2DeviceIDs() throws { + let store = LumaOMEMOStore(accountJID: "alice@example.org") + let context = try XCTUnwrap(SignalContext(withStorage: store)) + _ = context + let ownID = store.localRegistrationID + XCTAssertNotEqual(ownID, 0) + + store.setOMEMO2DeviceIDs([Int32(bitPattern: ownID)], for: "alice@example.org") + let devices = store.devices(for: "alice@example.org") + let own = try XCTUnwrap(devices.first { $0.isOwn }) + XCTAssertTrue(own.isOMEMO2) + + // Clearing the published set drops the badge. + store.setOMEMO2DeviceIDs([], for: "alice@example.org") + let cleared = store.devices(for: "alice@example.org") + let clearedOwn = try XCTUnwrap(cleared.first { $0.isOwn }) + XCTAssertFalse(clearedOwn.isOMEMO2) + } + // MARK: - Double Ratchet round trip (shared with the legacy module) func testRatchetSessionRoundTrip() throws { diff --git a/Tests/SASLMechanismPreferenceTests.swift b/Tests/SASLMechanismPreferenceTests.swift new file mode 100644 index 0000000..1377f26 --- /dev/null +++ b/Tests/SASLMechanismPreferenceTests.swift @@ -0,0 +1,104 @@ +import XCTest +@testable import Luma + +final class SASLMechanismPreferenceTests: XCTestCase { + func testPrefersScramSha512WhenAdvertised() { + XCTAssertEqual( + SASLMechanismPreference.selected(among: [ + "PLAIN", "SCRAM-SHA-1", "SCRAM-SHA-512", "SCRAM-SHA-256", + ]), + "SCRAM-SHA-512" + ) + } + + func testFallsBackToSha256WithoutSha512() { + XCTAssertEqual( + SASLMechanismPreference.selected(among: ["SCRAM-SHA-256", "PLAIN"]), + "SCRAM-SHA-256" + ) + } + + func testFallsBackToSha1AndPlain() { + XCTAssertEqual( + SASLMechanismPreference.selected(among: ["PLAIN", "SCRAM-SHA-1"]), + "SCRAM-SHA-1" + ) + XCTAssertEqual( + SASLMechanismPreference.selected(among: ["PLAIN"]), + "PLAIN" + ) + } + + func testReturnsNilWhenNothingKnownIsOffered() { + XCTAssertNil( + SASLMechanismPreference.selected(among: ["X-OAUTH2", "ANONYMOUS"]) + ) + XCTAssertNil(SASLMechanismPreference.selected(among: [])) + } + + func testStrongestAvailableRanking() { + XCTAssertTrue( + SASLMechanismPreference.isStrongestAvailable( + "SCRAM-SHA-512", + among: ["SCRAM-SHA-512", "SCRAM-SHA-256", "PLAIN"] + ) + ) + XCTAssertFalse( + SASLMechanismPreference.isStrongestAvailable( + "SCRAM-SHA-1", + among: ["SCRAM-SHA-512", "SCRAM-SHA-1"] + ) + ) + XCTAssertFalse( + SASLMechanismPreference.isStrongestAvailable( + "X-OAUTH2", + among: ["X-OAUTH2"] + ) + ) + } + + func testPLAINIsNeverChosenWhileSCRAMIsOffered() { + let offered = ["SCRAM-SHA-1", "PLAIN", "SCRAM-SHA-512"] + XCTAssertEqual(SASLMechanismPreference.selected(among: offered), "SCRAM-SHA-512") + XCTAssertNotEqual(SASLMechanismPreference.selected(among: offered), "PLAIN") + } + + func testPlusVariantsArePreferredOverPlainSCRAM() { + let offered = ["PLAIN", "SCRAM-SHA-1", "SCRAM-SHA-256-PLUS", "SCRAM-SHA-512"] + XCTAssertEqual( + SASLMechanismPreference.selected(among: offered), + "SCRAM-SHA-256-PLUS" + ) + XCTAssertEqual( + SASLMechanismPreference.selected(among: [ + "SCRAM-SHA-512", "SCRAM-SHA-1-PLUS", "SCRAM-SHA-256-PLUS", + ]), + "SCRAM-SHA-256-PLUS" + ) + XCTAssertEqual( + SASLMechanismPreference.selected(among: [ + "SCRAM-SHA-512-PLUS", "SCRAM-SHA-256-PLUS", "SCRAM-SHA-512", + ]), + "SCRAM-SHA-512-PLUS" + ) + } + + func testPlusVariantsSkippedWithoutChannelBinding() { + let offered = ["SCRAM-SHA-512-PLUS", "SCRAM-SHA-512", "PLAIN"] + XCTAssertEqual( + SASLMechanismPreference.selected( + among: offered, + allowsChannelBinding: false + ), + "SCRAM-SHA-512" + ) + // Without any plain SCRAM alternative, PLAIN is the only choice. + XCTAssertEqual( + SASLMechanismPreference.selected( + among: ["SCRAM-SHA-1-PLUS", "PLAIN"], + allowsChannelBinding: false + ), + "PLAIN" + ) + } +} diff --git a/Tests/SCRAMPlusTests.swift b/Tests/SCRAMPlusTests.swift new file mode 100644 index 0000000..1691a86 --- /dev/null +++ b/Tests/SCRAMPlusTests.swift @@ -0,0 +1,106 @@ +import CryptoKit +import XCTest +@testable import Luma + +final class SCRAMPlusTests: XCTestCase { + /// Reference vectors computed with an independent Python + /// implementation of RFC 5802 SCRAM-SHA-1 with channel binding + /// (tls-server-end-point, deterministic 32-byte binding data). + private func referenceExchange() -> SCRAMPlusExchange { + SCRAMPlusExchange( + hash: .sha1, + username: "user", + password: "pencil", + channelBindingType: "tls-server-end-point", + channelBindingData: Data(0..<32), + clientNonce: "fyko+d2lbbFgONRv9qkxdawL" + ) + } + + private static let referenceServerFirst = + "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096" + + func testClientFirstMessageMatchesReference() { + let exchange = referenceExchange() + let first = exchange.clientFirstMessage.data(using: .utf8)!.base64EncodedString() + XCTAssertEqual( + first, + "cD10bHMtc2VydmVyLWVuZC1wb2ludCwsbj11c2VyLHI9ZnlrbytkMmxiYkZnT05Sdjlxa3hkYXdM" + ) + } + + func testClientFinalMessageMatchesReference() throws { + let exchange = referenceExchange() + let final = try exchange.clientFinalMessage(serverFirst: Self.referenceServerFirst) + let encoded = final.data(using: .utf8)!.base64EncodedString() + XCTAssertEqual( + encoded, + "Yz1jRDEwYkhNdGMyVnlkbVZ5TFdWdVpDMXdiMmx1ZEN3c0FBRUNBd1FGQmdjSUNRb0xEQTBPRHhBUkVoTVVGUllYR0JrYUd4d2RIaDg9LHI9ZnlrbytkMmxiYkZnT05Sdjlxa3hkYXdMM3JmY05IWUpZMVpWdldWczdqLHA9RkVLbDNvQU13UFErc0JsYnR6SU1oaGdKSFJjPQ==" + ) + } + + func testExpectedServerSignatureMatchesReference() throws { + let exchange = referenceExchange() + let signature = try exchange.expectedServerSignature(serverFirst: Self.referenceServerFirst) + XCTAssertEqual(signature.base64EncodedString(), "CCSN72nmVmqopGNXz27xIKr6CxA=") + } + + func testVerifierValueExtractsOnlyTheBase64Payload() { + XCTAssertEqual( + SCRAMPlusExchange.verifierValue(in: "v=DQNSo72nmVmqopGNXz27xIKr6CxA="), + "DQNSo72nmVmqopGNXz27xIKr6CxA=" + ) + XCTAssertEqual( + SCRAMPlusExchange.verifierValue(in: "v=abc123"), + "abc123" + ) + XCTAssertEqual( + SCRAMPlusExchange.verifierValue(in: "v=abc123,ext=value"), + "abc123" + ) + XCTAssertNil(SCRAMPlusExchange.verifierValue(in: "e=invalid-proof")) + XCTAssertNil(SCRAMPlusExchange.verifierValue(in: "")) + } + + func testChannelBindingStoreDerivesEndPointDigest() { + let store = LumaChannelBindingStore() + let leaf = Data("certificate".utf8) + store.setTLSState( + LumaTLSState( + version: "TLSv1.3", + cipher: "TLS_AES_256_GCM_SHA384", + leafCertificateDER: leaf, + exporter: Data(0..<32) + ) + ) + store.setAdvertisedChannelBindingTypes(["tls-exporter", "tls-server-end-point"]) + XCTAssertEqual(store.preferredChannelBindingType, "tls-exporter") + XCTAssertEqual( + store.channelBindingData(for: "tls-server-end-point"), + Data(SHA256.hash(data: leaf)) + ) + XCTAssertEqual( + store.channelBindingData(for: "tls-exporter"), + Data(0..<32), + ) + } + + func testChannelBindingStoreFallsBackToEndPoint() { + let store = LumaChannelBindingStore() + store.setTLSState( + LumaTLSState( + version: "TLSv1.3", + cipher: "TLS_AES_256_GCM_SHA384", + leafCertificateDER: Data("leaf".utf8), + exporter: nil, + ) + ) + // Only the certificate digest is available: pick tls-server-end-point. + store.setAdvertisedChannelBindingTypes(["tls-server-end-point"]) + XCTAssertEqual(store.preferredChannelBindingType, "tls-server-end-point") + // Nothing usable when the server does not advertise any known type. + store.setAdvertisedChannelBindingTypes([]) + XCTAssertNil(store.preferredChannelBindingType) + XCTAssertFalse(store.canUseChannelBinding) + } +} diff --git a/UITests/LayoutCheckUITests.swift b/UITests/LayoutCheckUITests.swift index a4d40a0..7bda412 100644 --- a/UITests/LayoutCheckUITests.swift +++ b/UITests/LayoutCheckUITests.swift @@ -23,7 +23,10 @@ final class LayoutCheckUITests: XCTestCase { // `simctl io screenshot` captures of the tab bar. Thread.sleep(forTimeInterval: 12) - let chatSearch = app.textFields["Поиск чатов"] + // The Telegram-style tabs render `.searchable` fields as + // XCUIElementTypeSearchField (a TextField subtype in newer runtimes, + // but queried as `searchFields` on the iOS 26 simulator). + let chatSearch = app.searchFields["Поиск чатов"] XCTAssertTrue(chatSearch.waitForExistence(timeout: 30)) addScreenshot(named: "chats-search-top", app: app) @@ -37,7 +40,7 @@ final class LayoutCheckUITests: XCTestCase { app.buttons["Контакты"].tap() - let contactSearch = app.textFields["Поиск контактов"] + let contactSearch = app.searchFields["Поиск контактов"] XCTAssertTrue(contactSearch.waitForExistence(timeout: 15)) XCTAssertLessThan( contactSearch.frame.minY, diff --git a/project.yml b/project.yml index bf81831..1ba59c9 100644 --- a/project.yml +++ b/project.yml @@ -35,12 +35,15 @@ packages: WebRTC: url: https://github.com/stasel/WebRTC.git exactVersion: 150.0.0 + OpenSSL: + url: https://github.com/krzyzanowskim/OpenSSL.git + from: 3.4.0 targets: Luma: type: application platform: iOS - deploymentTarget: "17.0" + deploymentTarget: "26.0" sources: - path: Sources/App - path: Sources/Shared @@ -52,6 +55,7 @@ targets: - package: Martin - package: MartinOMEMO - package: WebRTC + - package: OpenSSL - target: LumaWatch info: path: Config/Luma-Info.plist @@ -72,6 +76,7 @@ targets: NSLocalNetworkUsageDescription: Luma использует локальную сеть для прямого соединения аудио- и видеозвонков. NSLocationWhenInUseUsageDescription: Luma использует геопозицию только когда вы выбираете отправку точки в чате. NSFaceIDUsageDescription: Luma использует Face ID для разблокировки приложения. + NSPhotoLibraryAddUsageDescription: Luma сохраняет фотографии и видео в вашу медиатеку. entitlements: path: Config/Luma.entitlements properties: {} @@ -90,7 +95,7 @@ targets: LumaMac: type: application platform: macOS - deploymentTarget: "14.0" + deploymentTarget: "26.0" sources: - path: Sources/App - path: Sources/Shared @@ -102,6 +107,7 @@ targets: - package: Martin - package: MartinOMEMO - package: WebRTC + - package: OpenSSL info: path: Config/LumaMac-Info.plist properties: @@ -122,6 +128,7 @@ targets: com.apple.security.network.client: true com.apple.security.network.server: true com.apple.security.files.user-selected.read-only: true + com.apple.security.files.user-selected.read-write: true com.apple.security.device.camera: true com.apple.security.device.audio-input: true com.apple.security.personal-information.location: true @@ -142,7 +149,7 @@ targets: # try to produce Luma.app/Luma. type: application platform: watchOS - deploymentTarget: "10.0" + deploymentTarget: "26.0" sources: - path: Sources/Watch - path: Resources/Shared.xcassets @@ -177,7 +184,7 @@ targets: LumaTests: type: bundle.unit-test platform: iOS - deploymentTarget: "17.0" + deploymentTarget: "26.0" sources: - path: Tests dependencies: @@ -190,7 +197,7 @@ targets: LumaUITests: type: bundle.ui-testing platform: iOS - deploymentTarget: "17.0" + deploymentTarget: "26.0" sources: - path: UITests dependencies: