tls sasl
This commit is contained in:
1 parent
e3fcee3351
commit
d4b4c92759
29 files changed
+2177
-61
No files matched your search
@@ -32,6 +32,8 @@
|
||||
<string>Luma использует геопозицию только когда вы выбираете отправку точки в чате.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Luma использует микрофон для голосовых сообщений и звонков.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Luma сохраняет фотографии и видео в вашу медиатеку.</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
|
||||
+31
-7
@@ -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
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
0386C2687C1C60EC734FDBB0 /* BubbleShapes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BubbleShapes.swift; sourceTree = "<group>"; };
|
||||
03AC6BC7FC7CFFD69A20DA17 /* MessageBubble.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBubble.swift; sourceTree = "<group>"; };
|
||||
050B83F6DA3A04B661FFA3B1 /* LumaScramPlusMechanism.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaScramPlusMechanism.swift; sourceTree = "<group>"; };
|
||||
05EEEE8A125347F7C5014A09 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
||||
096748EF4DC92889BCAB3260 /* MessageCorrectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCorrectionTests.swift; sourceTree = "<group>"; };
|
||||
0B5359E7A89ABF58B78CE036 /* TimelineUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineUITests.swift; sourceTree = "<group>"; };
|
||||
@@ -314,6 +329,7 @@
|
||||
13EFA01112297641B9B0EBC5 /* SASLprepTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SASLprepTests.swift; sourceTree = "<group>"; };
|
||||
184902980B7EE4B8DDEA907D /* AppAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = AppAssets.xcassets; sourceTree = "<group>"; };
|
||||
185E3C916B72D36733DE15AF /* EncryptionDevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionDevicesView.swift; sourceTree = "<group>"; };
|
||||
186BB225C4C5E528B3D7C9CE /* TLSVersionProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSVersionProbe.swift; sourceTree = "<group>"; };
|
||||
19C556A41E640592AF6376B6 /* ReplyThreadOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyThreadOverlay.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
@@ -323,6 +339,7 @@
|
||||
1DCD865B7E746D20B142FCE7 /* WatchVoiceMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchVoiceMessageTests.swift; sourceTree = "<group>"; };
|
||||
1F7B2B37E9E8E6A46ED7547A /* SaslFailureMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaslFailureMessageTests.swift; sourceTree = "<group>"; };
|
||||
2006464EAC88C6E4E2B08AC8 /* AccountConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountConfiguration.swift; sourceTree = "<group>"; };
|
||||
21D03F8C05843DD139C1A4CE /* SCRAMPlusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMPlusTests.swift; sourceTree = "<group>"; };
|
||||
22A045770C3C55BC5ADC409B /* ArchiveSyncWorkBudgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncWorkBudgetTests.swift; sourceTree = "<group>"; };
|
||||
22A1DBD88AF90D3892A02E1A /* ArchiveSyncPagination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncPagination.swift; sourceTree = "<group>"; };
|
||||
235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPreferences.swift; sourceTree = "<group>"; };
|
||||
@@ -339,6 +356,7 @@
|
||||
33F90AC63C822D9DF95D7B5C /* VideoNoteStopPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteStopPolicyTests.swift; sourceTree = "<group>"; };
|
||||
375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSnapshotTests.swift; sourceTree = "<group>"; };
|
||||
37C24C432306C3FDF7F7DB7D /* GeoLocationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoLocationTests.swift; sourceTree = "<group>"; };
|
||||
37D79A5AC35479F474FF3F44 /* SASLMechanismPreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SASLMechanismPreferenceTests.swift; sourceTree = "<group>"; };
|
||||
3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockView.swift; sourceTree = "<group>"; };
|
||||
3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveStore.swift; sourceTree = "<group>"; };
|
||||
3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewProcessor.swift; sourceTree = "<group>"; };
|
||||
@@ -351,6 +369,7 @@
|
||||
5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMSHA512Tests.swift; sourceTree = "<group>"; };
|
||||
515DD6F1F80A848C39EBAE84 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
||||
5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemPhotoCameraView.swift; sourceTree = "<group>"; };
|
||||
569F2BFFD578A23A99D73248 /* LumaTLSNetworkProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaTLSNetworkProcessor.swift; sourceTree = "<group>"; };
|
||||
5886E1E233C129B9317D6059 /* CertificateTrustEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateTrustEvaluator.swift; sourceTree = "<group>"; };
|
||||
5BB168F9AD341C35EADACF10 /* VideoNoteRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecorder.swift; sourceTree = "<group>"; };
|
||||
5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMetadataRecord.swift; sourceTree = "<group>"; };
|
||||
@@ -369,6 +388,7 @@
|
||||
6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPolicy.swift; sourceTree = "<group>"; };
|
||||
6EA8DF39BD360C0F8BA5F62F /* AvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarView.swift; sourceTree = "<group>"; };
|
||||
6EF29003DFA43387FC2FB6F4 /* AppLockVault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockVault.swift; sourceTree = "<group>"; };
|
||||
6F8D047FE7E9BA4DAA3DAF80 /* SASLMechanismPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SASLMechanismPreference.swift; sourceTree = "<group>"; };
|
||||
72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaCallEngine.swift; sourceTree = "<group>"; };
|
||||
758B448979C845F56168A51F /* ConversationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationRow.swift; sourceTree = "<group>"; };
|
||||
76FEB73D4B67CB98C93F6244 /* MediaTimeFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaTimeFormatterTests.swift; sourceTree = "<group>"; };
|
||||
@@ -403,6 +423,7 @@
|
||||
A862822F2507303EBB27EFCC /* ArchiveSyncPaginationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncPaginationTests.swift; sourceTree = "<group>"; };
|
||||
A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAttachmentPreview.swift; sourceTree = "<group>"; };
|
||||
A8C051C2A7014E8B5E825477 /* LumaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaApp.swift; sourceTree = "<group>"; };
|
||||
AB613D3331D85529B5F989D0 /* MediaDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDownloadService.swift; sourceTree = "<group>"; };
|
||||
ABB0729C69630AE38C2D7BBF /* ArchiveStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveStoreTests.swift; sourceTree = "<group>"; };
|
||||
B1E6875B522254FFB5FA880E /* MediaPickerSelectionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPickerSelectionPolicy.swift; sourceTree = "<group>"; };
|
||||
B2446F6A1D00ADAD261D4CCD /* SASLprep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SASLprep.swift; sourceTree = "<group>"; };
|
||||
@@ -435,12 +456,12 @@
|
||||
D8C81B9255E9886FB86E0785 /* InlineVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineVideoPlayer.swift; sourceTree = "<group>"; };
|
||||
D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncCheckpoint.swift; sourceTree = "<group>"; };
|
||||
D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycleTests.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInformation.swift; sourceTree = "<group>"; };
|
||||
E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRoomStore.swift; sourceTree = "<group>"; };
|
||||
EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyArchiveImporter.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionBanner.swift; sourceTree = "<group>"; };
|
||||
F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalog.swift; sourceTree = "<group>"; };
|
||||
@@ -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" */;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: " ")
|
||||
|
||||
@@ -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[..<chosenRank] where offered.contains(stronger) {
|
||||
return false
|
||||
}
|
||||
return offered.contains(mechanism)
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,14 @@ struct ServerInformation: Equatable, Sendable {
|
||||
let externalServices: [ExternalService]
|
||||
let connectionStats: ConnectionStats
|
||||
let saslMethods: [String]
|
||||
/// TLS version negotiated with the server during this connection
|
||||
/// (probed from a parallel handshake when the library does not expose it).
|
||||
let tlsVersion: String?
|
||||
/// Cipher suite negotiated alongside the TLS version.
|
||||
let tlsCipher: String?
|
||||
/// SASL mechanism actually selected for authentication (the strongest
|
||||
/// one the server advertises).
|
||||
let saslMechanism: String?
|
||||
let supportsStreamManagement: Bool
|
||||
let supportsCarbons: Bool
|
||||
let supportsClientState: Bool
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
#if os(iOS)
|
||||
import Photos
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// Saves a message's cached media to the right destination: photos, videos and
|
||||
/// video notes go to the Photos library on iOS (or Downloads on macOS, which
|
||||
/// would otherwise need a Photo Library entitlement), while files, audio and
|
||||
/// voice messages go to the user's Downloads location. Kept dependency-free so
|
||||
/// every bubble, the full-screen viewer and the audio player share one path.
|
||||
@MainActor
|
||||
enum MediaDownloadService {
|
||||
enum SaveError: LocalizedError {
|
||||
case missingLocalFile
|
||||
case unsupportedKind
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingLocalFile:
|
||||
return "Не удалось загрузить файл для сохранения."
|
||||
case .unsupportedKind:
|
||||
return "Этот тип сообщения нельзя сохранить."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Entry points
|
||||
|
||||
/// Resolve the cached copy for `message` (downloading through AppModel when
|
||||
/// needed) and save it. Failures are surfaced through AppModel's error alert.
|
||||
static func save(_ message: ChatMessage, model: AppModel) async {
|
||||
do {
|
||||
guard let sourceURL = await resolvedLocalURL(for: message, model: model) else {
|
||||
throw SaveError.missingLocalFile
|
||||
}
|
||||
let filename = message.localFilename ?? Self.defaultFilename(for: message)
|
||||
let didSave = try await save(
|
||||
url: sourceURL,
|
||||
kind: message.kind,
|
||||
filename: filename
|
||||
)
|
||||
if didSave {
|
||||
model.informationalMessage = "Сохранено."
|
||||
}
|
||||
} catch {
|
||||
model.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// Save an already-downloaded file, routing by kind and platform. Returns
|
||||
/// `false` when the user cancelled an interactive save sheet.
|
||||
@discardableResult
|
||||
static func save(
|
||||
url: URL,
|
||||
kind: ChatMessage.Kind,
|
||||
filename: String
|
||||
) async throws -> 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<Bool, Never>?
|
||||
|
||||
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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Bool> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,6 +4,7 @@ import SwiftUI
|
||||
|
||||
struct RootView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@State private var toastDismissTask: Task<Void, Never>?
|
||||
|
||||
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<Bool> {
|
||||
|
||||
@@ -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: "Неизвестно")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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..<rpadLength).map { _ in alphabet.randomElement()! })
|
||||
var result = "<envelope xmlns='urn:xmpp:sce:1'><content>" + content + "</content>"
|
||||
var result = "<envelope xmlns='" + Self.SCE_XMLNS + "'><content>" + content + "</content>"
|
||||
result += "<rpad>" + rpad + "</rpad>"
|
||||
result += "<from jid='" + escapeXML(fromJID) + "'/>"
|
||||
if let toJID {
|
||||
|
||||
@@ -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<Int32> = 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<Int32>, 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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ResetableScope>) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> = []
|
||||
|
||||
func setTLSState(_ state: LumaTLSState) {
|
||||
lock.lock()
|
||||
tlsState = state
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func setAdvertisedChannelBindingTypes(_ types: Set<String>) {
|
||||
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 <openssl/tls1.h> (TLS1_2_VERSION 0x0303,
|
||||
// TLS1_3_VERSION 0x0304) and <openssl/ssl.h>
|
||||
// (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..<Int(count)]),
|
||||
completion: .none
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func emitDecrypted() {
|
||||
guard let ssl else { return }
|
||||
var buffer = [UInt8](repeating: 0, count: 65_536)
|
||||
while true {
|
||||
let count = SSL_read(ssl, &buffer, Int32(buffer.count))
|
||||
if count > 0 {
|
||||
super.read(data: Data(buffer[0..<Int(count)]))
|
||||
continue
|
||||
}
|
||||
switch SSL_get_error(ssl, count) {
|
||||
case SSL_ERROR_WANT_READ, SSL_ERROR_WANT_WRITE:
|
||||
return
|
||||
case SSL_ERROR_ZERO_RETURN:
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handshake completion
|
||||
|
||||
private func completeHandshake() {
|
||||
guard let ssl else { return }
|
||||
let version = String(cString: SSL_get_version(ssl))
|
||||
// SSL_get_cipher_name is a C macro over
|
||||
// SSL_CIPHER_get_name(SSL_get_current_cipher(_:)).
|
||||
let cipher: String
|
||||
if let current = SSL_get_current_cipher(ssl) {
|
||||
cipher = String(cString: SSL_CIPHER_get_name(current))
|
||||
} else {
|
||||
cipher = ""
|
||||
}
|
||||
|
||||
var leafDER: Data?
|
||||
var chainCertificates: [SecCertificate] = []
|
||||
if let chain = SSL_get_peer_cert_chain(ssl) {
|
||||
// sk_X509_num / sk_X509_value are C macros; the imported Swift
|
||||
// surface exposes OPENSSL_sk_num / OPENSSL_sk_value instead.
|
||||
let count = OPENSSL_sk_num(chain)
|
||||
for index in 0..<count {
|
||||
guard let raw = OPENSSL_sk_value(chain, index) else { continue }
|
||||
let certificate = OpaquePointer(raw)
|
||||
guard let der = x509DER(certificate) else { continue }
|
||||
if leafDER == nil { leafDER = der }
|
||||
if let secCertificate = SecCertificateCreateWithData(nil, der as CFData) {
|
||||
chainCertificates.append(secCertificate)
|
||||
}
|
||||
}
|
||||
}
|
||||
if leafDER == nil, let certificate = SSL_get1_peer_certificate(ssl) {
|
||||
if let der = x509DER(certificate) {
|
||||
leafDER = der
|
||||
if let secCertificate = SecCertificateCreateWithData(nil, der as CFData) {
|
||||
chainCertificates.insert(secCertificate, at: 0)
|
||||
}
|
||||
}
|
||||
X509_free(certificate)
|
||||
}
|
||||
|
||||
guard let leafDER else {
|
||||
handshakeFailed = true
|
||||
certificateValidationFailed?(nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Certificate trust evaluation via SecTrust, honouring the
|
||||
// connector's configured validation (Luma uses the custom validator).
|
||||
var trust: SecTrust?
|
||||
if !chainCertificates.isEmpty {
|
||||
SecTrustCreateWithCertificates(chainCertificates as CFArray, nil, &trust)
|
||||
}
|
||||
var validated = false
|
||||
if let trust {
|
||||
let domain = serverName ?? ""
|
||||
switch certificateValidation {
|
||||
case .default:
|
||||
SecTrustSetPolicies(trust, SecPolicyCreateSSL(true, domain as CFString))
|
||||
var result = SecTrustResultType.invalid
|
||||
SecTrustEvaluate(trust, &result)
|
||||
validated = result == .proceed || result == .unspecified
|
||||
case .fingerprint(let fingerprint):
|
||||
validated = Self.matchesFingerprint(leafDER, fingerprint: fingerprint)
|
||||
case .customValidator(let validator):
|
||||
validated = validator(trust)
|
||||
}
|
||||
}
|
||||
guard validated else {
|
||||
handshakeFailed = true
|
||||
certificateValidationFailed?(trust)
|
||||
return
|
||||
}
|
||||
|
||||
// RFC 9266 tls-exporter channel binding (TLS 1.3).
|
||||
var exporter: Data?
|
||||
var exporterBuffer = [UInt8](repeating: 0, count: 32)
|
||||
let exporterLabel = "EXPORTER-Channel-Binding"
|
||||
let exporterResult = exporterLabel.withCString { label in
|
||||
SSL_export_keying_material(
|
||||
ssl,
|
||||
&exporterBuffer,
|
||||
exporterBuffer.count,
|
||||
label,
|
||||
exporterLabel.utf8.count,
|
||||
nil,
|
||||
0,
|
||||
0
|
||||
)
|
||||
}
|
||||
if exporterResult == 1 {
|
||||
exporter = Data(exporterBuffer)
|
||||
}
|
||||
|
||||
channelBindingStore.setTLSState(
|
||||
LumaTLSState(
|
||||
version: version,
|
||||
cipher: cipher,
|
||||
leafCertificateDER: leafDER,
|
||||
exporter: exporter
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func x509DER(_ certificate: OpaquePointer) -> 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
|
||||
}
|
||||
@@ -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 + <starttls/>) 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<TLSVersionProbe> = []
|
||||
|
||||
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<CFReadStream>?
|
||||
var writeStream: Unmanaged<CFWriteStream>?
|
||||
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 =
|
||||
"<?xml version='1.0'?>"
|
||||
+ "<stream:stream to='\(expectedDomain)' xmlns='jabber:client' "
|
||||
+ "xmlns:stream='http://etherx.jabber.org/streams' version='1.0'>"
|
||||
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 = "<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>"
|
||||
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..<count])
|
||||
}
|
||||
guard phase == .expectingFeatures else { return }
|
||||
let text = String(decoding: receivedData, as: UTF8.self)
|
||||
if text.contains("<proceed") {
|
||||
configureTLS()
|
||||
} else if text.contains("starttls") {
|
||||
sawStartTLSOffer = true
|
||||
if !sentStartTLS { writeStartTLS() }
|
||||
}
|
||||
}
|
||||
|
||||
private func configureTLS() {
|
||||
guard let inputStream, let outputStream else {
|
||||
finish(nil)
|
||||
return
|
||||
}
|
||||
let settings: [String: Any] = [
|
||||
kCFStreamSSLValidatesCertificateChain as String: false,
|
||||
kCFStreamSSLPeerName as String: expectedDomain,
|
||||
kCFStreamSSLLevel as String: StreamSocketSecurityLevel.negotiatedSSL,
|
||||
]
|
||||
inputStream.setProperty(
|
||||
settings,
|
||||
forKey: Stream.PropertyKey(kCFStreamPropertySSLSettings as String)
|
||||
)
|
||||
outputStream.setProperty(
|
||||
settings,
|
||||
forKey: Stream.PropertyKey(kCFStreamPropertySSLSettings as String)
|
||||
)
|
||||
if let rawContext = outputStream.property(forKey: Stream.PropertyKey(
|
||||
rawValue: kCFStreamPropertySSLContext as String
|
||||
)) {
|
||||
let context = rawContext as! SSLContext
|
||||
SSLSetALPNProtocols(context, ["xmpp-client"] as CFArray)
|
||||
sslContext = context
|
||||
}
|
||||
phase = .tlsNegotiating
|
||||
inputStream.close()
|
||||
outputStream.close()
|
||||
inputStream.open()
|
||||
outputStream.open()
|
||||
}
|
||||
|
||||
private func reportTLSResult() {
|
||||
guard phase != .finished else { return }
|
||||
phase = .finished
|
||||
var protocolVersion = SSLProtocol(rawValue: 0)!
|
||||
var cipher: SSLCipherSuite = 0
|
||||
if let sslContext {
|
||||
SSLGetNegotiatedProtocolVersion(sslContext, &protocolVersion)
|
||||
if SSLGetNegotiatedCipher(sslContext, &cipher) != errSecSuccess {
|
||||
cipher = 0
|
||||
}
|
||||
}
|
||||
guard let versionDescription = Self.protocolDescription(protocolVersion) else {
|
||||
finish(nil)
|
||||
return
|
||||
}
|
||||
finish(TLSProbeResult(
|
||||
version: versionDescription,
|
||||
cipher: Self.cipherDescription(cipher)
|
||||
))
|
||||
}
|
||||
|
||||
private func finish(_ result: TLSProbeResult?) {
|
||||
guard !finished else { return }
|
||||
finished = true
|
||||
phase = .finished
|
||||
inputStream?.close()
|
||||
outputStream?.close()
|
||||
inputStream = nil
|
||||
outputStream = nil
|
||||
completion(result)
|
||||
if let runLoop {
|
||||
runLoop.perform { CFRunLoopStop(CFRunLoopGetCurrent()) }
|
||||
}
|
||||
Self.activeLock.lock()
|
||||
Self.activeProbes.remove(self)
|
||||
Self.activeLock.unlock()
|
||||
}
|
||||
|
||||
// MARK: - Descriptions
|
||||
|
||||
/// The protocol constants are marked deprecated on modern SDKs, so match
|
||||
/// the raw SecureTransport values instead: TLS 1.3 = 10, TLS 1.2 = 8,
|
||||
/// TLS 1.1 = 7, TLS 1.0 = 4, SSL 3.0 = 2.
|
||||
static func protocolDescription(_ protocolVersion: SSLProtocol) -> 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<TLSProbeResult?, Never>?
|
||||
/// 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<AnyCancellable> = []
|
||||
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 <features>, 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 <body> fallback alongside the
|
||||
// OMEMO <encrypted> 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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+12
-5
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user