tls sasl
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-02 11:46:11 +07:00
1 parent e3fcee3351
commit d4b4c92759
29 files changed
+2177 -61

No files matched your search

+27 -5
View File
@@ -1913,6 +1913,10 @@ final class AppModel: ObservableObject {
errorMessage = nil
}
func clearInformationalMessage() {
informationalMessage = nil
}
private func beginMediaSendActivity() -> UUID {
let token = mediaSendActivity.begin()
isSendingAttachment = mediaSendActivity.isActive
@@ -3100,14 +3104,32 @@ final class AppModel: ObservableObject {
cursor: lastSuccessfulMAMCursor
)
}
// One-time cleanup of phantom «Не удалось расшифровать сообщение»
// placeholders: outgoing echoes the client could not decrypt back
// (broken encrypt-to-self sessions) used to be rendered as
// undecryptable bubbles. XMPPService now drops those echoes, so purge
// the leftovers from the local store instead of keeping the noise.
let phantomPlaceholderIDs = Set(
snapshot.messages
.filter { $0.direction == .outgoing && $0.security == .decryptionFailed }
.map(\.clientID)
)
for message in snapshot.messages
where phantomPlaceholderIDs.contains(message.clientID) {
modelContext.delete(message)
}
messages = snapshot.messages.filter {
!locallyDeletedMessageIDs.contains(
Self.localDeletionKey(
messageID: $0.clientID,
conversationID: $0.conversationID
))
!phantomPlaceholderIDs.contains($0.clientID)
&& !locallyDeletedMessageIDs.contains(
Self.localDeletionKey(
messageID: $0.clientID,
conversationID: $0.conversationID
))
}
rebuildMessageIndex()
if !phantomPlaceholderIDs.isEmpty {
schedulePersist()
}
let avatarJIDs = Set(snapshot.conversations.filter { !$0.isGroup }.map(\.jid) + [jid])
for avatarJID in avatarJIDs {
if let data = await avatarCache.data(for: avatarJID) {
+7 -1
View File
@@ -14,8 +14,14 @@ struct OMEMODevice: Identifiable, Hashable, Sendable {
let trust: Trust
let isActive: Bool
let isOwn: Bool
let isOMEMO2: Bool
var id: String { "\(jid)|\(deviceID)" }
var id: String { "\(jid)|\(deviceID)|\(isOMEMO2 ? "omemo2" : "legacy")" }
/// Short protocol label for the encryption screen badge.
var protocolName: String {
isOMEMO2 ? "OMEMO 2" : "OMEMO"
}
var formattedFingerprint: String {
fingerprint.chunked(every: 8).joined(separator: " ")
@@ -0,0 +1,45 @@
import Foundation
/// Order in which Luma prefers SASL mechanisms during authentication.
/// Channel-binding SCRAM first (tls-exporter / tls-server-end-point prove
/// the exchange runs over the client's own TLS channel, defeating MITM
/// relays), then the strongest plain SCRAM variant, and PLAIN as the
/// last-resort fallback. The server must advertise a mechanism for it to be
/// eligible — Martin's `SaslModule.guessSaslMechanism` walks exactly this
/// order, and the PLUS mechanisms additionally gate on available binding
/// data in `isAllowedToUse`.
enum SASLMechanismPreference {
static let order: [String] = [
"SCRAM-SHA-512-PLUS",
"SCRAM-SHA-256-PLUS",
"SCRAM-SHA-1-PLUS",
"SCRAM-SHA-512",
"SCRAM-SHA-256",
"SCRAM-SHA-1",
"PLAIN",
]
/// The most secure mechanism from `order` that the server advertises,
/// or nil when no password-based mechanism is offered. When
/// `allowsChannelBinding` is false the -PLUS variants are skipped, since
/// without binding data the exchange would fail.
static func selected(
among offered: [String],
allowsChannelBinding: Bool = true
) -> String? {
order.first { candidate in
guard offered.contains(candidate) else { return false }
return allowsChannelBinding || !candidate.hasSuffix("-PLUS")
}
}
/// True when `mechanism` is the strongest one available among
/// `offered`: the server offers nothing better than the chosen one.
static func isStrongestAvailable(_ mechanism: String, among offered: [String]) -> Bool {
guard let chosenRank = order.firstIndex(of: mechanism) else { return false }
for stronger in order[..<chosenRank] where offered.contains(stronger) {
return false
}
return offered.contains(mechanism)
}
}
@@ -72,6 +72,14 @@ struct ServerInformation: Equatable, Sendable {
let externalServices: [ExternalService]
let connectionStats: ConnectionStats
let saslMethods: [String]
/// TLS version negotiated with the server during this connection
/// (probed from a parallel handshake when the library does not expose it).
let tlsVersion: String?
/// Cipher suite negotiated alongside the TLS version.
let tlsCipher: String?
/// SASL mechanism actually selected for authentication (the strongest
/// one the server advertises).
let saslMechanism: String?
let supportsStreamManagement: Bool
let supportsCarbons: Bool
let supportsClientState: Bool
@@ -0,0 +1,247 @@
import Foundation
import UniformTypeIdentifiers
#if os(iOS)
import Photos
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// Saves a message's cached media to the right destination: photos, videos and
/// video notes go to the Photos library on iOS (or Downloads on macOS, which
/// would otherwise need a Photo Library entitlement), while files, audio and
/// voice messages go to the user's Downloads location. Kept dependency-free so
/// every bubble, the full-screen viewer and the audio player share one path.
@MainActor
enum MediaDownloadService {
enum SaveError: LocalizedError {
case missingLocalFile
case unsupportedKind
var errorDescription: String? {
switch self {
case .missingLocalFile:
return "Не удалось загрузить файл для сохранения."
case .unsupportedKind:
return "Этот тип сообщения нельзя сохранить."
}
}
}
// MARK: - Entry points
/// Resolve the cached copy for `message` (downloading through AppModel when
/// needed) and save it. Failures are surfaced through AppModel's error alert.
static func save(_ message: ChatMessage, model: AppModel) async {
do {
guard let sourceURL = await resolvedLocalURL(for: message, model: model) else {
throw SaveError.missingLocalFile
}
let filename = message.localFilename ?? Self.defaultFilename(for: message)
let didSave = try await save(
url: sourceURL,
kind: message.kind,
filename: filename
)
if didSave {
model.informationalMessage = "Сохранено."
}
} catch {
model.errorMessage = error.localizedDescription
}
}
/// Save an already-downloaded file, routing by kind and platform. Returns
/// `false` when the user cancelled an interactive save sheet.
@discardableResult
static func save(
url: URL,
kind: ChatMessage.Kind,
filename: String
) async throws -> Bool {
switch kind {
case .photo, .video, .videoNote:
#if os(iOS)
try await saveToPhotos(url: url, kind: kind)
return true
#else
return try await saveToDownloads(url: url, filename: filename)
#endif
case .attachment, .audio, .voice:
return try await saveToDownloads(url: url, filename: filename)
case .text, .location, .system:
throw SaveError.unsupportedKind
}
}
// MARK: - Local file resolution
private static func resolvedLocalURL(
for message: ChatMessage,
model: AppModel
) async -> URL? {
if let cached = model.mediaPreviewURL(for: message) {
return cached
}
// Inline media (photo/video/audio/voice/video note) is fetched by
// AppModel into its preview cache; files are not auto-previewed and fall
// through to the QuickLook handoff below.
await model.prepareMediaPreview(message)
if let cached = model.mediaPreviewURL(for: message) {
return cached
}
// Attachments are only fetched on demand via previewAttachment(_:),
// which parks the result in the single-shot previewURL property. Clear it
// before and after so the QuickLook sheet does not flash open.
model.previewURL = nil
await model.previewAttachment(message)
let url = model.previewURL
model.previewURL = nil
return url
}
// MARK: - Photos
#if os(iOS)
private static func saveToPhotos(url: URL, kind: ChatMessage.Kind) async throws {
try await PHPhotoLibrary.shared().performChanges {
if kind == .photo {
_ = PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: url)
} else {
_ = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url)
}
}
}
#endif
// MARK: - Downloads
private static func saveToDownloads(url: URL, filename: String) async throws -> Bool {
#if os(macOS)
return try await saveWithPanel(url: url, filename: filename)
#else
return await FileExportPresenter.shared.present(url: url)
#endif
}
#if os(macOS)
/// Present a save panel defaulting to ~/Downloads. The app sandbox grants
/// write access to the location the user picks, so no Downloads entitlement
/// is required.
private static func saveWithPanel(url: URL, filename: String) async throws -> Bool {
let panel = NSSavePanel()
panel.nameFieldStringValue = filename
panel.canCreateDirectories = true
if let downloads = FileManager.default.urls(
for: .downloadsDirectory,
in: .userDomainMask
).first {
panel.directoryURL = downloads
}
guard panel.runModal() == .OK, let destination = panel.url else {
return false
}
try FileManager.default.copyItem(at: url, to: destination)
return true
}
#endif
// MARK: - Helpers
private static func defaultFilename(for message: ChatMessage) -> String {
let ext = Self.fileExtension(for: message)
let base: String
switch message.kind {
case .photo: base = "Фото"
case .video: base = "Видео"
case .videoNote: base = "Видеосообщение"
case .voice: base = "Голосовое сообщение"
case .audio: base = "Аудио"
case .attachment: base = "Вложение"
default: base = "Файл"
}
return ext.isEmpty ? base : "\(base).\(ext)"
}
private static func fileExtension(for message: ChatMessage) -> String {
if let filename = message.localFilename {
let ext = (filename as NSString).pathExtension
if !ext.isEmpty { return ext }
}
if let mime = message.mimeType,
let type = UTType(mimeType: mime),
let ext = type.preferredFilenameExtension
{
return ext
}
switch message.kind {
case .video, .videoNote: return "mp4"
case .voice, .audio: return "m4a"
case .photo: return "jpg"
default: return ""
}
}
}
#if os(iOS)
/// Presents the system "save to Files" sheet for a cached file and reports
/// whether the user completed the save or cancelled. Held alive by the
/// continuation until the delegate fires.
@MainActor
private final class FileExportPresenter: NSObject, UIDocumentPickerDelegate {
static let shared = FileExportPresenter()
private var continuation: CheckedContinuation<Bool, Never>?
func present(url: URL) async -> Bool {
await withCheckedContinuation { continuation in
self.continuation = continuation
let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true)
picker.delegate = self
Task { @MainActor in
// Let a just-dismissed context menu finish its transition before
// presenting, otherwise the sheet can be rejected.
try? await Task.sleep(nanoseconds: 350_000_000)
guard let presenter = Self.topPresenter(),
presenter.presentedViewController == nil
else {
self.finish(false)
return
}
presenter.present(picker, animated: true)
}
}
}
func documentPicker(
_ controller: UIDocumentPickerViewController,
didPickDocumentsAt urls: [URL]
) {
finish(true)
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
finish(false)
}
private func finish(_ result: Bool) {
continuation?.resume(returning: result)
continuation = nil
}
private static func topPresenter() -> UIViewController? {
guard let scene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive }),
let window = scene.windows.first(where: { $0.isKeyWindow }),
let root = window.rootViewController
else { return nil }
var top = root
while let presented = top.presentedViewController {
top = presented
}
return top
}
}
#endif
@@ -53,6 +53,16 @@ struct AudioMessagePlayer: View {
.font(.caption2.monospacedDigit())
.foregroundStyle(secondaryText)
Spacer()
Button(action: saveToFiles) {
Image(systemName: "square.and.arrow.down")
.font(.caption.weight(.bold))
.foregroundStyle(accent)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(accent.opacity(0.14), in: Capsule())
}
.buttonStyle(.plain)
.accessibilityLabel("Сохранить в Файлы")
Button(action: playback.cycleVoiceRate) {
Text(rateText)
.font(.caption2.monospacedDigit().weight(.bold))
@@ -95,6 +105,13 @@ struct AudioMessagePlayer: View {
countStyle: .file
))
}
Spacer()
Button(action: saveToFiles) {
Image(systemName: "square.and.arrow.down")
.foregroundStyle(accent)
}
.buttonStyle(.plain)
.accessibilityLabel("Сохранить в Файлы")
}
.font(.caption2.monospacedDigit())
.foregroundStyle(secondaryText)
@@ -185,6 +202,10 @@ struct AudioMessagePlayer: View {
message.direction == .outgoing ? .white.opacity(0.78) : .secondary
}
private func saveToFiles() {
Task { await MediaDownloadService.save(message, model: model) }
}
private func togglePlayback() {
guard let localURL else {
Task {
@@ -45,6 +45,7 @@ struct MediaViewer: View {
@State private var isTrackingDismissGesture = false
@State private var isCompletingDismissal = false
@State private var isPhotoZoomed = false
@State private var saveErrorMessage: String?
var body: some View {
GeometryReader { geometry in
@@ -65,6 +66,16 @@ struct MediaViewer: View {
.lineLimit(1)
Spacer()
Button(action: save) {
Image(systemName: "square.and.arrow.down")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(.white.opacity(0.16), in: Circle())
}
.buttonStyle(.plain)
.accessibilityLabel("Сохранить")
}
.padding(.horizontal, 16)
.padding(.top, 12)
@@ -96,6 +107,11 @@ struct MediaViewer: View {
.accessibilityAction(.escape) {
closeImmediately()
}
.alert("Ошибка сохранения", isPresented: saveErrorBinding) {
Button("OK", role: .cancel) {}
} message: {
Text(saveErrorMessage ?? "Не удалось сохранить медиа.")
}
}
#if os(macOS)
.onExitCommand {
@@ -183,6 +199,27 @@ struct MediaViewer: View {
onClose()
}
private var saveErrorBinding: Binding<Bool> {
Binding(
get: { saveErrorMessage != nil },
set: { visible in if !visible { saveErrorMessage = nil } }
)
}
private func save() {
Task {
do {
try await MediaDownloadService.save(
url: item.url,
kind: item.kind,
filename: item.title
)
} catch {
saveErrorMessage = error.localizedDescription
}
}
}
private func dismissalProgress(in containerHeight: CGFloat) -> CGFloat {
min(1, abs(dragOffset) / max(1, containerHeight * 0.62))
}
@@ -81,6 +81,13 @@ struct MessageBubble: View {
Label("Копировать текст", systemImage: "doc.on.doc")
}
}
if message.kind.isMedia, !message.isRetracted {
Button {
Task { await MediaDownloadService.save(message, model: model) }
} label: {
Label("Сохранить", systemImage: "square.and.arrow.down")
}
}
if model.canRetryMediaMessage(message) {
Button(action: onRetry) {
Label("Повторить отправку", systemImage: "arrow.clockwise")
@@ -98,6 +98,15 @@ private struct DeviceRow: View {
systemImage: device.isActive ? "iphone" : "iphone.slash"
)
.font(.headline)
Text(device.protocolName)
.font(.caption2.weight(.medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.foregroundStyle(device.isOMEMO2 ? Color.indigo : Color.secondary)
.background(
(device.isOMEMO2 ? Color.indigo : Color.secondary).opacity(0.14),
in: Capsule()
)
Spacer()
Button(action: toggleVerification) {
Label(trustTitle, systemImage: trustIcon)
+39
View File
@@ -4,6 +4,7 @@ import SwiftUI
struct RootView: View {
@ObservedObject var model: AppModel
@State private var toastDismissTask: Task<Void, Never>?
var body: some View {
ZStack {
@@ -32,17 +33,55 @@ struct RootView: View {
.transition(.opacity.combined(with: .scale(scale: 1.015)))
.zIndex(200)
}
if let info = model.informationalMessage {
VStack {
toast(info)
Spacer()
}
.transition(.move(edge: .top).combined(with: .opacity))
.zIndex(300)
}
}
}
.animation(.easeInOut(duration: 0.22), value: model.account?.id)
.animation(.easeInOut(duration: 0.2), value: model.mediaViewerItem?.id)
.animation(.easeInOut(duration: 0.2), value: model.activeCall?.id)
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: model.informationalMessage)
.alert("Luma", isPresented: errorBinding) {
Button("OK") { model.clearError() }
} message: {
Text(model.errorMessage ?? "Неизвестная ошибка")
}
.quickLookPreview($model.previewURL)
.onChange(of: model.informationalMessage) { _, newValue in
guard newValue != nil else { return }
toastDismissTask?.cancel()
toastDismissTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 2_500_000_000)
guard !Task.isCancelled else { return }
model.clearInformationalMessage()
}
}
}
/// Compact Telegram-style confirmation toast shown for a couple of
/// seconds above every layer (lists, media viewer, calls).
private func toast(_ message: String) -> some View {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.accentColor)
Text(message)
.font(.subheadline.weight(.medium))
.lineLimit(2)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.regularMaterial, in: Capsule())
.overlay(Capsule().strokeBorder(Color.primary.opacity(0.06)))
.shadow(color: .black.opacity(0.18), radius: 12, y: 4)
.padding(.top, 8)
.accessibilityIdentifier("info-toast")
}
private var errorBinding: Binding<Bool> {
+22 -3
View File
@@ -55,8 +55,14 @@ struct ServerInfoView: View {
Section("Это программное обеспечение, работающее на вашем сервере.") {
softwareEntry(info)
}
Section("Параметры подключения к вашему серверу.") {
connectionRows()
Section {
connectionRows(info)
} header: {
Text("Параметры подключения к вашему серверу.")
} footer: {
Text(
"Luma автоматически выбирает самый безопасный вариант: при TLS договаривается о максимальной версии (1.3 > 1.2), а при входе — о самом стойком SASL-механизме, который поддерживает сервер."
)
}
Section("Современные возможности XMPP, обнаруженные на вашем сервере после входа.") {
ForEach(info.capabilities) { capability in
@@ -123,7 +129,7 @@ struct ServerInfoView: View {
.listRowBackground(Color.gray.opacity(0.16))
}
private func connectionRows() -> some View {
private func connectionRows(_ info: ServerInformation) -> some View {
Group {
if let account = model.account {
if let host = account.manualHost {
@@ -138,6 +144,19 @@ struct ServerInfoView: View {
}
LabeledContent("Ресурс", value: account.effectiveResource)
}
if let tlsVersion = info.tlsVersion {
LabeledContent(
"Версия TLS",
value: info.tlsCipher.map { "\(tlsVersion) · \($0)" } ?? tlsVersion
)
} else {
LabeledContent("Версия TLS", value: "Неизвестно")
}
if let saslMechanism = info.saslMechanism {
LabeledContent("SASL-механизм", value: saslMechanism)
} else {
LabeledContent("SASL-механизм", value: "Неизвестно")
}
}
}
+34 -2
View File
@@ -28,7 +28,7 @@ final class LumaOMEMO2Module: AbstractPEPModule, XmppModule {
public static let XMLNS = "urn:xmpp:omemo:2"
public static let DEVICES_LIST_NODE = "urn:xmpp:omemo:2:devices"
public static let BUNDLES_NODE = "urn:xmpp:omemo:2:bundles"
public static let SCE_XMLNS = "urn:xmpp:sce:1"
public static let SCE_XMLNS = "urn:xmpp:sce:0"
public static let HKDF_INFO = "OMEMO Payload"
public let id: String = ID
@@ -75,6 +75,35 @@ final class LumaOMEMO2Module: AbstractPEPModule, XmppModule {
return known.filter { !failed.contains($0) }
}
/// Returns the cached OMEMO 2 device list for `jid`, fetching it from
/// PEP on demand when `fetching` is true and nothing is cached yet. Used
/// by the send path so the first message to an OMEMO 2-only contact does
/// not fall back to the legacy protocol.
public func deviceIDs(for jid: BareJID, fetching: Bool) async -> [Int32]? {
if let known = devices(for: jid) { return known }
guard fetching, let context else { return nil }
return await withCheckedContinuation { continuation in
context.module(.pubsub).retrieveItems(
from: jid,
for: Self.DEVICES_LIST_NODE,
limit: .lastItems(1)
) { [weak self] result in
guard let self,
case .success(let items) = result,
let listEl = items.items.first?.payload,
listEl.name == "devices",
listEl.xmlns == Self.XMLNS
else {
continuation.resume(returning: nil)
return
}
let known = listEl.mapChildren(transform: { $0.getAttribute("id").flatMap(Int32.init) })
self.devicesQueue.async { self.devices[jid] = known }
continuation.resume(returning: known)
}
}
}
public func isAvailable(for jid: BareJID) -> Bool {
!(devicesQueue.sync { devices[jid] }?.isEmpty ?? true)
|| !storage.sessionStore.allDevices(for: jid.stringValue, activeAndTrusted: true).isEmpty
@@ -166,6 +195,9 @@ final class LumaOMEMO2Module: AbstractPEPModule, XmppModule {
// Track known devices and align identity states with the list.
let known = list.mapChildren(transform: { $0.getAttribute("id").flatMap(Int32.init) })
devicesQueue.async { [weak self] in self?.devices[jid] = known }
// Record which device IDs are OMEMO 2 so the encryption screen can
// badge them; legacy-only identities stay unmarked.
(storage as? LumaOMEMOStore)?.setOMEMO2DeviceIDs(Set(known), for: jid.stringValue)
let active = storage.sessionStore.allDevices(for: jid.stringValue, activeAndTrusted: true)
active.filter { !known.contains($0) }.forEach {
_ = storage.identityKeyStore.setStatus(active: false, forIdentity: SignalAddress(name: jid.stringValue, deviceId: $0))
@@ -780,7 +812,7 @@ final class LumaOMEMO2Module: AbstractPEPModule, XmppModule {
let alphabet = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
let rpadLength = Int.random(in: 12...96)
let rpad = String((0..<rpadLength).map { _ in alphabet.randomElement()! })
var result = "<envelope xmlns='urn:xmpp:sce:1'><content>" + content + "</content>"
var result = "<envelope xmlns='" + Self.SCE_XMLNS + "'><content>" + content + "</content>"
result += "<rpad>" + rpad + "</rpad>"
result += "<from jid='" + escapeXML(fromJID) + "'/>"
if let toJID {
+26 -3
View File
@@ -86,19 +86,38 @@ final class LumaOMEMOStore: SignalStorage {
}
func devices(for jid: String) -> [OMEMODevice] {
identityAdapter.identities(forName: jid.lowercased()).map { identity in
let key = jid.lowercased()
let published: Set<Int32> = Set(repository.read { $0.omemo2DeviceIDs?[key] ?? [] })
return identityAdapter.identities(forName: key).map { identity in
OMEMODevice(
jid: identity.address.name,
deviceID: identity.address.deviceId,
fingerprint: identity.fingerprint,
trust: Self.mapTrust(identity.status),
isActive: identity.status.isActive,
isOwn: identity.own
isOwn: identity.own,
isOMEMO2: published.contains(identity.address.deviceId)
)
}
.sorted { lhs, rhs in lhs.deviceID < rhs.deviceID }
}
/// Records the set of device IDs a JID currently publishes under
/// `urn:xmpp:omemo:2`. Drives the per-device protocol badge on the
/// encryption screen and persists so the label survives a relaunch.
func setOMEMO2DeviceIDs(_ deviceIDs: Set<Int32>, for jid: String) {
let key = jid.lowercased()
repository.mutate { state in
var all = state.omemo2DeviceIDs ?? [:]
if deviceIDs.isEmpty {
all.removeValue(forKey: key)
} else {
all[key] = deviceIDs.sorted()
}
state.omemo2DeviceIDs = all
}
}
func hasSession(for address: SignalAddress) -> Bool {
sessionAdapter.containsSessionRecord(forAddress: address)
}
@@ -573,6 +592,9 @@ private struct StoredOMEMOState: Codable {
var signedPreKeys: [String: Data]
var sessions: [String: Data]
var senderKeys: [String: Data]
// Lowercased JID -> device IDs currently published under urn:xmpp:omemo:2.
// Optional for backward compatibility with state written before OMEMO 2.
var omemo2DeviceIDs: [String: [Int32]]?
static let empty = StoredOMEMOState(
registrationID: 0,
@@ -581,7 +603,8 @@ private struct StoredOMEMOState: Codable {
preKeys: [:],
signedPreKeys: [:],
sessions: [:],
senderKeys: [:]
senderKeys: [:],
omemo2DeviceIDs: nil
)
}
@@ -0,0 +1,271 @@
import CommonCrypto
import Foundation
import Martin
/// Digest-parameterized SCRAM math (RFC 5802) for the channel-binding
/// PLUS variants. The existing SCRAMSHA512 helper stays SHA-512-only for
/// its reference-vector tests; this core serves SHA-1/256/512 PLUS.
enum SCRAMHash {
case sha1
case sha256
case sha512
var mechanismName: String {
switch self {
case .sha1: return "SCRAM-SHA-1"
case .sha256: return "SCRAM-SHA-256"
case .sha512: return "SCRAM-SHA-512"
}
}
var digestLength: Int {
switch self {
case .sha1: return Int(CC_SHA1_DIGEST_LENGTH)
case .sha256: return Int(CC_SHA256_DIGEST_LENGTH)
case .sha512: return Int(CC_SHA512_DIGEST_LENGTH)
}
}
private var hmacAlgorithm: CCHmacAlgorithm {
switch self {
case .sha1: return CCHmacAlgorithm(kCCHmacAlgSHA1)
case .sha256: return CCHmacAlgorithm(kCCHmacAlgSHA256)
case .sha512: return CCHmacAlgorithm(kCCHmacAlgSHA512)
}
}
private var pbkdfAlgorithm: CCPseudoRandomAlgorithm {
switch self {
case .sha1: return CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1)
case .sha256: return CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256)
case .sha512: return CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA512)
}
}
func hmac(key: Data, data: Data) -> Data {
var output = [UInt8](repeating: 0, count: digestLength)
key.withUnsafeBytes { keyBytes in
data.withUnsafeBytes { dataBytes in
CCHmac(
hmacAlgorithm,
keyBytes.baseAddress,
keyBytes.count,
dataBytes.baseAddress,
dataBytes.count,
&output
)
}
}
return Data(output)
}
func hash(data: Data) -> Data {
var output = [UInt8](repeating: 0, count: digestLength)
data.withUnsafeBytes { bytes in
switch self {
case .sha1:
CC_SHA1(bytes.baseAddress, CC_LONG(bytes.count), &output)
case .sha256:
CC_SHA256(bytes.baseAddress, CC_LONG(bytes.count), &output)
case .sha512:
CC_SHA512(bytes.baseAddress, CC_LONG(bytes.count), &output)
}
}
return Data(output)
}
/// PBKDF2 (RFC 5802 Hi function).
func saltedPassword(password: String, salt: Data, iterations: Int) -> Data {
let passwordBytes = Array(password.utf8)
var output = [UInt8](repeating: 0, count: digestLength)
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
passwordBytes,
passwordBytes.count,
[UInt8](salt),
salt.count,
pbkdfAlgorithm,
UInt32(iterations),
&output,
output.count
)
return Data(output)
}
}
/// 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
/// so unit tests can check it against reference vectors.
struct SCRAMPlusExchange {
let hash: SCRAMHash
let username: String
let password: String
let channelBindingType: String
let channelBindingData: Data
let clientNonce: String
var gs2Header: String { "p=\(channelBindingType),," }
var clientFirstMessageBare: String { "n=\(username),r=\(clientNonce)" }
var clientFirstMessage: String { gs2Header + clientFirstMessageBare }
/// gs2-header || channel-binding-data (RFC 5802 §7).
private var channelBindingInput: Data {
(gs2Header.data(using: .utf8) ?? Data()) + channelBindingData
}
func clientFinalMessage(serverFirst: String) throws -> String {
let parsed = try SCRAMSHA512.parseServerFirst(
serverFirst,
expectedNoncePrefix: clientNonce
)
let saltedPassword = hash.saltedPassword(
password: password,
salt: parsed.salt,
iterations: parsed.iterations
)
let finalWithoutProof =
"c=\(channelBindingInput.base64EncodedString()),r=\(parsed.nonce)"
let authMessage = authMessageString(serverFirst: serverFirst, parsed: parsed)
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))
let proof = Data(zip(clientKey, signature).map { $0 ^ $1 })
return finalWithoutProof + ",p=" + proof.base64EncodedString()
}
func expectedServerSignature(serverFirst: String) throws -> Data {
let parsed = try SCRAMSHA512.parseServerFirst(
serverFirst,
expectedNoncePrefix: clientNonce
)
let saltedPassword = hash.saltedPassword(
password: password,
salt: parsed.salt,
iterations: parsed.iterations
)
let authMessage = authMessageString(serverFirst: serverFirst, parsed: parsed)
let serverKey = hash.hmac(key: saltedPassword, data: Data("Server Key".utf8))
return hash.hmac(key: serverKey, data: Data(authMessage.utf8))
}
private func authMessageString(
serverFirst: String,
parsed: SCRAMSHA512.ServerFirst
) -> String {
let finalWithoutProof =
"c=\(channelBindingInput.base64EncodedString()),r=\(parsed.nonce)"
return clientFirstMessageBare + "," + serverFirst + "," + finalWithoutProof
}
/// The base64 value of the server's final `v=` attribute, or nil when
/// the message is an `e=` error instead.
static func verifierValue(in finalMessage: String) -> String? {
guard finalMessage.hasPrefix("v=") else { return nil }
let value = finalMessage.dropFirst(2).split(separator: ",", maxSplits: 1).first ?? Substring()
guard !value.isEmpty else { return nil }
return String(value)
}
}
/// SCRAM with channel binding (RFC 5802 / RFC 9266, XEP-0474): the
/// tls-exporter or tls-server-end-point binding proves the SASL exchange
/// runs over the same TLS channel the client sees, defeating MITM relays.
/// Registered ahead of the plain SCRAM variants when binding data exists.
final class LumaScramPlusMechanism: SaslMechanism {
let name: String
private(set) var status: SaslMechanismStatus = .new
private let hash: SCRAMHash
private let channelBindingStore: LumaChannelBindingStore
private var stage = 0
private var exchange: SCRAMPlusExchange?
private var serverFirst: String?
init(hash: SCRAMHash, channelBindingStore: LumaChannelBindingStore) {
self.hash = hash
self.channelBindingStore = channelBindingStore
name = hash.mechanismName + "-PLUS"
}
func reset(scopes: Set<ResetableScope>) {
guard scopes.contains(.stream) else { return }
status = .new
stage = 0
exchange = nil
serverFirst = nil
}
func isAllowedToUse(_ context: Context) -> Bool {
guard case .password(_, _, _) = context.connectionConfiguration.credentials else {
return false
}
return channelBindingStore.canUseChannelBinding
}
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(let password, _, _) = context.connectionConfiguration.credentials,
let bindingType = channelBindingStore.preferredChannelBindingType,
let bindingData = channelBindingStore.channelBindingData(for: bindingType)
else {
throw ClientSaslException.genericError(
msg: "Channel binding unavailable")
}
exchange = SCRAMPlusExchange(
hash: hash,
username: context.userBareJid.localPart ?? "",
password: password,
channelBindingType: bindingType,
channelBindingData: bindingData,
clientNonce: SCRAMSHA512.makeNonce()
)
stage = 1
status = .completedExpected
return exchange?.clientFirstMessage.data(using: .utf8)?.base64EncodedString()
case 1:
guard let exchange,
let input,
let data = Data(base64Encoded: input),
let decodedServerFirst = String(data: data, encoding: .utf8)
else {
throw ClientSaslException.badChallenge(msg: "Invalid challenge")
}
serverFirst = decodedServerFirst
let final = try exchange.clientFinalMessage(serverFirst: decodedServerFirst)
stage = 2
return final.data(using: .utf8)?.base64EncodedString()
case 2:
guard let exchange, let serverFirst,
let input,
let data = Data(base64Encoded: input),
let finalMessage = String(data: data, encoding: .utf8),
let verifier = SCRAMPlusExchange.verifierValue(in: finalMessage),
let value = Data(base64Encoded: verifier)
else {
throw ClientSaslException.badChallenge(msg: "Invalid final challenge")
}
let expected = try exchange.expectedServerSignature(serverFirst: serverFirst)
guard value == expected else {
throw ClientSaslException.invalidServerSignature
}
status = .completed
return nil
default:
throw ClientSaslException.genericError(msg: "Invalid SCRAM stage")
}
}
}
@@ -0,0 +1,443 @@
import CryptoKit
import Foundation
import Martin
import OpenSSL
import Security
/// Negotiated TLS parameters of the live connection, captured by the TLS
/// processor and shared with the SASL layer (SCRAM channel binding) and the
/// server-information screen.
struct LumaTLSState: Sendable {
/// "TLSv1.3" / "TLSv1.2" — OpenSSL's SSL_get_version string.
let version: String
/// OpenSSL cipher-suite name, e.g. TLS_AES_256_GCM_SHA384.
let cipher: String
/// DER of the leaf certificate.
let leafCertificateDER: Data
/// RFC 9266 tls-exporter output (32 bytes), when the handshake version
/// supports exporters (TLS 1.3).
let exporter: Data?
}
/// Thread-safe holder of the negotiated TLS state plus the channel-binding
/// types the server advertised in its stream features.
final class LumaChannelBindingStore: @unchecked Sendable {
private let lock = NSLock()
private var tlsState: LumaTLSState?
private var advertisedTypes: Set<String> = []
func setTLSState(_ state: LumaTLSState) {
lock.lock()
tlsState = state
lock.unlock()
}
func setAdvertisedChannelBindingTypes(_ types: Set<String>) {
lock.lock()
advertisedTypes = types
lock.unlock()
}
func reset() {
lock.lock()
tlsState = nil
advertisedTypes = []
lock.unlock()
}
var negotiatedState: LumaTLSState? {
lock.lock()
defer { lock.unlock() }
return tlsState
}
/// The strongest channel-binding type both sides support: RFC 9266
/// tls-exporter first, then tls-server-end-point.
var preferredChannelBindingType: String? {
lock.lock()
defer { lock.unlock() }
guard let tlsState else { return nil }
if advertisedTypes.contains("tls-exporter"), tlsState.exporter != nil {
return "tls-exporter"
}
if advertisedTypes.contains("tls-server-end-point") {
return "tls-server-end-point"
}
return nil
}
/// Channel-binding data for a concrete type: RFC 9266 §4.2 for
/// tls-exporter and §4.3 for tls-server-end-point (SHA-256 of the leaf
/// certificate; RFC 5929's upgrade rules make this correct for the
/// SHA-256-signed certificates used in practice on TLS 1.2 as well).
func channelBindingData(for type: String) -> Data? {
lock.lock()
defer { lock.unlock() }
guard let tlsState else { return nil }
switch type {
case "tls-exporter":
return tlsState.exporter
case "tls-server-end-point":
return Data(SHA256.hash(data: tlsState.leafCertificateDER))
default:
return nil
}
}
var canUseChannelBinding: Bool {
preferredChannelBindingType != nil
}
}
/// Supplies Luma's OpenSSL-based TLS processor to Martin's legacy
/// SocketConnector. SecureTransport caps stream TLS at 1.2 and has no keying
/// exporter, so Luma runs its own TLS 1.3 stack (like Monal) while Martin
/// keeps owning the socket and the XMPP framing.
final class LumaTLSNetworkProcessorProvider: NetworkProcessorProvider {
var providedFeatures: [ConnectorFeature] = [.TLS]
private let channelBindingStore: LumaChannelBindingStore
init(channelBindingStore: LumaChannelBindingStore) {
self.channelBindingStore = channelBindingStore
}
func supply() -> SocketConnector.NetworkProcessor {
LumaTLSNetworkProcessor(channelBindingStore: channelBindingStore)
}
}
/// OpenSSL TLS 1.3 client processor driven by Martin's byte stream:
/// encrypted socket bytes go in through read(data:), plaintext goes out to
/// the connector; plaintext from the connector is encrypted and handed to
/// the socket through the write delegate.
final class LumaTLSNetworkProcessor: SocketConnector.NetworkProcessor, SSLNetworkProcessor {
var serverName: String?
var certificateValidation: SSLCertificateValidation = .default
var certificateValidationFailed: ((SecTrust?) -> Void)?
private let channelBindingStore: LumaChannelBindingStore
private var alpnProtocols: [String] = []
private var sslContext: OpaquePointer?
private var ssl: OpaquePointer?
/// C-string copy of the SNI hostname; OpenSSL keeps the pointer, so the
/// storage must outlive the SSL object.
private var storedServerName: [CChar]?
private var readBIO: OpaquePointer?
private var writeBIO: OpaquePointer?
private var handshakeCompleted = false
private var handshakeFailed = false
private var pendingPlaintext: [UInt8] = []
init(channelBindingStore: LumaChannelBindingStore) {
self.channelBindingStore = channelBindingStore
super.init()
}
deinit {
SSL_free(ssl)
SSL_CTX_free(sslContext)
}
func setALPNProtocols(_ protocols: [String]) {
alpnProtocols = protocols
}
// MARK: - Byte stream
/// Encrypted bytes from the socket.
override func read(data: Data) {
guard !handshakeFailed else { return }
ensureTLS()
guard let ssl, let readBIO else { return }
data.withUnsafeBytes { raw in
_ = BIO_write(readBIO, raw.baseAddress, Int32(raw.count))
}
drive()
}
/// Plaintext to encrypt and send to the socket.
override func write(data: Data, completion: WriteCompletion) {
guard !handshakeFailed else {
completion.completed(result: .failure(LumaTLSProcessorError.handshakeFailed))
return
}
pendingPlaintext.append(contentsOf: data)
ensureTLS()
drive()
completion.completed(result: .success(()))
}
// MARK: - TLS lifecycle
private func ensureTLS() {
guard ssl == nil, !handshakeFailed else { return }
guard let context = SSL_CTX_new(TLS_client_method()) else {
handshakeFailed = true
return
}
sslContext = context
// SSL_CTX_set_min/max_proto_version are C macros, so the Swift
// importer drops them. Drive the underlying control instead; the
// values come from <openssl/tls1.h> (TLS1_2_VERSION 0x0303,
// TLS1_3_VERSION 0x0304) and <openssl/ssl.h>
// (SSL_CTRL_SET_MIN_PROTO_VERSION 123, SSL_CTRL_SET_MAX_PROTO_VERSION
// 124). This pins the connection to TLS 1.2–1.3.
_ = SSL_CTX_ctrl(context, 123, 0x0303, nil)
_ = SSL_CTX_ctrl(context, 124, 0x0304, nil)
// The peer must present a certificate, but OpenSSL's own chain
// verification is disabled: it has no CA store and would reject the
// server mid-handshake with an "unknown ca" alert. Actual trust
// evaluation is performed by Luma after the handshake (SecTrust +
// configured validation), mirroring Martin's custom-validator flow.
SSL_CTX_set_verify(context, SSL_VERIFY_PEER, { _, _ in 1 })
guard let connection = SSL_new(context) else {
handshakeFailed = true
return
}
// This OpenSSL build does not pre-select the connection role in
// SSL_new; without it SSL_do_handshake fails with
// "connection type not set".
SSL_set_connect_state(connection)
ssl = connection
if let serverName {
// SSL_set_tlsext_host_name is a C macro; use SSL_ctrl with
// SSL_CTRL_SET_TLSEXT_HOSTNAME (55) and TLSEXT_NAMETYPE_host_name
// (0) instead. OpenSSL keeps the pointer, so the C string must
// stay alive for the lifetime of the handshake.
storedServerName = Array(serverName.utf8CString)
storedServerName?.withUnsafeMutableBufferPointer { buffer in
_ = SSL_ctrl(connection, 55, 0, buffer.baseAddress)
}
}
if !alpnProtocols.isEmpty {
var wire: [UInt8] = []
for proto in alpnProtocols {
let bytes = Array(proto.utf8)
wire.append(UInt8(bytes.count))
wire.append(contentsOf: bytes)
}
_ = SSL_set_alpn_protos(connection, wire, UInt32(wire.count))
}
let incoming = BIO_new(BIO_s_mem())
let outgoing = BIO_new(BIO_s_mem())
guard let incoming, let outgoing else {
handshakeFailed = true
return
}
readBIO = incoming
writeBIO = outgoing
SSL_set_bio(connection, incoming, outgoing)
}
private func drive() {
guard let ssl else { return }
if !handshakeCompleted, !handshakeFailed {
driveHandshake()
}
if handshakeCompleted, !pendingPlaintext.isEmpty {
// Copy the pending plaintext before the C call: the array cannot
// be mutated while it is borrowed by withUnsafeBufferPointer.
let buffer = pendingPlaintext
let written = buffer.withUnsafeBufferPointer { raw -> Int32 in
guard let base = raw.baseAddress, !raw.isEmpty else { return 0 }
return SSL_write(ssl, base, Int32(raw.count))
}
if written > 0 {
pendingPlaintext.removeFirst(Int(written))
}
}
flushEncryptedOutput()
if handshakeCompleted {
emitDecrypted()
flushEncryptedOutput()
}
}
private func driveHandshake() {
guard let ssl else { return }
let result = SSL_do_handshake(ssl)
if result == 1 {
handshakeCompleted = true
completeHandshake()
return
}
switch SSL_get_error(ssl, result) {
case SSL_ERROR_WANT_READ, SSL_ERROR_WANT_WRITE:
// Progress made; more bytes needed from either side.
break
default:
handshakeFailed = true
certificateValidationFailed?(nil)
}
}
private func flushEncryptedOutput() {
guard let writeBIO else { return }
var buffer = [UInt8](repeating: 0, count: 16_384)
while true {
let count = BIO_read(writeBIO, &buffer, Int32(buffer.count))
guard count > 0 else { break }
super.write(
data: Data(buffer[0..<Int(count)]),
completion: .none
)
}
}
private func emitDecrypted() {
guard let ssl else { return }
var buffer = [UInt8](repeating: 0, count: 65_536)
while true {
let count = SSL_read(ssl, &buffer, Int32(buffer.count))
if count > 0 {
super.read(data: Data(buffer[0..<Int(count)]))
continue
}
switch SSL_get_error(ssl, count) {
case SSL_ERROR_WANT_READ, SSL_ERROR_WANT_WRITE:
return
case SSL_ERROR_ZERO_RETURN:
return
default:
return
}
}
}
// MARK: - Handshake completion
private func completeHandshake() {
guard let ssl else { return }
let version = String(cString: SSL_get_version(ssl))
// SSL_get_cipher_name is a C macro over
// SSL_CIPHER_get_name(SSL_get_current_cipher(_:)).
let cipher: String
if let current = SSL_get_current_cipher(ssl) {
cipher = String(cString: SSL_CIPHER_get_name(current))
} else {
cipher = ""
}
var leafDER: Data?
var chainCertificates: [SecCertificate] = []
if let chain = SSL_get_peer_cert_chain(ssl) {
// sk_X509_num / sk_X509_value are C macros; the imported Swift
// surface exposes OPENSSL_sk_num / OPENSSL_sk_value instead.
let count = OPENSSL_sk_num(chain)
for index in 0..<count {
guard let raw = OPENSSL_sk_value(chain, index) else { continue }
let certificate = OpaquePointer(raw)
guard let der = x509DER(certificate) else { continue }
if leafDER == nil { leafDER = der }
if let secCertificate = SecCertificateCreateWithData(nil, der as CFData) {
chainCertificates.append(secCertificate)
}
}
}
if leafDER == nil, let certificate = SSL_get1_peer_certificate(ssl) {
if let der = x509DER(certificate) {
leafDER = der
if let secCertificate = SecCertificateCreateWithData(nil, der as CFData) {
chainCertificates.insert(secCertificate, at: 0)
}
}
X509_free(certificate)
}
guard let leafDER else {
handshakeFailed = true
certificateValidationFailed?(nil)
return
}
// Certificate trust evaluation via SecTrust, honouring the
// connector's configured validation (Luma uses the custom validator).
var trust: SecTrust?
if !chainCertificates.isEmpty {
SecTrustCreateWithCertificates(chainCertificates as CFArray, nil, &trust)
}
var validated = false
if let trust {
let domain = serverName ?? ""
switch certificateValidation {
case .default:
SecTrustSetPolicies(trust, SecPolicyCreateSSL(true, domain as CFString))
var result = SecTrustResultType.invalid
SecTrustEvaluate(trust, &result)
validated = result == .proceed || result == .unspecified
case .fingerprint(let fingerprint):
validated = Self.matchesFingerprint(leafDER, fingerprint: fingerprint)
case .customValidator(let validator):
validated = validator(trust)
}
}
guard validated else {
handshakeFailed = true
certificateValidationFailed?(trust)
return
}
// RFC 9266 tls-exporter channel binding (TLS 1.3).
var exporter: Data?
var exporterBuffer = [UInt8](repeating: 0, count: 32)
let exporterLabel = "EXPORTER-Channel-Binding"
let exporterResult = exporterLabel.withCString { label in
SSL_export_keying_material(
ssl,
&exporterBuffer,
exporterBuffer.count,
label,
exporterLabel.utf8.count,
nil,
0,
0
)
}
if exporterResult == 1 {
exporter = Data(exporterBuffer)
}
channelBindingStore.setTLSState(
LumaTLSState(
version: version,
cipher: cipher,
leafCertificateDER: leafDER,
exporter: exporter
)
)
}
private func x509DER(_ certificate: OpaquePointer) -> Data? {
// Encode into a fixed scratch buffer: X.509 certificates are far
// smaller than 16 KiB in practice, and this avoids manual OpenSSL
// memory management in Swift.
let length = i2d_X509(certificate, nil)
guard length > 0, length <= 16_384 else { return nil }
var buffer = [UInt8](repeating: 0, count: Int(length))
var copied: Int32 = 0
buffer.withUnsafeMutableBytes { raw in
var cursor = raw.baseAddress?.assumingMemoryBound(to: UInt8.self)
copied = i2d_X509(certificate, &cursor)
}
guard copied == length else { return nil }
return Data(buffer)
}
private static func matchesFingerprint(_ der: Data, fingerprint: String) -> Bool {
let expected = fingerprint
.replacingOccurrences(of: ":", with: "")
.replacingOccurrences(of: " ", with: "")
.lowercased()
let candidates = [
Insecure.SHA1.hash(data: der).map { String(format: "%02x", $0) }.joined(),
SHA256.hash(data: der).map { String(format: "%02x", $0) }.joined(),
]
return candidates.contains(expected)
}
}
enum LumaTLSProcessorError: Error {
case handshakeFailed
}
+304
View File
@@ -0,0 +1,304 @@
import Foundation
import Security
/// Result of probing the negotiated TLS parameters of the XMPP server:
/// the protocol version and the cipher suite actually agreed upon.
struct TLSProbeResult: Sendable {
let version: String
let cipher: String
}
/// Standalone, best-effort probe that negotiates TLS against the same host
/// and port the XMPP connection uses, and reports the negotiated TLS version
/// and cipher suite. It performs the STARTTLS stream handshake (stream
/// header + <starttls/>) when the account does not use Direct TLS, mirroring
/// what Martin's SocketConnector does, then reads the result from
/// SecureTransport. The app's real connection negotiates TLS the same way,
/// so the probe accurately reflects what the server screen displays.
final class TLSVersionProbe: NSObject, StreamDelegate {
private let host: String
private let port: UInt32
private let directTLS: Bool
private let expectedDomain: String
private let completion: (TLSProbeResult?) -> Void
private enum Phase {
case starting
case expectingFeatures
case tlsNegotiating
case finished
}
private var phase: Phase = .starting
private var inputStream: InputStream?
private var outputStream: OutputStream?
private var receivedData = Data()
private var sentStreamHeader = false
private var sentStartTLS = false
private var sawStartTLSOffer = false
private var sslContext: SSLContext?
private var runLoop: RunLoop?
private var finished = false
private static let activeLock = NSLock()
private static var activeProbes: Set<TLSVersionProbe> = []
static func run(
host: String,
port: UInt32,
directTLS: Bool,
expectedDomain: String,
completion: @escaping (TLSProbeResult?) -> Void
) {
let probe = TLSVersionProbe(
host: host,
port: port,
directTLS: directTLS,
expectedDomain: expectedDomain,
completion: completion
)
activeLock.lock()
activeProbes.insert(probe)
activeLock.unlock()
probe.start()
}
private init(
host: String,
port: UInt32,
directTLS: Bool,
expectedDomain: String,
completion: @escaping (TLSProbeResult?) -> Void
) {
self.host = host
self.port = port
self.directTLS = directTLS
self.expectedDomain = expectedDomain
self.completion = completion
super.init()
}
private func start() {
let thread = Thread { [weak self] in
guard let self else { return }
self.runLoop = RunLoop.current
var readStream: Unmanaged<CFReadStream>?
var writeStream: Unmanaged<CFWriteStream>?
CFStreamCreatePairWithSocketToHost(
kCFAllocatorDefault,
self.host as CFString,
self.port,
&readStream,
&writeStream
)
guard let readStream, let writeStream else {
self.finish(nil)
return
}
self.inputStream = readStream.takeRetainedValue()
self.outputStream = writeStream.takeRetainedValue()
self.inputStream?.delegate = self
self.outputStream?.delegate = self
self.inputStream?.schedule(in: .current, forMode: .default)
self.outputStream?.schedule(in: .current, forMode: .default)
self.inputStream?.open()
self.outputStream?.open()
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 12) { [weak self] in
guard let self, !self.finished else { return }
self.finish(nil)
}
RunLoop.current.run()
}
thread.name = "app.luma.tlsProbe"
thread.start()
}
// MARK: - StreamDelegate
func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
switch eventCode {
case .openCompleted:
if phase == .tlsNegotiating {
reportTLSResult()
} else if directTLS, phase == .starting {
configureTLS()
} else if !sentStreamHeader {
writeStreamHeader()
}
case .hasSpaceAvailable:
if directTLS { return }
if !sentStreamHeader {
writeStreamHeader()
} else if sawStartTLSOffer, !sentStartTLS {
writeStartTLS()
}
case .hasBytesAvailable:
readAvailableBytes()
case .errorOccurred, .endEncountered:
if phase == .tlsNegotiating {
reportTLSResult()
} else if phase != .finished {
finish(nil)
}
default:
break
}
}
// MARK: - Steps
private func writeStreamHeader() {
guard let outputStream else { return }
let header =
"<?xml version='1.0'?>"
+ "<stream:stream to='\(expectedDomain)' xmlns='jabber:client' "
+ "xmlns:stream='http://etherx.jabber.org/streams' version='1.0'>"
let data = Data(header.utf8)
sentStreamHeader = data.count == outputStream.write(
[UInt8](data),
maxLength: data.count
)
if sentStreamHeader, !directTLS {
phase = .expectingFeatures
}
}
private func writeStartTLS() {
guard let outputStream else { return }
let startTLS = "<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>"
let data = Data(startTLS.utf8)
sentStartTLS = data.count == outputStream.write(
[UInt8](data),
maxLength: data.count
)
}
private func readAvailableBytes() {
guard let inputStream else { return }
var chunk = [UInt8](repeating: 0, count: 4096)
while inputStream.hasBytesAvailable {
let count = inputStream.read(&chunk, maxLength: chunk.count)
guard count > 0 else { break }
receivedData.append(contentsOf: chunk[0..<count])
}
guard phase == .expectingFeatures else { return }
let text = String(decoding: receivedData, as: UTF8.self)
if text.contains("<proceed") {
configureTLS()
} else if text.contains("starttls") {
sawStartTLSOffer = true
if !sentStartTLS { writeStartTLS() }
}
}
private func configureTLS() {
guard let inputStream, let outputStream else {
finish(nil)
return
}
let settings: [String: Any] = [
kCFStreamSSLValidatesCertificateChain as String: false,
kCFStreamSSLPeerName as String: expectedDomain,
kCFStreamSSLLevel as String: StreamSocketSecurityLevel.negotiatedSSL,
]
inputStream.setProperty(
settings,
forKey: Stream.PropertyKey(kCFStreamPropertySSLSettings as String)
)
outputStream.setProperty(
settings,
forKey: Stream.PropertyKey(kCFStreamPropertySSLSettings as String)
)
if let rawContext = outputStream.property(forKey: Stream.PropertyKey(
rawValue: kCFStreamPropertySSLContext as String
)) {
let context = rawContext as! SSLContext
SSLSetALPNProtocols(context, ["xmpp-client"] as CFArray)
sslContext = context
}
phase = .tlsNegotiating
inputStream.close()
outputStream.close()
inputStream.open()
outputStream.open()
}
private func reportTLSResult() {
guard phase != .finished else { return }
phase = .finished
var protocolVersion = SSLProtocol(rawValue: 0)!
var cipher: SSLCipherSuite = 0
if let sslContext {
SSLGetNegotiatedProtocolVersion(sslContext, &protocolVersion)
if SSLGetNegotiatedCipher(sslContext, &cipher) != errSecSuccess {
cipher = 0
}
}
guard let versionDescription = Self.protocolDescription(protocolVersion) else {
finish(nil)
return
}
finish(TLSProbeResult(
version: versionDescription,
cipher: Self.cipherDescription(cipher)
))
}
private func finish(_ result: TLSProbeResult?) {
guard !finished else { return }
finished = true
phase = .finished
inputStream?.close()
outputStream?.close()
inputStream = nil
outputStream = nil
completion(result)
if let runLoop {
runLoop.perform { CFRunLoopStop(CFRunLoopGetCurrent()) }
}
Self.activeLock.lock()
Self.activeProbes.remove(self)
Self.activeLock.unlock()
}
// MARK: - Descriptions
/// The protocol constants are marked deprecated on modern SDKs, so match
/// the raw SecureTransport values instead: TLS 1.3 = 10, TLS 1.2 = 8,
/// TLS 1.1 = 7, TLS 1.0 = 4, SSL 3.0 = 2.
static func protocolDescription(_ protocolVersion: SSLProtocol) -> String? {
switch protocolVersion.rawValue {
case 10: return "TLS 1.3"
case 8: return "TLS 1.2"
case 7: return "TLS 1.1"
case 4: return "TLS 1.0"
case 2: return "SSL 3.0"
default: return nil
}
}
static func cipherDescription(_ cipher: SSLCipherSuite) -> String {
switch cipher {
case 0x1301: return "TLS_AES_128_GCM_SHA256"
case 0x1302: return "TLS_AES_256_GCM_SHA384"
case 0x1303: return "TLS_CHACHA20_POLY1305_SHA256"
case 0xC02F: return "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
case 0xC02B: return "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"
case 0xC030: return "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"
case 0xC02C: return "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"
case 0xCCA8: return "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256"
case 0xCCA9: return "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256"
case 0xCCAA: return "TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256"
case 0x009E: return "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256"
case 0x009F: return "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384"
case 0x009C: return "TLS_RSA_WITH_AES_128_GCM_SHA256"
case 0x009D: return "TLS_RSA_WITH_AES_256_GCM_SHA384"
case 0xC013: return "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA"
case 0xC014: return "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA"
case 0x003C: return "TLS_RSA_WITH_AES_128_CBC_SHA256"
case 0x002F: return "TLS_RSA_WITH_AES_128_CBC_SHA"
case 0x0035: return "TLS_RSA_WITH_AES_256_CBC_SHA"
default: return String(format: "0x%04X", cipher)
}
}
}
+221 -19
View File
@@ -270,6 +270,16 @@ final class XMPPService {
private var activePassword: String?
private var lastLoginDate: Date?
private var smacksSessionEstablishedDate: Date?
private(set) var negotiatedSASLMechanism: String?
private(set) var negotiatedTLSVersion: String?
private(set) var negotiatedTLSCipher: String?
private var tlsProbeTask: Task<TLSProbeResult?, Never>?
/// TLS parameters and channel-binding data of the live connection,
/// captured by Luma's OpenSSL TLS processor and shared with SCRAM-PLUS.
private let channelBindingStore = LumaChannelBindingStore()
/// Mechanisms advertised in the stream features (classic SASL + SASL2),
/// shown on the server-information screen.
private var advertisedSASLMechanisms: [String] = []
private var cancellables: Set<AnyCancellable> = []
private var account: AccountConfiguration?
private var archiveSyncStarted = false
@@ -418,6 +428,13 @@ final class XMPPService {
roomRealJIDByNickname.removeAll()
omemoConfiguredRoomJIDs.removeAll()
knownChatStatePeers.removeAll()
negotiatedSASLMechanism = nil
negotiatedTLSVersion = nil
negotiatedTLSCipher = nil
tlsProbeTask?.cancel()
tlsProbeTask = nil
channelBindingStore.reset()
advertisedSASLMechanisms = []
let account = try account.validated()
self.account = account
@@ -517,6 +534,11 @@ final class XMPPService {
saslFailureModule = nil
lastLoginDate = nil
smacksSessionEstablishedDate = nil
negotiatedSASLMechanism = nil
negotiatedTLSVersion = nil
negotiatedTLSCipher = nil
tlsProbeTask?.cancel()
tlsProbeTask = nil
eventHandler?(.connection(.disconnected(reason: nil)))
}
@@ -861,9 +883,12 @@ final class XMPPService {
)
// OMEMO 2 only when every member publishes OMEMO 2 devices;
// otherwise the legacy protocol keeps legacy members readable.
// Device lists are fetched on demand so a first message to a
// room never falls back to legacy just because the lists are
// not cached yet.
if let omemo2 = client.moduleOrNil(.omemo2),
omemo2.isReady,
recipients.allSatisfy({ omemo2.devices(for: $0)?.isEmpty == false }) {
await Self.allRecipientsPublishOMEMO2(recipients, module: omemo2) {
let encryptedMessage = try await encrypt(
message,
forGroupRecipients: recipients,
@@ -908,10 +933,13 @@ final class XMPPService {
}
if encrypted {
// Prefer OMEMO 2 when the peer publishes OMEMO 2 devices; legacy
// OMEMO stays as the fallback for legacy-only contacts.
// OMEMO stays as the fallback for legacy-only contacts. The
// peer's device list is fetched on demand, so the very first
// message to an OMEMO 2-only contact no longer falls back to
// legacy.
if let omemo2 = client.moduleOrNil(.omemo2),
omemo2.isReady,
omemo2.devices(for: peer)?.isEmpty == false {
await !(omemo2.deviceIDs(for: peer, fetching: true) ?? []).isEmpty {
let encryptedMessage = try await encrypt(message, using: omemo2)
try await chat.send(message: encryptedMessage.message)
return encryptedMessage.fingerprint
@@ -928,6 +956,32 @@ final class XMPPService {
}
}
/// OpenSSL version strings to their user-facing form ("TLSv1.3" →
/// "TLS 1.3").
private static func displayTLSVersion(_ version: String) -> String {
switch version {
case "TLSv1.3": return "TLS 1.3"
case "TLSv1.2": return "TLS 1.2"
case "TLSv1.1": return "TLS 1.1"
case "TLSv1": return "TLS 1.0"
default: return version
}
}
/// True when every MUC recipient has a non-empty OMEMO 2 device list.
/// Fetches the lists from PEP on demand for recipients that have not been
/// cached yet.
private static func allRecipientsPublishOMEMO2(
_ recipients: [BareJID],
module: LumaOMEMO2Module
) async -> Bool {
for recipient in recipients {
let devices = await module.deviceIDs(for: recipient, fetching: true) ?? []
if devices.isEmpty { return false }
}
return true
}
/// Loads one older MAM page for a single conversation. `before` is the
/// oldest server/MAM id currently known by the UI. For direct chats the
/// query is scoped with XEP-0313 `with`; for MUC the IQ is addressed to the
@@ -1439,6 +1493,58 @@ final class XMPPService {
ownFingerprint: omemoStorage?.ownFingerprint))
}
/// Negotiated TLS version of the current connection, probing the
/// server with a parallel handshake when the underlying library does not
/// expose it. Single-flight: the first caller starts the probe, later
/// callers (and later screen refreshes) reuse the stored result.
private func currentTLSVersion() async -> String? {
// Prefer the negotiated parameters of the live connection, captured
// by the OpenSSL TLS processor.
if let state = channelBindingStore.negotiatedState {
negotiatedTLSVersion = Self.displayTLSVersion(state.version)
negotiatedTLSCipher = state.cipher
return negotiatedTLSVersion
}
if let existing = tlsProbeTask {
return await existing.value?.version
}
guard let client, client.state == .connected() else {
return negotiatedTLSVersion
}
let endpointHost: String
let endpointPort: Int
if let endpoint = client.connector?.currentEndpoint as? SocketConnector.Endpoint {
endpointHost = endpoint.host
endpointPort = endpoint.port
} else if let account {
endpointHost = account.manualHost ?? account.domain ?? account.normalizedJID
endpointPort = account.manualPort ?? (account.usesDirectTLS ? 5223 : 5222)
} else {
return negotiatedTLSVersion
}
let directTLS = account?.usesDirectTLS ?? (endpointPort == 5223)
let domain = account?.domain ?? client.userBareJid.domain
let task = Task.detached(priority: .utility) { () -> TLSProbeResult? in
await withCheckedContinuation { continuation in
TLSVersionProbe.run(
host: endpointHost,
port: UInt32(endpointPort),
directTLS: directTLS,
expectedDomain: domain
) { result in
continuation.resume(returning: result)
}
}
}
tlsProbeTask = task
let result = await task.value
if let result {
negotiatedTLSVersion = result.version
negotiatedTLSCipher = result.cipher
}
return negotiatedTLSVersion
}
/// Queries the server's capabilities to populate the "server information"
/// settings screen: XEP-0030 disco#info (server + account), XEP-0092
/// software version, conference (MUC) services via disco#items, and
@@ -1510,11 +1616,18 @@ final class XMPPService {
// SASL mechanisms and Client State Indication are advertised in the raw
// stream <features>, not in disco#info, so read them from there.
let streamFeaturesElement = client.module(.streamFeatures).streamFeatures.element
let saslMechanisms = streamFeaturesElement?
.findChild(name: "mechanisms", xmlns: "urn:ietf:params:xml:ns:xmpp-sasl")?
.children
.filter { $0.name == "mechanism" }
.compactMap { $0.value } ?? []
let saslMechanisms: [String]
if !advertisedSASLMechanisms.isEmpty {
// Includes both classic SASL and SASL2 mechanisms when the server
// advertises them in separate stream-feature elements.
saslMechanisms = advertisedSASLMechanisms
} else {
saslMechanisms = streamFeaturesElement?
.findChild(name: "mechanisms", xmlns: "urn:ietf:params:xml:ns:xmpp-sasl")?
.children
.filter { $0.name == "mechanism" }
.compactMap { $0.value } ?? []
}
let supportsCSI = streamFeaturesElement?
.findChild(name: "csi", xmlns: "urn:xmpp:csi:0") != nil
let supportsRosterVersioning = streamFeaturesElement?
@@ -1529,6 +1642,23 @@ final class XMPPService {
let stats = connectionStatsModule?.snapshot
?? LumaConnectionStatsModule.Snapshot(sent: 0, acknowledged: 0, received: 0)
let tlsVersion = await currentTLSVersion()
// Show the channel-binding type alongside a PLUS mechanism, so the
// screen reflects how the exchange was actually bound to the TLS
// channel (tls-exporter preferred, tls-server-end-point fallback).
let displaySASLMechanism: String?
if let negotiatedSASLMechanism {
if negotiatedSASLMechanism.hasSuffix("-PLUS"),
let bindingType = channelBindingStore.preferredChannelBindingType
{
displaySASLMechanism = negotiatedSASLMechanism + " · " + bindingType
} else {
displaySASLMechanism = negotiatedSASLMechanism
}
} else {
displaySASLMechanism = nil
}
return ServerInformation(
serverDomain: serverDomain,
software: ServerInformation.Software(
@@ -1555,6 +1685,9 @@ final class XMPPService {
received: stats.received
),
saslMethods: saslMechanisms,
tlsVersion: tlsVersion,
tlsCipher: negotiatedTLSCipher,
saslMechanism: displaySASLMechanism,
supportsStreamManagement: client.module(.streamManagement).isAvailable,
supportsCarbons: client.module(.messageCarbons).isAvailable,
supportsClientState: supportsCSI,
@@ -1649,10 +1782,24 @@ 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())
// 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.
// 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
// mechanism is only eligible when the server advertises it, and the
// PLUS variants additionally gate on available binding data.
let sasl = client.modulesManager.register(SaslModule())
sasl.addMechanism(
LumaScramPlusMechanism(hash: .sha1, channelBindingStore: channelBindingStore),
first: true
)
sasl.addMechanism(
LumaScramPlusMechanism(hash: .sha256, channelBindingStore: channelBindingStore),
first: true
)
sasl.addMechanism(
LumaScramPlusMechanism(hash: .sha512, channelBindingStore: channelBindingStore),
first: true
)
sasl.addMechanism(LumaScramSha512Mechanism(), first: true)
_ = client.modulesManager.register(ResourceBinderModule())
_ = client.modulesManager.register(SessionEstablishmentModule())
@@ -1754,6 +1901,13 @@ final class XMPPService {
expectedDomain: expectedDomain
)
}
// SecureTransport caps stream TLS at 1.2 and has no keying
// exporter, so Luma supplies its own OpenSSL TLS 1.3 processor
// (TLS 1.3 + STARTTLS + channel binding). Certificate trust is
// still evaluated through the custom validator above.
options.networkProcessorProviders = [
LumaTLSNetworkProcessorProvider(channelBindingStore: channelBindingStore),
]
if let host = account.manualHost {
let defaultPort = account.usesDirectTLS ? 5223 : 5222
options.dnsResolver = StaticDNSSrvResolver(
@@ -2067,7 +2221,26 @@ final class XMPPService {
security = .plaintext
fingerprint = nil
contentMessage = message
case .duplicateMessage:
// The Double Ratchet message key is single-use: `.duplicateMessage`
// means this exact stanza was already decrypted on an earlier
// delivery (carbon, SMACK resend or a previous MAM pass), and its
// content was handled then. Emitting another bubble here turned the
// archived echoes of our own receipts, chat markers and resent
// copies into phantom "Не удалось расшифровать сообщение"
// placeholders that do not exist on the server. Mirrors the group
// message path.
return
default:
if outgoing {
// Our own outgoing echo cannot be decrypted back (no
// encrypt-to-self key, or a stale self-session). The
// optimistic local copy already carries the content, and
// messages sent from other devices have nothing we could
// render. Other clients never surface these stanzas as
// messages, so dropping them matches the server history.
return
}
// Some clients include a plaintext <body> fallback alongside the
// OMEMO <encrypted> payload. When decryption fails, prefer that
// fallback so a readable message is never shown as undecryptable.
@@ -2076,13 +2249,6 @@ final class XMPPService {
fingerprint = nil
contentMessage = message
} else {
// Includes `.duplicateMessage` (our own message without an
// encrypt-to-self key, or a key already consumed by a
// previous session): the body can no longer be recovered
// here. Still emit the message (as an undecryptable bubble)
// instead of dropping it, so history does not silently
// stop. Genuine duplicates are filtered later in AppModel
// by stanza/origin ID.
security = .decryptionFailed
fingerprint = nil
contentMessage = nil
@@ -4001,6 +4167,12 @@ final class XMPPService {
condition: saslFailureModule?.lastFailure?.condition,
serverText: saslFailureModule?.lastFailure?.text
)
case .sslCertError:
return "Сертификат сервера не прошёл проверку доверия."
case .timeout:
return "Сервер не ответил вовремя."
case .noRouteToServer:
return "Сервер недоступен по сети."
default:
return reason.localizedDescription
}
@@ -4014,13 +4186,43 @@ final class XMPPService {
}
private func applySASLprepIfNeeded(client: XMPPClient, features: Element) {
guard let activePassword, !activePassword.isEmpty else { return }
// Classic RFC 6120 mechanisms, SASL2 (RFC 9050) mechanisms and the
// XEP-0440 channel-binding types are three separate stream-feature
// elements; collect all three so the selection and the server screen
// see the complete picture.
let mechanisms =
features
.findChild(name: "mechanisms", xmlns: "urn:ietf:params:xml:ns:xmpp-sasl")?
.children
.filter { $0.name == "mechanism" }
.compactMap { $0.value } ?? []
let sasl2Mechanisms =
features
.findChild(name: "authentication", xmlns: "urn:xmpp:sasl:2")?
.children
.filter { $0.name == "mechanism" }
.compactMap { $0.value } ?? []
let channelBindingTypes = Set(
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))
// 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
// when the server sends features without mechanisms (e.g. stream
// resumption after SMACK), so the screen does not flip to unknown.
if let selected = SASLMechanismPreference.selected(
among: mechanisms,
allowsChannelBinding: channelBindingStore.canUseChannelBinding
) {
negotiatedSASLMechanism = selected
}
guard let activePassword, !activePassword.isEmpty else { return }
guard mechanisms.contains(where: { $0.hasPrefix("SCRAM-") }) else { return }
guard case .password(let currentPassword, _, _) = client.connectionConfiguration.credentials,
let prepared = try? SASLprep.prepare(activePassword),