Files
Luma/Sources/Shared/XMPP/SASLprep.swift
T
wt fc07855036
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
#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.
2026-08-29 01:26:03 +07:00

102 lines
4.3 KiB
Swift

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
}
}