#11 fix SASL login failures with non-ASCII passwords
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

- Normalize SCRAM passwords with RFC 4013 SASLprep before the
  challenge response, so they match SASLprep-compliant servers
  (Martin hashes raw UTF-8) and PLAIN still sends the password
  untouched.
- Capture the raw <failure/> condition with LumaSaslFailureModule and
  map SASL errors to actionable Russian messages instead of the
  cryptic OS-localized "Martin.SaslError, error 5".
- Cover SASLprep and failure message mapping with unit tests and new
  verify.sh invariants.
This commit is contained in:
wt committed 2026-08-29 01:26:03 +07:00
1 parent 2884810ec4
commit fc07855036
13 files changed
+410 -28

No files matched your search

@@ -0,0 +1,37 @@
import Foundation
import Martin
/// Observes the raw SASL `<failure/>` stanza before `SaslModule` collapses
/// it into its `SaslError` enum, so the UI can show the real RFC 6120
/// condition (`account-disabled`, `credentials-expired`, …) and the
/// optional server `<text/>` instead of a catch-all "authentication
/// failure". Registered before `SaslModule` in the module list.
final class LumaSaslFailureModule: XmppModuleBase, XmppModule {
static let ID = "lumaSaslFailure"
static let saslXMLNS = "urn:ietf:params:xml:ns:xmpp-sasl"
struct Failure: Equatable, Sendable {
let condition: String?
let text: String?
}
let criteria = Criteria.name("failure", xmlns: LumaSaslFailureModule.saslXMLNS)
let features: [String] = []
private let lock = NSLock()
private var stored: Failure?
var lastFailure: Failure? {
lock.lock()
defer { lock.unlock() }
return stored
}
func process(stanza: Stanza) throws {
let condition = stanza.findChild()?.name
let text = stanza.findChild(name: "text")?.value
lock.lock()
stored = Failure(condition: condition, text: text)
lock.unlock()
}
}
+101
View File
@@ -0,0 +1,101 @@
import Foundation
/// RFC 4013 (SASLprep) profile of stringprep for XMPP passwords.
///
/// Martin's SCRAM implementation feeds the password into the PBKDF as raw
/// UTF-8 (ScramMechanism.normalize), while SASLprep-compliant servers
/// (Prosody, ejabberd) normalize it first. Passwords containing characters
/// the profile changes (non-ASCII spaces, soft hyphen, zero-width marks,
/// fullwidth forms, decomposed accents, ...) therefore fail with
/// 'not-authorized' even when the password is correct. Luma prepares the
/// password itself before handing it to SCRAM so both sides agree.
///
/// The profile is: mapping (RFC 3454 B.1 + RFC 4013 C.1.2), NFKC
/// normalization, prohibition checks. The RFC 3454 section 6 bidi rules are
/// intentionally not enforced: they only affect pathological passwords and
/// the server enforces them on its side anyway.
enum SASLprep {
enum PreparationError: Error, Equatable {
case prohibitedCharacter(Character)
}
/// RFC 4013 C.1.2: non-ASCII space characters mapped to U+0020 SPACE.
private static let mappedToSpace: Set<Character> = [
"\u{00A0}", "\u{1680}", "\u{2000}", "\u{2001}", "\u{2002}", "\u{2003}",
"\u{2004}", "\u{2005}", "\u{2006}", "\u{2007}", "\u{2008}", "\u{2009}",
"\u{200A}", "\u{200B}", "\u{202F}", "\u{205F}", "\u{3000}",
]
/// RFC 3454 B.1: characters mapped to nothing. U+200B is deliberately
/// absent: RFC 4013 C.1.2 maps it to SPACE instead.
private static func isMappedToNothing(_ scalar: Unicode.Scalar) -> Bool {
let value = scalar.value
if value == 0x00AD || value == 0x034F || value == 0x1806 { return true }
if value >= 0x180B && value <= 0x180D { return true }
if value == 0x200C || value == 0x200D { return true }
if value == 0x2060 || value == 0xFEFF { return true }
if value >= 0xFE00 && value <= 0xFE0F { return true }
return false
}
/// RFC 3454 C.2.1, C.2.2, C.2.3, C.3, C.4 and C.8: prohibited output.
private static func isProhibited(_ scalar: Unicode.Scalar) -> Bool {
let value = scalar.value
// C.2.1 ASCII control characters.
if value <= 0x1F || value == 0x7F { return true }
// C.2.2 non-ASCII control characters.
if value >= 0x80 && value <= 0x9F { return true }
// C.2.3 private use.
if value >= 0xE000 && value <= 0xF8FF { return true }
if value >= 0xF0000 && value <= 0xFFFFD { return true }
if value >= 0x100000 && value <= 0x10FFFD { return true }
// C.3 non-character code points.
if value >= 0xFDD0 && value <= 0xFDEF { return true }
if value & 0xFFFF == 0xFFFE || value & 0xFFFF == 0xFFFF { return true }
// C.4 surrogates cannot appear in a Swift string.
// C.8 change display properties / deprecated.
if value == 0x0340 || value == 0x0341 { return true }
if value == 0x200E || value == 0x200F { return true }
if value == 0x202A || value == 0x202B || value == 0x202C || value == 0x202D
|| value == 0x202E
{
return true
}
if value == 0x206A || value == 0x206B || value == 0x206C || value == 0x206D
|| value == 0x206E || value == 0x206F
{
return true
}
return false
}
/// Returns the prepared password, or throws PreparationError when the
/// input contains characters RFC 4013 prohibits.
static func prepare(_ input: String) throws -> String {
// 1. Map.
var mapped = String.UnicodeScalarView()
for scalar in input.unicodeScalars {
let character = Character(String(scalar))
if isMappedToNothing(scalar) { continue }
if mappedToSpace.contains(character) {
mapped.append(contentsOf: " ".unicodeScalars)
continue
}
mapped.append(scalar)
}
// 2. Normalize (NFKC also composes decomposed accents).
let normalized = String(mapped).applyingTransform(
StringTransform("NFKC"),
reverse: false
) ?? String(mapped).precomposedStringWithCanonicalMapping
// 3. Prohibit.
for scalar in normalized.unicodeScalars where isProhibited(scalar) {
throw PreparationError.prohibitedCharacter(Character(String(scalar)))
}
return normalized
}
}
@@ -0,0 +1,47 @@
import Foundation
import Martin
/// Turns a SASL authentication failure into an actionable Russian message.
/// The raw `<failure/>` condition (captured by `LumaSaslFailureModule`)
/// wins over Martin's `SaslError`, because Martin maps every unknown RFC
/// 6120 condition (e.g. `account-disabled`) to `not_authorized`.
enum SaslFailureMessage {
static func describe(error: Error, condition: String?, serverText: String?) -> String {
if let serverText, !serverText.isEmpty {
return "Сервер отклонил вход: \(serverText)"
}
switch condition {
case "account-disabled":
return "Аккаунт отключён на сервере. Обратитесь к администратору сервера."
case "credentials-expired":
return "Срок действия пароля истёк. Обновите пароль на сервере."
case "encryption-required":
return "Сервер требует шифрование канала для входа."
case "malformed-request":
return "Сервер не смог обработать запрос входа. Попробуйте ещё раз."
case "restricted-connection":
return "Сервер ограничил подключения с этого адреса."
default:
break
}
if let saslError = error as? SaslError {
switch saslError {
case .not_authorized:
return "Сервер отклонил имя пользователя или пароль. Проверьте JID и пароль."
case .temporary_auth_failure:
return "Сервер временно не может проверить учётные данные. Попробуйте через минуту."
case .server_not_trusted:
return "Сервер не прошёл проверку подписи SCRAM."
case .incorrect_encoding:
return "Сервер не принял кодировку учётных данных."
case .invalid_authzid:
return "Сервер отклонил идентификатор авторизации."
case .invalid_mechanism, .mechanism_too_weak:
return "Сервер не поддерживает доступные способы входа."
case .aborted:
return "Вход прерван."
}
}
return error.localizedDescription
}
}
+64 -7
View File
@@ -264,6 +264,8 @@ final class XMPPService {
private var client: XMPPClient?
private var omemoStorage: LumaOMEMOStore?
private var connectionStatsModule: LumaConnectionStatsModule?
private var saslFailureModule: LumaSaslFailureModule?
private var activePassword: String?
private var lastLoginDate: Date?
private var smacksSessionEstablishedDate: Date?
private var cancellables: Set<AnyCancellable> = []
@@ -361,7 +363,7 @@ final class XMPPService {
case .connected:
return .connected
case .disconnected(let reason):
return .disconnected(reason: Self.reasonText(reason))
return .disconnected(reason: reasonText(reason))
}
}
@@ -429,6 +431,7 @@ final class XMPPService {
configureModules(client: client, signalContext: signalContext, omemoStorage: omemoStorage)
configureConnection(client: client, account: account, password: password)
activePassword = password
subscribe(to: client, omemoStorage: omemoStorage)
self.client = client
@@ -449,8 +452,9 @@ final class XMPPService {
startArchiveSyncIfAvailable()
} catch {
callEngine.detach()
eventHandler?(.connection(.disconnected(reason: error.localizedDescription)))
throw LumaXMPPError.connection(error.localizedDescription)
let message = connectionFailureMessage(for: error)
eventHandler?(.connection(.disconnected(reason: message)))
throw LumaXMPPError.connection(message)
}
}
@@ -503,6 +507,7 @@ final class XMPPService {
omemoConfiguredRoomJIDs.removeAll()
knownChatStatePeers.removeAll()
connectionStatsModule = nil
saslFailureModule = nil
lastLoginDate = nil
smacksSessionEstablishedDate = nil
eventHandler?(.connection(.disconnected(reason: nil)))
@@ -1557,6 +1562,9 @@ final class XMPPService {
LumaConnectionStatsModule()
)
_ = client.modulesManager.register(StreamManagementModule())
// 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())
_ = client.modulesManager.register(ResourceBinderModule())
_ = client.modulesManager.register(SessionEstablishmentModule())
@@ -1671,6 +1679,20 @@ final class XMPPService {
}
.store(in: &cancellables)
// When the server advertises SCRAM, swap the password in the
// connection configuration for its RFC 4013 (SASLprep) form before
// the challenge response is computed. Martin's SCRAM hashes the raw
// UTF-8 password, which mismatches SASLprep-compliant servers for
// passwords with non-ASCII spaces, soft hyphens, fullwidth forms, …
client.module(.streamFeatures).$streamFeatures
.compactMap { $0.element }
.receive(on: DispatchQueue.main)
.sink { [weak self, weak client] features in
guard let self, let client else { return }
self.applySASLprepIfNeeded(client: client, features: features)
}
.store(in: &cancellables)
client.module(.roster).events
.receive(on: DispatchQueue.main)
.sink { [weak self] action in
@@ -1858,7 +1880,7 @@ final class XMPPService {
startArchiveSyncIfAvailable()
case .disconnected(let reason):
suspendArchiveSyncForDisconnect()
eventHandler?(.connection(.disconnected(reason: Self.reasonText(reason))))
eventHandler?(.connection(.disconnected(reason: reasonText(reason))))
}
}
@@ -3717,11 +3739,46 @@ final class XMPPService {
#endif
}
private static func reasonText(_ reason: XMPPClient.State.DisconnectionReason) -> String? {
if case .none = reason {
private func reasonText(_ reason: XMPPClient.State.DisconnectionReason) -> String? {
switch reason {
case .none:
return nil
case .authenticationFailure(let error):
return SaslFailureMessage.describe(
error: error,
condition: saslFailureModule?.lastFailure?.condition,
serverText: saslFailureModule?.lastFailure?.text
)
default:
return reason.localizedDescription
}
return reason.localizedDescription
}
private func connectionFailureMessage(for error: Error) -> String {
if let reason = error as? XMPPClient.State.DisconnectionReason {
return reasonText(reason) ?? error.localizedDescription
}
return error.localizedDescription
}
private func applySASLprepIfNeeded(client: XMPPClient, features: Element) {
guard let activePassword, !activePassword.isEmpty else { return }
let mechanisms =
features
.findChild(name: "mechanisms", xmlns: "urn:ietf:params:xml:ns:xmpp-sasl")?
.children
.filter { $0.name == "mechanism" }
.compactMap { $0.value } ?? []
guard mechanisms.contains(where: { $0.hasPrefix("SCRAM-") }) else { return }
guard case .password(let currentPassword, _, _) = client.connectionConfiguration.credentials,
let prepared = try? SASLprep.prepare(activePassword),
prepared != currentPassword
else { return }
client.connectionConfiguration.credentials = .password(
password: prepared,
authenticationName: nil,
cache: nil
)
}
}