diff --git a/Luma.xcodeproj/project.pbxproj b/Luma.xcodeproj/project.pbxproj index 56c8258..b5259a8 100644 --- a/Luma.xcodeproj/project.pbxproj +++ b/Luma.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 03C65C3DAA1D8776AF496EFA /* LumaRoomStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */; }; 03F2CD213153D0EF5CBC8021 /* EncryptionPreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD286F7AF06DFC5488FF0CEA /* EncryptionPreferenceTests.swift */; }; 0535722A2D4FC893F16D78F1 /* MessageReplySwipeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D3F72BE63D8F360ECA0419 /* MessageReplySwipeTests.swift */; }; + 059A5169D66AD8F04B341403 /* LumaSaslChallengeModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F90741D722FD1A972B4C78 /* LumaSaslChallengeModule.swift */; }; 067D121E9C8D8EA076D974FB /* RTCVideoRendererView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */; }; 087E600676B3F6134BB26112 /* CallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B11FED2D58621C071C3AD14 /* CallView.swift */; }; 088945A14C15828F5265AA29 /* ArchiveSyncRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28187D29B1BB6E3ECB02F637 /* ArchiveSyncRecoveryPolicy.swift */; }; @@ -142,6 +143,7 @@ 7AC93485DA12BC90717739E9 /* ChatMediaImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A438A50BC08940CA1410B4FA /* ChatMediaImageCache.swift */; }; 7BC399937E4586EC5067282C /* GeoLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD13FA20B6C2CD8B6718BF86 /* GeoLocation.swift */; }; 7C89D0095051D751FA245324 /* ArchiveMetadataRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */; }; + 7D095DA2AAD5E7D813586E7D /* LumaSaslChallengeModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F90741D722FD1A972B4C78 /* LumaSaslChallengeModule.swift */; }; 7D93BF350FBBF6F9EB29BA23 /* MediaPickerSelectionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1E6875B522254FFB5FA880E /* MediaPickerSelectionPolicy.swift */; }; 7F012252FA73A8282B92323C /* LumaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C051C2A7014E8B5E825477 /* LumaApp.swift */; }; 7F211C3BBA81E5D43D3A88AE /* LumaCallEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */; }; @@ -232,6 +234,7 @@ DA77E8A49D2D091EC0975DD3 /* InlineVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C81B9255E9886FB86E0785 /* InlineVideoPlayer.swift */; }; DBB0216BFAB04C3847FEE95C /* GroupInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAA74044D62409FAC6062BC1 /* GroupInfoView.swift */; }; DC4FAE63AB8AF89C30512763 /* MediaFileIOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 772A4F24CAF527E328657749 /* MediaFileIOTests.swift */; }; + DD7E0B9153FCC21BAF6EE655 /* SCRAMDowngradeProtectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5C75CB772638C15C54F101 /* SCRAMDowngradeProtectionTests.swift */; }; E2245416CB055ECEF0C5F81D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A338A8EB30206647BDD922F /* SettingsView.swift */; }; E42A674072CE5D3AF1F5848D /* Conversation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7846C2EEE2551C6690A4FDC6 /* Conversation.swift */; }; E449F2B17E6D5053EBA7F6A8 /* Shared.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7C9866558B72CB1909A01EEC /* Shared.xcassets */; }; @@ -365,6 +368,7 @@ 4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewer.swift; sourceTree = ""; }; 43A4CE3E678096BA76F2988C /* NewChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = ""; }; 47680B7C615374A8C245818F /* AppLockPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockPolicy.swift; sourceTree = ""; }; + 4F5C75CB772638C15C54F101 /* SCRAMDowngradeProtectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMDowngradeProtectionTests.swift; sourceTree = ""; }; 5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCVideoRendererView.swift; sourceTree = ""; }; 5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMSHA512Tests.swift; sourceTree = ""; }; 515DD6F1F80A848C39EBAE84 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; @@ -465,6 +469,7 @@ F0A35C9A7B52458996513382 /* WatchVoiceRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchVoiceRecorder.swift; sourceTree = ""; }; F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionBanner.swift; sourceTree = ""; }; F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalog.swift; sourceTree = ""; }; + F4F90741D722FD1A972B4C78 /* LumaSaslChallengeModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaSaslChallengeModule.swift; sourceTree = ""; }; F69AB3930D5E93CB77EA9C7D /* CryptoKitAESGCMEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoKitAESGCMEngineTests.swift; sourceTree = ""; }; F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPolicyTests.swift; sourceTree = ""; }; F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticDNSSrvResolver.swift; sourceTree = ""; }; @@ -509,6 +514,7 @@ 24A68154C2D2B3E9AB0C145F /* LumaOMEMOStore.swift */, E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */, F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */, + F4F90741D722FD1A972B4C78 /* LumaSaslChallengeModule.swift */, C3E21A7D359AF7A7AF97D8DE /* LumaSaslFailureModule.swift */, 050B83F6DA3A04B661FFA3B1 /* LumaScramPlusMechanism.swift */, 7C16CFC03E7F478838A704FD /* LumaScramSha512Mechanism.swift */, @@ -655,6 +661,7 @@ 1F7B2B37E9E8E6A46ED7547A /* SaslFailureMessageTests.swift */, 37D79A5AC35479F474FF3F44 /* SASLMechanismPreferenceTests.swift */, 13EFA01112297641B9B0EBC5 /* SASLprepTests.swift */, + 4F5C75CB772638C15C54F101 /* SCRAMDowngradeProtectionTests.swift */, 21D03F8C05843DD139C1A4CE /* SCRAMPlusTests.swift */, 5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */, 9C8B12EFBC83F9235B6AF992 /* VideoNoteRecordingCompletionPolicyTests.swift */, @@ -1040,6 +1047,7 @@ FB1052104B90994CF03FE6A2 /* LumaOMEMOStore.swift in Sources */, CBED8565C1313DC03D21A105 /* LumaRoomStore.swift in Sources */, F1713D1CD4E019540BFA78FE /* LumaRosterStore.swift in Sources */, + 059A5169D66AD8F04B341403 /* LumaSaslChallengeModule.swift in Sources */, 0BD15E9B1C488AA2B7B56AF7 /* LumaSaslFailureModule.swift in Sources */, ED1C54A2FB844226AA284CCC /* LumaScramPlusMechanism.swift in Sources */, E6B279889E62AB9AD0C5BCE1 /* LumaScramSha512Mechanism.swift in Sources */, @@ -1159,6 +1167,7 @@ 1B20F2D3E4F18E6094B103BF /* LumaOMEMOStore.swift in Sources */, 03C65C3DAA1D8776AF496EFA /* LumaRoomStore.swift in Sources */, 7F4C00E98574C8E5193F442E /* LumaRosterStore.swift in Sources */, + 7D095DA2AAD5E7D813586E7D /* LumaSaslChallengeModule.swift in Sources */, C1AB954660804F7925607038 /* LumaSaslFailureModule.swift in Sources */, FCC5133038CDBEC39B5B42D3 /* LumaScramPlusMechanism.swift in Sources */, 127C4B7A5083081D22FD21CD /* LumaScramSha512Mechanism.swift in Sources */, @@ -1245,6 +1254,7 @@ 9E98F99F3FBFC9DAB99A3650 /* NotificationPolicyTests.swift in Sources */, 5A5C811D7D19AE7C694273FC /* SASLMechanismPreferenceTests.swift in Sources */, 1B7C9F60AA7BFF6A2C0FE45D /* SASLprepTests.swift in Sources */, + DD7E0B9153FCC21BAF6EE655 /* SCRAMDowngradeProtectionTests.swift in Sources */, 662B8E72F00F5A77DE28819A /* SCRAMPlusTests.swift in Sources */, 3BF0521699C170F8411386CD /* SCRAMSHA512Tests.swift in Sources */, 3DDB1B15BCE5B58D12B60558 /* SaslFailureMessageTests.swift in Sources */, diff --git a/Scripts/verify.sh b/Scripts/verify.sh index 181f139..1ac3142 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -366,7 +366,7 @@ grep -q 'SCRAM-SHA-512' Sources/Shared/XMPP/LumaScramSha512Mechanism.swift || { echo "The SCRAM-SHA-512 mechanism must be available" exit 1 } -grep -q 'addMechanism(LumaScramSha512Mechanism' Sources/Shared/XMPP/XMPPService.swift || { +grep -q 'LumaScramSha512Mechanism(channelBindingStore: channelBindingStore)' Sources/Shared/XMPP/XMPPService.swift || { echo "SCRAM-SHA-512 must be registered ahead of Martin's mechanisms" exit 1 } diff --git a/Sources/Shared/Models/SASLMechanismPreference.swift b/Sources/Shared/Models/SASLMechanismPreference.swift index e243354..67e0125 100644 --- a/Sources/Shared/Models/SASLMechanismPreference.swift +++ b/Sources/Shared/Models/SASLMechanismPreference.swift @@ -33,6 +33,10 @@ enum SASLMechanismPreference { } } + /// Mechanisms that send credentials in a way a TLS MITM can replay or + /// read directly; shown with a warning on the server-information screen. + static let weakMechanisms: Set = ["PLAIN", "OAUTHBEARER"] + /// 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 { diff --git a/Sources/Shared/Models/ServerInformation.swift b/Sources/Shared/Models/ServerInformation.swift index cf81432..5ad8147 100644 --- a/Sources/Shared/Models/ServerInformation.swift +++ b/Sources/Shared/Models/ServerInformation.swift @@ -72,6 +72,20 @@ struct ServerInformation: Equatable, Sendable { let externalServices: [ExternalService] let connectionStats: ConnectionStats let saslMethods: [String] + /// SASL2 (RFC 9050) mechanisms advertised in ``. + let sasl2Methods: [String] + /// XEP-0440 channel-binding types advertised by the server, used to + /// detect MITM attacks on the TLS layer during SCRAM-PLUS. + let channelBindingTypes: [String] + /// The channel-binding type actually used for this connection's SASL + /// exchange, when a -PLUS mechanism was selected. + let usedChannelBindingType: String? + /// True when the server included its XEP-0474 downgrade-protection + /// hash in the SCRAM exchange (`h` attribute of the server-first message). + let supportsSCRAMDowngradeProtection: Bool + /// Raw name of the SASL mechanism used for this connection's auth + /// (without the channel-binding suffix), highlighted in the method lists. + let usedSASLMechanism: 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? @@ -156,8 +170,10 @@ struct ServerInformation: Equatable, Sendable { ), Capability( title: "XEP-0474 SASL SCRAM Downgrade Protection", - detail: "Защита SASL/SASL2-рукопожатия от понижения метода и channel-binding. Определяется во время SCRAM-аутентификации, а не рекламируется статически.", - status: .normal + detail: supportsSCRAMDowngradeProtection + ? "Сервер включил downgrade-protection hash в SCRAM-обмен (атрибут h): списки SASL-механизмов и channel-binding защищены от подмены MITM." + : "Сервер не включил downgrade-protection hash в SCRAM-обмен (или вход выполнен через PLAIN): списки механизмов и channel-binding не защищены от подмены.", + status: supportsSCRAMDowngradeProtection ? .success : .error ), ] } diff --git a/Sources/Shared/UI/ServerInfoView.swift b/Sources/Shared/UI/ServerInfoView.swift index af48595..3c486f3 100644 --- a/Sources/Shared/UI/ServerInfoView.swift +++ b/Sources/Shared/UI/ServerInfoView.swift @@ -82,6 +82,18 @@ struct ServerInfoView: View { Section("Методы аутентификации SASL, которые поддерживает ваш сервер.") { saslEntries(info) } + Section("Методы аутентификации SASL2 (RFC 9050), которые поддерживает ваш сервер.") { + sasl2Entries(info) + } + Section { + channelBindingEntries(info) + } header: { + Text("Привязка канала (channel binding), которую поддерживает ваш сервер.") + } footer: { + Text( + "Channel binding связывает SASL-обмен с TLS-каналом: MITM-ретрансляция не сможет подменить соединение. Зелёным отмечен тип, использованный в текущем соединении, оранжевым — типы, которые Luma не поддерживает." + ) + } } else if isLoading { Section { HStack(spacing: 12) { @@ -233,13 +245,101 @@ struct ServerInfoView: View { ForEach(info.saslMethods, id: \.self) { method in entry( title: "Метод: \(method)", - detail: saslDescription(method), - status: method == "PLAIN" ? .warning : .normal + detail: method == info.usedSASLMechanism + ? "Используется в текущем соединении. " + saslDescription(method) + : saslDescription(method), + status: method == info.usedSASLMechanism + ? .success + : (SASLMechanismPreference.weakMechanisms.contains(method) + ? .warning + : .normal) ) } } } + @ViewBuilder + private func sasl2Entries(_ info: ServerInformation) -> some View { + if info.sasl2Methods.isEmpty { + entry( + title: "Нет", + detail: "Сервер не объявил методы аутентификации SASL2 (RFC 9050).", + status: .normal + ) + } else { + ForEach(info.sasl2Methods, id: \.self) { method in + entry( + title: "Метод: \(method)", + detail: method == info.usedSASLMechanism + ? "Используется в текущем соединении. " + sasl2Description(method) + : sasl2Description(method), + status: method == info.usedSASLMechanism + ? .success + : (SASLMechanismPreference.weakMechanisms.contains(method) + ? .warning + : .normal) + ) + } + } + } + + private func sasl2Description(_ method: String) -> String { + switch method { + case "PLAIN": + return "Отправляет пароль открытым текстом (шифруется только TLS), не очень безопасно." + case "OAUTHBEARER": + return "OAuth 2.0 bearer-токен: пароль не передаётся, но сам токен — bearer-секрет, не очень безопасно." + case "EXTERNAL": + return "Использует TLS-клиентские сертификаты для аутентификации." + case let method where method.hasPrefix("SCRAM-") && method.hasSuffix("-PLUS"): + return "Salted Challenge Response Authentication Mechanism с channel-binding." + case let method where method.hasPrefix("SCRAM-"): + return "Salted Challenge Response Authentication Mechanism на указанном хэше." + default: + return "Неизвестный метод аутентификации." + } + } + + /// Channel-binding types Luma can actually produce binding data for. + private static let supportedChannelBindingTypes: Set = [ + "tls-exporter", "tls-server-end-point", + ] + + @ViewBuilder + private func channelBindingEntries(_ info: ServerInformation) -> some View { + if info.channelBindingTypes.isEmpty { + entry( + title: "Нет", + detail: "Сервер не рекламирует channel-binding типы (XEP-0440): SASL-обмен не детектирует MITM-атаку на TLS-слой.", + status: .warning + ) + } else { + ForEach(info.channelBindingTypes, id: \.self) { type in + let used = info.usedChannelBindingType == type + let supported = Self.supportedChannelBindingTypes.contains(type) + entry( + title: "Тип: \(type)", + detail: channelBindingDescription(type, used: used, supported: supported), + status: used ? .success : (supported ? .normal : .warning) + ) + } + } + } + + private func channelBindingDescription( + _ type: String, + used: Bool, + supported: Bool + ) -> String { + if used { + return "Используется в текущем соединении." + } + if supported { + return "Luma поддерживает этот тип привязки." + } + return "Luma не поддерживает этот тип привязки." + } + private func saslDescription(_ method: String) -> String { switch method { case "PLAIN": diff --git a/Sources/Shared/XMPP/LumaSaslChallengeModule.swift b/Sources/Shared/XMPP/LumaSaslChallengeModule.swift new file mode 100644 index 0000000..772ef25 --- /dev/null +++ b/Sources/Shared/XMPP/LumaSaslChallengeModule.swift @@ -0,0 +1,26 @@ +import Foundation +import Martin + +/// Observes the raw SASL `` stanzas before `SaslModule` consumes +/// them, so Luma can detect the XEP-0474 downgrade-protection hash (`h=` +/// attribute in the SCRAM server-first message) regardless of which SCRAM +/// mechanism implementation actually runs the exchange. Registered before +/// `SaslModule` in the module list. +final class LumaSaslChallengeModule: XmppModuleBase, XmppModule { + static let ID = "lumaSaslChallenge" + static let saslXMLNS = "urn:ietf:params:xml:ns:xmpp-sasl" + + let criteria = Criteria.name("challenge", xmlns: LumaSaslChallengeModule.saslXMLNS) + let features: [String] = [] + + private let onChallenge: (String) -> Void + + init(onChallenge: @escaping (String) -> Void) { + self.onChallenge = onChallenge + super.init() + } + + func process(stanza: Stanza) throws { + onChallenge(stanza.element.value ?? "") + } +} diff --git a/Sources/Shared/XMPP/LumaScramPlusMechanism.swift b/Sources/Shared/XMPP/LumaScramPlusMechanism.swift index 6db3ee2..a1a3b05 100644 --- a/Sources/Shared/XMPP/LumaScramPlusMechanism.swift +++ b/Sources/Shared/XMPP/LumaScramPlusMechanism.swift @@ -93,6 +93,40 @@ enum SCRAMHash { } } +/// XEP-0474 SASL SCRAM Downgrade Protection: the hash both sides include in +/// the SCRAM exchange (server `h` / client `x` attributes). Per §5.1 the +/// input is the server-advertised SASL mechanisms SORTED with i;octet +/// collation and joined with 0x1E; when the server also advertised +/// channel-binding types, a 0x1F delimiter followed by the sorted +/// channel-binding types joined with 0x1E. The digest is the SCRAM hash of +/// the negotiated mechanism. A mismatch means a MITM tampered with the +/// advertised lists. +enum SCRAMDowngradeProtection { + static func hash( + mechanisms: [String], + channelBindingTypes: [String], + using hash: SCRAMHash + ) -> Data { + // "i;octet" collation = byte-wise comparison; for these pure-ASCII + // identifiers plain lexicographic sorting matches it. + let sortedMechanisms = mechanisms.sorted() + let sortedBindings = channelBindingTypes.sorted() + var input = Data() + for (index, mechanism) in sortedMechanisms.enumerated() { + if index > 0 { input.append(0x1E) } + input.append(contentsOf: mechanism.utf8) + } + if !sortedBindings.isEmpty { + input.append(0x1F) + for (index, type) in sortedBindings.enumerated() { + if index > 0 { input.append(0x1E) } + input.append(contentsOf: type.utf8) + } + } + return hash.hash(data: input) + } +} + /// 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 @@ -116,7 +150,10 @@ struct SCRAMPlusExchange { (gs2Header.data(using: .utf8) ?? Data()) + channelBindingData } - func clientFinalMessage(serverFirst: String) throws -> String { + func clientFinalMessage( + serverFirst: String, + downgradeProtectionHashBase64: String? = nil + ) throws -> String { let parsed = try SCRAMSHA512.parseServerFirst( serverFirst, expectedNoncePrefix: clientNonce @@ -126,9 +163,15 @@ struct SCRAMPlusExchange { salt: parsed.salt, iterations: parsed.iterations ) - let finalWithoutProof = - "c=\(channelBindingInput.base64EncodedString()),r=\(parsed.nonce)" - let authMessage = authMessageString(serverFirst: serverFirst, parsed: parsed) + let finalWithoutProof = finalWithoutProofString( + parsed: parsed, + downgradeProtectionHashBase64: downgradeProtectionHashBase64 + ) + let authMessage = authMessageString( + serverFirst: serverFirst, + parsed: parsed, + downgradeProtectionHashBase64: downgradeProtectionHashBase64 + ) 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)) @@ -136,7 +179,10 @@ struct SCRAMPlusExchange { return finalWithoutProof + ",p=" + proof.base64EncodedString() } - func expectedServerSignature(serverFirst: String) throws -> Data { + func expectedServerSignature( + serverFirst: String, + downgradeProtectionHashBase64: String? = nil + ) throws -> Data { let parsed = try SCRAMSHA512.parseServerFirst( serverFirst, expectedNoncePrefix: clientNonce @@ -146,17 +192,36 @@ struct SCRAMPlusExchange { salt: parsed.salt, iterations: parsed.iterations ) - let authMessage = authMessageString(serverFirst: serverFirst, parsed: parsed) + let authMessage = authMessageString( + serverFirst: serverFirst, + parsed: parsed, + downgradeProtectionHashBase64: downgradeProtectionHashBase64 + ) let serverKey = hash.hmac(key: saltedPassword, data: Data("Server Key".utf8)) return hash.hmac(key: serverKey, data: Data(authMessage.utf8)) } + private func finalWithoutProofString( + parsed: SCRAMSHA512.ServerFirst, + downgradeProtectionHashBase64: String? + ) -> String { + var result = + "c=\(channelBindingInput.base64EncodedString()),r=\(parsed.nonce)" + if let downgradeProtectionHashBase64 { + result += ",x=\(downgradeProtectionHashBase64)" + } + return result + } + private func authMessageString( serverFirst: String, - parsed: SCRAMSHA512.ServerFirst + parsed: SCRAMSHA512.ServerFirst, + downgradeProtectionHashBase64: String? ) -> String { - let finalWithoutProof = - "c=\(channelBindingInput.base64EncodedString()),r=\(parsed.nonce)" + let finalWithoutProof = finalWithoutProofString( + parsed: parsed, + downgradeProtectionHashBase64: downgradeProtectionHashBase64 + ) return clientFirstMessageBare + "," + serverFirst + "," + finalWithoutProof } @@ -184,6 +249,9 @@ final class LumaScramPlusMechanism: SaslMechanism { private var stage = 0 private var exchange: SCRAMPlusExchange? private var serverFirst: String? + /// XEP-0474: our own downgrade-protection hash sent in the `x` + /// attribute once the server has presented its `h` attribute. + private var downgradeHashBase64: String? init(hash: SCRAMHash, channelBindingStore: LumaChannelBindingStore) { self.hash = hash @@ -197,6 +265,7 @@ final class LumaScramPlusMechanism: SaslMechanism { stage = 0 exchange = nil serverFirst = nil + downgradeHashBase64 = nil } func isAllowedToUse(_ context: Context) -> Bool { @@ -243,7 +312,34 @@ final class LumaScramPlusMechanism: SaslMechanism { throw ClientSaslException.badChallenge(msg: "Invalid challenge") } serverFirst = decodedServerFirst - let final = try exchange.clientFinalMessage(serverFirst: decodedServerFirst) + // XEP-0474: verify the server's `h` downgrade-protection hash over + // the advertised mechanism and channel-binding lists, and answer + // with our own hash in `x`. A mismatch means a MITM tampered with + // the advertised lists. + var downgradeHash: String? + if let serverHash = try SCRAMSHA512.parseServerFirst( + decodedServerFirst, + expectedNoncePrefix: exchange.clientNonce + ).downgradeProtectionHash { + channelBindingStore.markDowngradeProtectionDetected() + let expected = SCRAMDowngradeProtection.hash( + mechanisms: channelBindingStore.advertisedSASLMechanismsOrdered, + channelBindingTypes: channelBindingStore.advertisedChannelBindingTypesOrdered, + using: hash + ) + let expectedBase64 = expected.base64EncodedString() + guard expectedBase64 == serverHash else { + throw ClientSaslException.badChallenge( + msg: "SCRAM downgrade protection hash mismatch (possible MITM)" + ) + } + downgradeHash = expectedBase64 + } + downgradeHashBase64 = downgradeHash + let final = try exchange.clientFinalMessage( + serverFirst: decodedServerFirst, + downgradeProtectionHashBase64: downgradeHash + ) stage = 2 return final.data(using: .utf8)?.base64EncodedString() @@ -257,7 +353,10 @@ final class LumaScramPlusMechanism: SaslMechanism { else { throw ClientSaslException.badChallenge(msg: "Invalid final challenge") } - let expected = try exchange.expectedServerSignature(serverFirst: serverFirst) + let expected = try exchange.expectedServerSignature( + serverFirst: serverFirst, + downgradeProtectionHashBase64: downgradeHashBase64 + ) guard value == expected else { throw ClientSaslException.invalidServerSignature } diff --git a/Sources/Shared/XMPP/LumaScramSha512Mechanism.swift b/Sources/Shared/XMPP/LumaScramSha512Mechanism.swift index 302289e..0207de7 100644 --- a/Sources/Shared/XMPP/LumaScramSha512Mechanism.swift +++ b/Sources/Shared/XMPP/LumaScramSha512Mechanism.swift @@ -11,6 +11,9 @@ enum SCRAMSHA512 { let nonce: String let salt: Data let iterations: Int + /// XEP-0474: base64 downgrade-protection hash in the optional `h=` + /// SCRAM attribute, when the server supports the extension. + let downgradeProtectionHash: String? } private static let nonceAlphabet = Array( @@ -25,7 +28,7 @@ enum SCRAMSHA512 { _ message: String, expectedNoncePrefix: String ) throws -> ServerFirst { - let pattern = #"^(?:m=[^\000=]+,)?r=([\x21-\x2B\x2D-\x7E]+),s=([a-zA-Z0-9/+=]+),i=(\d+)(?:,.*)?$"# + let pattern = #"^(?:m=[^\000=]+,)?r=([\x21-\x2B\x2D-\x7E]+),s=([a-zA-Z0-9/+=]+),i=(\d+)(?:,h=([a-zA-Z0-9/+=]+))?(?:,.*)?$"# guard let regex = try? NSRegularExpression(pattern: pattern) else { throw ClientSaslException.badChallenge(msg: "Failed to parse challenge") } @@ -39,12 +42,23 @@ enum SCRAMSHA512 { } let nonce = String(message[nonceRange]) let iterations = Int(message[iterationsRange]) ?? 0 + let downgradeProtectionHash: String? + if let hashRange = Range(match.range(at: 4), in: message) { + downgradeProtectionHash = String(message[hashRange]) + } else { + downgradeProtectionHash = nil + } guard nonce.hasPrefix(expectedNoncePrefix), let salt = Data(base64Encoded: String(message[saltRange])), iterations > 0 else { throw ClientSaslException.badChallenge(msg: "Invalid challenge") } - return ServerFirst(nonce: nonce, salt: salt, iterations: iterations) + return ServerFirst( + nonce: nonce, + salt: salt, + iterations: iterations, + downgradeProtectionHash: downgradeProtectionHash + ) } /// PBKDF2-HMAC-SHA-512 (RFC 5802 \"Hi\" function). @@ -114,12 +128,21 @@ final class LumaScramSha512Mechanism: SaslMechanism { let name = "SCRAM-SHA-512" private(set) var status: SaslMechanismStatus = .new + /// Optional channel-binding store: when present the mechanism verifies + /// the XEP-0474 downgrade-protection hash from the server and answers + /// with its own hash. + private let channelBindingStore: LumaChannelBindingStore? + private var stage = 0 private var clientNonce = "" private var clientFirstMessageBare = "" private var authMessage = "" private var saltedPassword: [UInt8] = [] + init(channelBindingStore: LumaChannelBindingStore? = nil) { + self.channelBindingStore = channelBindingStore + } + func reset(scopes: Set) { guard scopes.contains(.stream) else { return } status = .new @@ -168,7 +191,30 @@ final class LumaScramSha512Mechanism: SaslMechanism { serverFirst, expectedNoncePrefix: clientNonce ) + // XEP-0474: the optional `h` attribute carries the server's + // downgrade-protection hash over the advertised mechanism and + // channel-binding lists. Verify it (a mismatch means a MITM + // tampered with the lists) and answer with our own hash in `x`. + var downgradeHashBase64: String? + if let serverHash = parsed.downgradeProtectionHash { + channelBindingStore?.markDowngradeProtectionDetected() + let mechanisms = channelBindingStore?.advertisedSASLMechanismsOrdered ?? [] + let bindingTypes = channelBindingStore?.advertisedChannelBindingTypesOrdered ?? [] + let expected = SCRAMDowngradeProtection.hash( + mechanisms: mechanisms, + channelBindingTypes: bindingTypes, + using: .sha512 + ) + let expectedBase64 = expected.base64EncodedString() + guard expectedBase64 == serverHash else { + throw ClientSaslException.badChallenge( + msg: "SCRAM downgrade protection hash mismatch (possible MITM)" + ) + } + downgradeHashBase64 = expectedBase64 + } let clientFinalWithoutProof = "c=biws,r=\(parsed.nonce)" + + (downgradeHashBase64.map { ",x=\($0)" } ?? "") authMessage = clientFirstMessageBare + "," + serverFirst + "," + clientFinalWithoutProof saltedPassword = SCRAMSHA512.saltedPassword( password: password, diff --git a/Sources/Shared/XMPP/LumaTLSNetworkProcessor.swift b/Sources/Shared/XMPP/LumaTLSNetworkProcessor.swift index 2ff25ed..dfd2b77 100644 --- a/Sources/Shared/XMPP/LumaTLSNetworkProcessor.swift +++ b/Sources/Shared/XMPP/LumaTLSNetworkProcessor.swift @@ -25,6 +25,11 @@ final class LumaChannelBindingStore: @unchecked Sendable { private let lock = NSLock() private var tlsState: LumaTLSState? private var advertisedTypes: Set = [] + /// Ordered copies of the advertised SASL mechanism and channel-binding + /// lists — XEP-0474 hashes them in advertisement order. + private var mechanismsOrdered: [String] = [] + private var channelBindingTypesOrdered: [String] = [] + private var downgradeProtectionDetected = false func setTLSState(_ state: LumaTLSState) { lock.lock() @@ -38,10 +43,49 @@ final class LumaChannelBindingStore: @unchecked Sendable { lock.unlock() } + /// Records the advertised lists for the XEP-0474 downgrade-protection + /// hash and the server-information screen. + func setSASLContext( + mechanisms: [String], + channelBindingTypes: [String] + ) { + lock.lock() + mechanismsOrdered = mechanisms + channelBindingTypesOrdered = channelBindingTypes + lock.unlock() + } + + func markDowngradeProtectionDetected() { + lock.lock() + downgradeProtectionDetected = true + lock.unlock() + } + + var isDowngradeProtectionDetected: Bool { + lock.lock() + defer { lock.unlock() } + return downgradeProtectionDetected + } + + var advertisedSASLMechanismsOrdered: [String] { + lock.lock() + defer { lock.unlock() } + return mechanismsOrdered + } + + var advertisedChannelBindingTypesOrdered: [String] { + lock.lock() + defer { lock.unlock() } + return channelBindingTypesOrdered + } + func reset() { lock.lock() tlsState = nil advertisedTypes = [] + mechanismsOrdered = [] + channelBindingTypesOrdered = [] + downgradeProtectionDetected = false lock.unlock() } @@ -87,6 +131,13 @@ final class LumaChannelBindingStore: @unchecked Sendable { var canUseChannelBinding: Bool { preferredChannelBindingType != nil } + + /// Channel-binding types the server advertised in its stream features. + var advertisedChannelBindingTypes: Set { + lock.lock() + defer { lock.unlock() } + return advertisedTypes + } } /// Supplies Luma's OpenSSL-based TLS processor to Martin's legacy diff --git a/Sources/Shared/XMPP/XMPPService.swift b/Sources/Shared/XMPP/XMPPService.swift index 321b642..044e630 100644 --- a/Sources/Shared/XMPP/XMPPService.swift +++ b/Sources/Shared/XMPP/XMPPService.swift @@ -280,6 +280,9 @@ final class XMPPService { /// Mechanisms advertised in the stream features (classic SASL + SASL2), /// shown on the server-information screen. private var advertisedSASLMechanisms: [String] = [] + /// SASL2 (RFC 9050) mechanisms from the `` stream + /// feature, shown as a dedicated list on the server-information screen. + private var advertisedSASL2Mechanisms: [String] = [] private var cancellables: Set = [] private var account: AccountConfiguration? private var archiveSyncStarted = false @@ -435,6 +438,7 @@ final class XMPPService { tlsProbeTask = nil channelBindingStore.reset() advertisedSASLMechanisms = [] + advertisedSASL2Mechanisms = [] let account = try account.validated() self.account = account @@ -1628,6 +1632,38 @@ final class XMPPService { .filter { $0.name == "mechanism" } .compactMap { $0.value } ?? [] } + // Same fallback pattern for SASL2 mechanisms, channel-binding types + // and XEP-0474: prefer the values captured from the pre-auth stream + // features, and only fall back to the live features element when the + // capture never ran (e.g. the session was resumed without a fresh + // login). + let sasl2ForDisplay: [String] + if !advertisedSASL2Mechanisms.isEmpty { + sasl2ForDisplay = advertisedSASL2Mechanisms + } else { + sasl2ForDisplay = streamFeaturesElement? + .findChild(name: "authentication", xmlns: "urn:xmpp:sasl:2")? + .children + .filter { $0.name == "mechanism" } + .compactMap { $0.value } ?? [] + } + let channelBindingTypesForDisplay: [String] + if !channelBindingStore.advertisedChannelBindingTypes.isEmpty { + channelBindingTypesForDisplay = Array( + channelBindingStore.advertisedChannelBindingTypes + ).sorted() + } else { + channelBindingTypesForDisplay = streamFeaturesElement? + .findChild(name: "sasl-channel-binding", xmlns: "urn:xmpp:sasl-cb:0")? + .children + .filter { $0.name == "channel-binding" } + .compactMap { $0.getAttribute("type") } ?? [] + } + // XEP-0474 support is detected during the SCRAM exchange: the server + // includes its downgrade-protection hash in the `h` attribute of the + // server-first message. + let downgradeProtectionForDisplay = channelBindingStore.isDowngradeProtectionDetected + let supportsCSI = streamFeaturesElement? .findChild(name: "csi", xmlns: "urn:xmpp:csi:0") != nil let supportsRosterVersioning = streamFeaturesElement? @@ -1685,6 +1721,14 @@ final class XMPPService { received: stats.received ), saslMethods: saslMechanisms, + sasl2Methods: sasl2ForDisplay, + channelBindingTypes: channelBindingTypesForDisplay, + usedChannelBindingType: + (negotiatedSASLMechanism?.hasSuffix("-PLUS") == true) + ? channelBindingStore.preferredChannelBindingType + : nil, + supportsSCRAMDowngradeProtection: downgradeProtectionForDisplay, + usedSASLMechanism: negotiatedSASLMechanism, tlsVersion: tlsVersion, tlsCipher: negotiatedTLSCipher, saslMechanism: displaySASLMechanism, @@ -1782,6 +1826,22 @@ 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()) + // Watches the raw SASL for the XEP-0474 `h=` attribute so + // the server screen can report downgrade protection regardless of the + // SCRAM mechanism implementation in use. + _ = client.modulesManager.register( + LumaSaslChallengeModule { [weak self] challenge in + guard let data = Data(base64Encoded: challenge), + let text = String(data: data, encoding: .utf8), + let parsed = try? SCRAMSHA512.parseServerFirst( + text, + expectedNoncePrefix: "" + ), + parsed.downgradeProtectionHash != nil + else { return } + self?.channelBindingStore.markDowngradeProtectionDetected() + } + ) // 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 @@ -1800,7 +1860,10 @@ final class XMPPService { LumaScramPlusMechanism(hash: .sha512, channelBindingStore: channelBindingStore), first: true ) - sasl.addMechanism(LumaScramSha512Mechanism(), first: true) + sasl.addMechanism( + LumaScramSha512Mechanism(channelBindingStore: channelBindingStore), + first: true + ) _ = client.modulesManager.register(ResourceBinderModule()) _ = client.modulesManager.register(SessionEstablishmentModule()) _ = client.modulesManager.register( @@ -4202,15 +4265,29 @@ final class XMPPService { .children .filter { $0.name == "mechanism" } .compactMap { $0.value } ?? [] - let channelBindingTypes = Set( + let channelBindingTypesOrdered = 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)) + let channelBindingTypes = Set(channelBindingTypesOrdered) + // Post-auth stream features (SMACK resumption) carry no SASL elements; + // refreshing the captured state from them would wipe the screen to + // "no methods / no channel binding". Only pre-auth features (those + // carrying any SASL-related element) update the state. + let hasSASLElements = !mechanisms.isEmpty + || !sasl2Mechanisms.isEmpty + || !channelBindingTypes.isEmpty + if hasSASLElements { + channelBindingStore.setAdvertisedChannelBindingTypes(channelBindingTypes) + channelBindingStore.setSASLContext( + mechanisms: mechanisms, + channelBindingTypes: channelBindingTypesOrdered + ) + advertisedSASLMechanisms = Array(Set(mechanisms + sasl2Mechanisms)) + advertisedSASL2Mechanisms = 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 diff --git a/Tests/SASLMechanismPreferenceTests.swift b/Tests/SASLMechanismPreferenceTests.swift index 1377f26..c42b553 100644 --- a/Tests/SASLMechanismPreferenceTests.swift +++ b/Tests/SASLMechanismPreferenceTests.swift @@ -83,6 +83,14 @@ final class SASLMechanismPreferenceTests: XCTestCase { ) } + func testWeakMechanismsAreFlagged() { + XCTAssertTrue(SASLMechanismPreference.weakMechanisms.contains("PLAIN")) + XCTAssertTrue(SASLMechanismPreference.weakMechanisms.contains("OAUTHBEARER")) + XCTAssertFalse(SASLMechanismPreference.weakMechanisms.contains("SCRAM-SHA-1")) + XCTAssertFalse(SASLMechanismPreference.weakMechanisms.contains("SCRAM-SHA-512-PLUS")) + XCTAssertFalse(SASLMechanismPreference.weakMechanisms.contains("EXTERNAL")) + } + func testPlusVariantsSkippedWithoutChannelBinding() { let offered = ["SCRAM-SHA-512-PLUS", "SCRAM-SHA-512", "PLAIN"] XCTAssertEqual( diff --git a/Tests/SCRAMDowngradeProtectionTests.swift b/Tests/SCRAMDowngradeProtectionTests.swift new file mode 100644 index 0000000..bb63536 --- /dev/null +++ b/Tests/SCRAMDowngradeProtectionTests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import Luma + +final class SCRAMDowngradeProtectionTests: XCTestCase { + /// Reference vector from XEP-0474 §6.3: the server's `h` attribute is + /// base64(SHA-1('SCRAM-SHA-1\u{1E}SCRAM-SHA-1-PLUS\u{1F}tls-exporter\u{1E}tls-server-end-point')). + func testHashMatchesXEP0474Example() { + let hash = SCRAMDowngradeProtection.hash( + mechanisms: ["SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"], + channelBindingTypes: ["tls-exporter", "tls-server-end-point"], + using: .sha1 + ) + XCTAssertEqual(hash.base64EncodedString(), "G6k/rBLDqgOhRRaCuuatSDFkJ08=") + } + + func testHashSortsListsPerIOctetCollation() { + // XEP-0474 §5.1 requires i;octet-sorted lists, so advertisement order + // must not affect the hash. + let advertisedOrder = SCRAMDowngradeProtection.hash( + mechanisms: ["SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"], + channelBindingTypes: ["tls-exporter", "tls-server-end-point"], + using: .sha1 + ) + let reordered = SCRAMDowngradeProtection.hash( + mechanisms: ["SCRAM-SHA-1-PLUS", "SCRAM-SHA-1"], + channelBindingTypes: ["tls-server-end-point", "tls-exporter"], + using: .sha1 + ) + XCTAssertEqual(advertisedOrder, reordered) + } + + func testHashOmitsBindingSectionWhenNoTypesAdvertised() { + let withBindings = SCRAMDowngradeProtection.hash( + mechanisms: ["SCRAM-SHA-1"], + channelBindingTypes: ["tls-exporter"], + using: .sha1 + ) + let withoutBindings = SCRAMDowngradeProtection.hash( + mechanisms: ["SCRAM-SHA-1"], + channelBindingTypes: [], + using: .sha1 + ) + XCTAssertNotEqual(withBindings, withoutBindings) + // Without bindings the input is exactly the sorted mechanisms with no + // trailing 0x1F delimiter. + let expected = SCRAMHash.sha1.hash(data: Data("SCRAM-SHA-1".utf8)) + XCTAssertEqual(withoutBindings, expected) + } + + func testServerFirstParsesDowngradeProtectionHash() throws { + let message = "r=12C4CD5C-E38E-4A98-8F6D-15C38F51CCC6a09117a6-ac50-4f2f-93f1-93799c2bddf6,s=QSXCR+Q6sek8bf92,i=4096,h=G6k/rBLDqgOhRRaCuuatSDFkJ08=" + let parsed = try SCRAMSHA512.parseServerFirst( + message, + expectedNoncePrefix: "12C4CD5C-E38E-4A98-8F6D-15C38F51CCC6" + ) + XCTAssertEqual(parsed.downgradeProtectionHash, "G6k/rBLDqgOhRRaCuuatSDFkJ08=") + } + + func testServerFirstWithoutHashParsesCleanly() throws { + let message = "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096" + let parsed = try SCRAMSHA512.parseServerFirst( + message, + expectedNoncePrefix: "fyko+d2lbbFgONRv9qkxdawL" + ) + XCTAssertNil(parsed.downgradeProtectionHash) + } + + func testClientFinalIncludesXAttributeWhenHashPresent() throws { + let exchange = SCRAMPlusExchange( + hash: .sha1, + username: "user", + password: "pencil", + channelBindingType: "tls-server-end-point", + channelBindingData: Data(0..<32), + clientNonce: "fyko+d2lbbFgONRv9qkxdawL" + ) + let serverFirst = "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096,h=G6k/rBLDqgOhRRaCuuatSDFkJ08=" + let final = try exchange.clientFinalMessage( + serverFirst: serverFirst, + downgradeProtectionHashBase64: "G6k/rBLDqgOhRRaCuuatSDFkJ08=" + ) + XCTAssertTrue(final.contains(",x=G6k/rBLDqgOhRRaCuuatSDFkJ08=,")) + // The same x-attribute must be part of the expected signature's + // auth message (server reconstructs client-final-without-proof). + let expected = try exchange.expectedServerSignature( + serverFirst: serverFirst, + downgradeProtectionHashBase64: "G6k/rBLDqgOhRRaCuuatSDFkJ08=" + ) + XCTAssertFalse(expected.isEmpty) + } +}