serverinfoview
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

This commit is contained in:
wt committed 2026-09-03 12:42:20 +07:00
1 parent d4b4c92759
commit 1650145d4d
12 files changed
+551 -23

No files matched your search

@@ -0,0 +1,26 @@
import Foundation
import Martin
/// Observes the raw SASL `<challenge/>` 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 ?? "")
}
}
+110 -11
View File
@@ -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
}
@@ -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<ResetableScope>) {
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,
@@ -25,6 +25,11 @@ final class LumaChannelBindingStore: @unchecked Sendable {
private let lock = NSLock()
private var tlsState: LumaTLSState?
private var advertisedTypes: Set<String> = []
/// 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<String> {
lock.lock()
defer { lock.unlock() }
return advertisedTypes
}
}
/// Supplies Luma's OpenSSL-based TLS processor to Martin's legacy
+82 -5
View File
@@ -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 `<authentication/>` stream
/// feature, shown as a dedicated list on the server-information screen.
private var advertisedSASL2Mechanisms: [String] = []
private var cancellables: Set<AnyCancellable> = []
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 <challenge/> 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