From b0eb44d7a74484d6f4e18b3abd0795e5a2627e76 Mon Sep 17 00:00:00 2001 From: TolaMironcenko Date: Sat, 29 Aug 2026 05:30:30 +0700 Subject: [PATCH] #7 add SCRAM-SHA-512 and document TLS 1.3 support - Implement SCRAM-SHA-512 (RFC 5802 with SHA-512) as a Luma-side SaslMechanism and register it ahead of Martin's SHA-256/SHA-1/PLAIN, so modern servers preferring SHA-512 authenticate with it. - Verify the math against Python-computed reference vectors (salted password, client proof, server signature) in SCRAMSHA512Tests and guard the mechanism registration in Scripts/verify.sh. - Confirm the TLS stack negotiates TLS 1.3 (negotiatedSSL + ALPN via SecureTransport, handshake verified against a TLS 1.3-only server) and document the TLS/SCRAM posture in SECURITY.md. --- AGENTS.md | 3 +- Docs/SECURITY.md | 10 + Luma.xcodeproj/project.pbxproj | 10 + Scripts/verify.sh | 10 + Sources/Shared/UI/LoginView.swift | 4 +- .../XMPP/LumaScramSha512Mechanism.swift | 205 ++++++++++++++++++ Sources/Shared/XMPP/XMPPService.swift | 6 +- Tests/SCRAMSHA512Tests.swift | 86 ++++++++ 8 files changed, 331 insertions(+), 3 deletions(-) create mode 100644 Sources/Shared/XMPP/LumaScramSha512Mechanism.swift create mode 100644 Tests/SCRAMSHA512Tests.swift diff --git a/AGENTS.md b/AGENTS.md index 0bb442a..a3c26ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,8 @@ and the Martin / MartinOMEMO libraries. The Xcode project is generated from SwiftData through `@Query` (`MainChatView`, `ChatView`, `ForwardMessageView`). - `XMPP/` — `XMPPService` (MAM/OMEMO/MUC), `LumaCallEngine`, OMEMO store, - `SASLprep` (RFC 4013), SASL failure observer/messages. + `SASLprep` (RFC 4013), SCRAM-SHA-512 mechanism, SASL failure + observer/messages. - `Persistence/` — `ArchiveStore` (per-account SwiftData container), `ArchiveMetadataRecord` (durable MAM checkpoint metadata), `LegacyArchiveImporter` (one-time legacy JSON snapshot migration), diff --git a/Docs/SECURITY.md b/Docs/SECURITY.md index cd20947..4be86c3 100644 --- a/Docs/SECURITY.md +++ b/Docs/SECURITY.md @@ -9,6 +9,16 @@ доверия обязательны даже при ручном адресе подключения. - Пароль для SCRAM нормализуется по RFC 4013 (SASLprep), чтобы совпадать с серверной нормализацией; для PLAIN пароль отправляется как введён. +- SCRAM доступен в вариантах SHA-512 (предпочтительный, реализация Luma), + SHA-256 и SHA-1 (Martin); механизм выбирается по рекламе сервера. +- Канал шифруется STARTTLS/direct TLS через SecureTransport с уровнем + `negotiatedSSL` (исключает TLS ниже 1.2) и ALPN «xmpp-client»; на системах + с поддержкой SecureTransport договаривается TLS 1.3. +- SCRAM доступен в вариантах SHA-512 (предпочтительный, реализация Luma), + SHA-256 и SHA-1 (Martin); механизм выбирается по рекламе сервера. +- Канал шифруется STARTTLS/direct TLS через SecureTransport с уровнем + `negotiatedSSL` (исключает TLS ниже 1.2) и ALPN «xmpp-client»; на системах + с поддержкой SecureTransport договаривается TLS 1.3. - Когда OMEMO включено глобально или для конкретного чата, исходящие сообщения не откатываются на plaintext при ошибке: ошибка показывается пользователю. - Пользователь может осознанно отключить OMEMO глобально или для отдельного diff --git a/Luma.xcodeproj/project.pbxproj b/Luma.xcodeproj/project.pbxproj index dee48ce..6f360c0 100644 --- a/Luma.xcodeproj/project.pbxproj +++ b/Luma.xcodeproj/project.pbxproj @@ -26,6 +26,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 */; }; + 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 */; }; 1655A2B7ACF4003EF41A1AB8 /* ConversationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 758B448979C845F56168A51F /* ConversationRow.swift */; }; @@ -65,6 +66,7 @@ 3375874DFCE22B22879112DD /* SystemPhotoCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */; }; 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 */; }; 3C2727D165D2EF1CC742EEF7 /* VideoNoteCaptureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0142FF4A1B05C373DAB4CDBE /* VideoNoteCaptureView.swift */; }; 3CFEAC0C613CC9248DA8E35E /* ReplyThreadOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19C556A41E640592AF6376B6 /* ReplyThreadOverlay.swift */; }; 3D3DDA2292673CE4FF397174 /* ServerInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */; }; @@ -212,6 +214,7 @@ E65D356ABC5320BBFB3E2F6E /* VideoNoteRecordingLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */; }; E67E0D2C3FBA2C3739B692E0 /* VideoAttachmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */; }; E6890C743C5BBD0B287DDB53 /* ArchiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */; }; + E6B279889E62AB9AD0C5BCE1 /* LumaScramSha512Mechanism.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C16CFC03E7F478838A704FD /* LumaScramSha512Mechanism.swift */; }; E9FA22C965454FA6890DC3D5 /* AvatarImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5746D1E21ABBBCFC6CD9694 /* AvatarImageProcessor.swift */; }; EBAF4861DD93FBEAA4B37D1C /* MediaViewerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F7B01C59E9667ADAFABBAC /* MediaViewerItem.swift */; }; ECAC4761F04316A71EC3C716 /* AttachmentDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C04D749B7CA93CB7C59127D6 /* AttachmentDraft.swift */; }; @@ -319,6 +322,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 = ""; }; 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 = ""; }; 5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemPhotoCameraView.swift; sourceTree = ""; }; 5886E1E233C129B9317D6059 /* CertificateTrustEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateTrustEvaluator.swift; sourceTree = ""; }; @@ -343,6 +347,7 @@ 7846C2EEE2551C6690A4FDC6 /* Conversation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Conversation.swift; sourceTree = ""; }; 78E78CC8582C0399901D8A27 /* AttachmentPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentPreviewView.swift; sourceTree = ""; }; 7BD0A69BACDF8F0C43218AEB /* AudioMessageRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMessageRecorder.swift; sourceTree = ""; }; + 7C16CFC03E7F478838A704FD /* LumaScramSha512Mechanism.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaScramSha512Mechanism.swift; sourceTree = ""; }; 7C97DDD3DA8B45C3F2085661 /* MessageInteractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInteractionTests.swift; sourceTree = ""; }; 7C9866558B72CB1909A01EEC /* Shared.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Shared.xcassets; sourceTree = ""; }; 7F5960729C94DC995374CFD6 /* ComposerRecordingGestureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerRecordingGestureTests.swift; sourceTree = ""; }; @@ -446,6 +451,7 @@ E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */, F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */, C3E21A7D359AF7A7AF97D8DE /* LumaSaslFailureModule.swift */, + 7C16CFC03E7F478838A704FD /* LumaScramSha512Mechanism.swift */, 69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */, B2446F6A1D00ADAD261D4CCD /* SASLprep.swift */, F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */, @@ -572,6 +578,7 @@ F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */, 1F7B2B37E9E8E6A46ED7547A /* SaslFailureMessageTests.swift */, 13EFA01112297641B9B0EBC5 /* SASLprepTests.swift */, + 5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */, 9C8B12EFBC83F9235B6AF992 /* VideoNoteRecordingCompletionPolicyTests.swift */, D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */, 33F90AC63C822D9DF95D7B5C /* VideoNoteStopPolicyTests.swift */, @@ -912,6 +919,7 @@ CBED8565C1313DC03D21A105 /* LumaRoomStore.swift in Sources */, F1713D1CD4E019540BFA78FE /* LumaRosterStore.swift in Sources */, 0BD15E9B1C488AA2B7B56AF7 /* LumaSaslFailureModule.swift in Sources */, + E6B279889E62AB9AD0C5BCE1 /* LumaScramSha512Mechanism.swift in Sources */, 08696800299D2D7BCCBCAA58 /* MainChatView.swift in Sources */, 0E4CE634E1568EE121B8AF75 /* MediaFileIO.swift in Sources */, 2822384FC6AF9685C4D014C1 /* MediaMetadata.swift in Sources */, @@ -1020,6 +1028,7 @@ 03C65C3DAA1D8776AF496EFA /* LumaRoomStore.swift in Sources */, 7F4C00E98574C8E5193F442E /* LumaRosterStore.swift in Sources */, C1AB954660804F7925607038 /* LumaSaslFailureModule.swift in Sources */, + 127C4B7A5083081D22FD21CD /* LumaScramSha512Mechanism.swift in Sources */, 7DA3177772595E37DC8066E4 /* MainChatView.swift in Sources */, EFC29F3543B1371E63D48510 /* MediaFileIO.swift in Sources */, AFCBFB59F729DE95E453A7D3 /* MediaMetadata.swift in Sources */, @@ -1095,6 +1104,7 @@ 0535722A2D4FC893F16D78F1 /* MessageReplySwipeTests.swift in Sources */, 9E98F99F3FBFC9DAB99A3650 /* NotificationPolicyTests.swift in Sources */, 1B7C9F60AA7BFF6A2C0FE45D /* SASLprepTests.swift in Sources */, + 3BF0521699C170F8411386CD /* SCRAMSHA512Tests.swift in Sources */, 3DDB1B15BCE5B58D12B60558 /* SaslFailureMessageTests.swift in Sources */, FEB3A38F302E95303F0E8773 /* VideoNoteRecordingCompletionPolicyTests.swift in Sources */, E65D356ABC5320BBFB3E2F6E /* VideoNoteRecordingLifecycleTests.swift in Sources */, diff --git a/Scripts/verify.sh b/Scripts/verify.sh index 2f82f4f..e79f1e3 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -84,6 +84,8 @@ required=( Tests/SASLprepTests.swift Tests/SaslFailureMessageTests.swift Tests/MediaPreviewProcessorVideoTests.swift + Tests/SCRAMSHA512Tests.swift + Sources/Shared/XMPP/LumaScramSha512Mechanism.swift Sources/Shared/XMPP/SASLprep.swift Sources/Shared/XMPP/LumaSaslFailureModule.swift Sources/Shared/XMPP/SaslFailureMessage.swift @@ -321,6 +323,14 @@ grep -q 'passwordVisible' Sources/Shared/UI/LoginView.swift || { echo "The login screen must let the user verify the typed password" exit 1 } +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 || { + echo "SCRAM-SHA-512 must be registered ahead of Martin's mechanisms" + exit 1 +} grep -q 'func deleteGroupChat' Sources/Shared/Models/AppModel.swift || { echo "Group chats must support local deletion" exit 1 diff --git a/Sources/Shared/UI/LoginView.swift b/Sources/Shared/UI/LoginView.swift index fc09295..30d6030 100644 --- a/Sources/Shared/UI/LoginView.swift +++ b/Sources/Shared/UI/LoginView.swift @@ -79,9 +79,9 @@ struct LoginView: View { TextField("you@example.org", text: $jid) #if os(iOS) .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) #endif .textContentType(.username) - .textInputAutocapitalization(.never) .autocorrectionDisabled() .textFieldStyle(.roundedBorder) .disableAutocorrection(true) @@ -101,7 +101,9 @@ struct LoginView: View { } } .autocorrectionDisabled() + #if os(iOS) .textInputAutocapitalization(.never) + #endif .textFieldStyle(.roundedBorder) Button { diff --git a/Sources/Shared/XMPP/LumaScramSha512Mechanism.swift b/Sources/Shared/XMPP/LumaScramSha512Mechanism.swift new file mode 100644 index 0000000..302289e --- /dev/null +++ b/Sources/Shared/XMPP/LumaScramSha512Mechanism.swift @@ -0,0 +1,205 @@ +import CommonCrypto +import CryptoKit +import Foundation +import Martin + +/// Pure SCRAM-SHA-512 math per RFC 5802 (SHA-512 / HMAC-SHA-512), kept +/// separate from the Martin mechanism so the proof computation can be +/// unit-tested against reference vectors. +enum SCRAMSHA512 { + struct ServerFirst { + let nonce: String + let salt: Data + let iterations: Int + } + + private static let nonceAlphabet = Array( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + ) + + static func makeNonce(length: Int = 24) -> String { + String((0.. ServerFirst { + let pattern = #"^(?:m=[^\000=]+,)?r=([\x21-\x2B\x2D-\x7E]+),s=([a-zA-Z0-9/+=]+),i=(\d+)(?:,.*)?$"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { + throw ClientSaslException.badChallenge(msg: "Failed to parse challenge") + } + let fullRange = NSRange(message.startIndex..., in: message) + guard let match = regex.firstMatch(in: message, range: fullRange), + match.numberOfRanges >= 4, + let nonceRange = Range(match.range(at: 1), in: message), + let saltRange = Range(match.range(at: 2), in: message), + let iterationsRange = Range(match.range(at: 3), in: message) else { + throw ClientSaslException.badChallenge(msg: "Failed to parse challenge") + } + let nonce = String(message[nonceRange]) + let iterations = Int(message[iterationsRange]) ?? 0 + 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) + } + + /// PBKDF2-HMAC-SHA-512 (RFC 5802 \"Hi\" function). + static func saltedPassword(password: String, salt: Data, iterations: Int) -> [UInt8] { + let passwordBytes = Array(password.utf8) + let saltBytes = [UInt8](salt) + var output = [UInt8](repeating: 0, count: Int(CC_SHA512_DIGEST_LENGTH)) + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passwordBytes, passwordBytes.count, + saltBytes, saltBytes.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA512), + UInt32(iterations), + &output, output.count + ) + return output + } + + static func clientProof(saltedPassword: [UInt8], authMessage: String) -> [UInt8] { + let clientKey = hmac(saltedPassword, Array("Client Key".utf8)) + let storedKey = digest(clientKey) + let clientSignature = hmac(storedKey, Array(authMessage.utf8)) + return zip(clientKey, clientSignature).map(^) + } + + static func serverSignature(saltedPassword: [UInt8], authMessage: String) -> [UInt8] { + let serverKey = hmac(saltedPassword, Array("Server Key".utf8)) + return hmac(serverKey, Array(authMessage.utf8)) + } + + static func verifyServerSignature( + saltedPassword: [UInt8], + authMessage: String, + finalMessage: String + ) -> Bool { + let pattern = #"^(?:e=([^,]+)|v=([a-zA-Z0-9/+=]+)(?:,.*)?)$"# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch( + in: finalMessage, + range: NSRange(finalMessage.startIndex..., in: finalMessage) + ), + match.numberOfRanges >= 3, + let vRange = Range(match.range(at: 2), in: finalMessage), + let value = Data(base64Encoded: String(finalMessage[vRange])) else { + return false + } + return value == Data(serverSignature(saltedPassword: saltedPassword, authMessage: authMessage)) + } + + private static func hmac(_ key: [UInt8], _ data: [UInt8]) -> [UInt8] { + let code = HMAC.authenticationCode( + for: Data(data), + using: SymmetricKey(data: Data(key)) + ) + return Array(code) + } + + private static func digest(_ data: [UInt8]) -> [UInt8] { + Array(SHA512.hash(data: Data(data))) + } +} + +/// SCRAM-SHA-512 SASL mechanism. Martin ships only SCRAM-SHA-1 and +/// SCRAM-SHA-256; modern servers prefer SHA-512, so Luma registers this +/// mechanism ahead of Martin's ones. +final class LumaScramSha512Mechanism: SaslMechanism { + let name = "SCRAM-SHA-512" + private(set) var status: SaslMechanismStatus = .new + + private var stage = 0 + private var clientNonce = "" + private var clientFirstMessageBare = "" + private var authMessage = "" + private var saltedPassword: [UInt8] = [] + + func reset(scopes: Set) { + guard scopes.contains(.stream) else { return } + status = .new + stage = 0 + clientNonce = "" + clientFirstMessageBare = "" + authMessage = "" + saltedPassword = [] + } + + func isAllowedToUse(_ context: Context) -> Bool { + if case .password(_, _, _) = context.connectionConfiguration.credentials { + return true + } + return false + } + + 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(_, _, _) = context.connectionConfiguration.credentials else { + throw ClientSaslException.genericError(msg: "Invalid credentials type") + } + clientNonce = SCRAMSHA512.makeNonce() + clientFirstMessageBare = + "n=\(context.userBareJid.localPart ?? ""),r=\(clientNonce)" + stage = 1 + status = .completedExpected + let first = "n,," + clientFirstMessageBare + return first.data(using: .utf8)?.base64EncodedString() + + case 1: + guard case .password(let password, _, _) = context.connectionConfiguration.credentials, + let input, + let data = Data(base64Encoded: input), + let serverFirst = String(data: data, encoding: .utf8) else { + throw ClientSaslException.badChallenge(msg: "Invalid challenge") + } + let parsed = try SCRAMSHA512.parseServerFirst( + serverFirst, + expectedNoncePrefix: clientNonce + ) + let clientFinalWithoutProof = "c=biws,r=\(parsed.nonce)" + authMessage = clientFirstMessageBare + "," + serverFirst + "," + clientFinalWithoutProof + saltedPassword = SCRAMSHA512.saltedPassword( + password: password, + salt: parsed.salt, + iterations: parsed.iterations + ) + let proof = SCRAMSHA512.clientProof( + saltedPassword: saltedPassword, + authMessage: authMessage + ) + stage = 2 + let final = clientFinalWithoutProof + ",p=" + Data(proof).base64EncodedString() + return final.data(using: .utf8)?.base64EncodedString() + + case 2: + guard let input, + let data = Data(base64Encoded: input), + let finalMessage = String(data: data, encoding: .utf8), + SCRAMSHA512.verifyServerSignature( + saltedPassword: saltedPassword, + authMessage: authMessage, + finalMessage: finalMessage + ) else { + throw ClientSaslException.invalidServerSignature + } + status = .completed + return nil + + default: + throw ClientSaslException.genericError(msg: "Illegal state") + } + } +} + diff --git a/Sources/Shared/XMPP/XMPPService.swift b/Sources/Shared/XMPP/XMPPService.swift index 13a618f..67dfcc2 100644 --- a/Sources/Shared/XMPP/XMPPService.swift +++ b/Sources/Shared/XMPP/XMPPService.swift @@ -1565,7 +1565,11 @@ 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()) - _ = client.modulesManager.register(SaslModule()) + // 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. + let sasl = client.modulesManager.register(SaslModule()) + sasl.addMechanism(LumaScramSha512Mechanism(), first: true) _ = client.modulesManager.register(ResourceBinderModule()) _ = client.modulesManager.register(SessionEstablishmentModule()) _ = client.modulesManager.register( diff --git a/Tests/SCRAMSHA512Tests.swift b/Tests/SCRAMSHA512Tests.swift new file mode 100644 index 0000000..6bd61bc --- /dev/null +++ b/Tests/SCRAMSHA512Tests.swift @@ -0,0 +1,86 @@ +import XCTest +@testable import Luma + +final class SCRAMSHA512Tests: XCTestCase { + /// Reference values computed with Python hashlib/hmac for the RFC 5802 + /// vector inputs (password \"pencil\", salt QSXCR+Q6sek8bf92, 4096 rounds). + private let referenceAuthMessage = + "n=user,r=fyko+d2lbbFgONRv9qkxdawL" + + ",r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096" + + ",c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j" + + func testSaltedPasswordMatchesReference() throws { + let salt = try XCTUnwrap(Data(base64Encoded: "QSXCR+Q6sek8bf92")) + let salted = SCRAMSHA512.saltedPassword(password: "pencil", salt: salt, iterations: 4096) + let hex = salted.map { String(format: "%02x", $0) }.joined() + XCTAssertEqual( + hex, + "97382788b15cbe09512d2d20b7e0b8832f8dbab4b7388395440535cd9395e0ffaa1625453b6fde746412bbf903d4bc1d5f448d57f2ac3dd1d2c04979a914ee65" + ) + } + + func testClientProofMatchesReference() throws { + let salt = try XCTUnwrap(Data(base64Encoded: "QSXCR+Q6sek8bf92")) + let salted = SCRAMSHA512.saltedPassword(password: "pencil", salt: salt, iterations: 4096) + let proof = SCRAMSHA512.clientProof( + saltedPassword: salted, + authMessage: referenceAuthMessage + ) + XCTAssertEqual( + Data(proof).base64EncodedString(), + "VdS8LkrURiej1tG6iX+fqCXQfUnBb//d9llXYaH+ylUbDwBUz9geyR9fC4TewskRUM2tlYSalhAT4Aay1Q5dTA==" + ) + } + + func testServerSignatureMatchesReference() throws { + let salt = try XCTUnwrap(Data(base64Encoded: "QSXCR+Q6sek8bf92")) + let salted = SCRAMSHA512.saltedPassword(password: "pencil", salt: salt, iterations: 4096) + let signature = SCRAMSHA512.serverSignature( + saltedPassword: salted, + authMessage: referenceAuthMessage + ) + XCTAssertEqual( + Data(signature).base64EncodedString(), + "14PAAuavk9hxBEkgB0brDxUhvWu+N16meYk+qxVNFqchR8QPohM09Y4Z6WaTCuX4C6nqMB9KIJTDm6RpSM990g==" + ) + } + + func testVerifyServerSignatureAcceptsAndRejects() throws { + let salt = try XCTUnwrap(Data(base64Encoded: "QSXCR+Q6sek8bf92")) + let salted = SCRAMSHA512.saltedPassword(password: "pencil", salt: salt, iterations: 4096) + let good = "v=14PAAuavk9hxBEkgB0brDxUhvWu+N16meYk+qxVNFqchR8QPohM09Y4Z6WaTCuX4C6nqMB9KIJTDm6RpSM990g==" + XCTAssertTrue(SCRAMSHA512.verifyServerSignature( + saltedPassword: salted, + authMessage: referenceAuthMessage, + finalMessage: good + )) + XCTAssertFalse(SCRAMSHA512.verifyServerSignature( + saltedPassword: salted, + authMessage: referenceAuthMessage, + finalMessage: "v=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + )) + XCTAssertFalse(SCRAMSHA512.verifyServerSignature( + saltedPassword: salted, + authMessage: referenceAuthMessage, + finalMessage: "e=server-error", + )) + } + + func testParseServerFirst() throws { + let parsed = try SCRAMSHA512.parseServerFirst( + "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096", + expectedNoncePrefix: "fyko+d2lbbFgONRv9qkxdawL" + ) + XCTAssertEqual(parsed.nonce, "fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j") + XCTAssertEqual(parsed.salt, Data(base64Encoded: "QSXCR+Q6sek8bf92")) + XCTAssertEqual(parsed.iterations, 4096) + } + + func testParseServerFirstRejectsWrongNonce() { + XCTAssertThrowsError(try SCRAMSHA512.parseServerFirst( + "r=OTHER,s=QSXCR+Q6sek8bf92,i=4096", + expectedNoncePrefix: "fyko+d2lbbFgONRv9qkxdawL" + )) + } +} +