#11 fix SASL login failures with non-ASCII passwords
- 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:
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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user