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: