Author SHA1 Message Date
Mark Gregory c27210f4f4 DBSyn 1.1.0 Build 82 - MySQL connection improvements 2026-10-21 09:45:20 +01:00
4 changed files with 55 additions and 238 deletions
+4 -4
View File
@@ -221,8 +221,8 @@
ENABLE_APP_SANDBOX = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "DBSyn";
MARKETING_VERSION = 1.1.1;
CURRENT_PROJECT_VERSION = 83;
MARKETING_VERSION = 1.1.0;
CURRENT_PROJECT_VERSION = 82;
ENABLE_TESTABILITY = YES;
ONLY_ACTIVE_ARCH = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved.";
@@ -244,8 +244,8 @@
ENABLE_APP_SANDBOX = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "DBSyn";
MARKETING_VERSION = 1.1.1;
CURRENT_PROJECT_VERSION = 83;
MARKETING_VERSION = 1.1.0;
CURRENT_PROJECT_VERSION = 82;
ENABLE_TESTABILITY = YES;
ONLY_ACTIVE_ARCH = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved.";
+6 -42
View File
@@ -74,53 +74,17 @@ enum PasswordStore {
return ""
}
@discardableResult
static func save(_ password: String, id: UUID) -> Bool {
static func save(_ password: String, id: UUID) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: id.uuidString
]
if password.isEmpty {
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
let data = Data(password.utf8)
let update: [String: Any] = [
kSecValueData as String: data
]
let updateStatus = SecItemUpdate(
query as CFDictionary,
update as CFDictionary
)
if updateStatus == errSecSuccess {
return true
}
if updateStatus == errSecItemNotFound {
var attributes = query
attributes[kSecValueData as String] = data
let addStatus = SecItemAdd(
attributes as CFDictionary,
nil
)
if addStatus == errSecSuccess {
return true
}
NSLog("DBSyn Keychain add failed: %d", addStatus)
return false
}
NSLog("DBSyn Keychain update failed: %d", updateStatus)
return false
SecItemDelete(query as CFDictionary)
guard !password.isEmpty else { return }
var attributes = query
attributes[kSecValueData as String] = Data(password.utf8)
SecItemAdd(attributes as CFDictionary, nil)
}
static func delete(_ id: UUID) {
+45 -183
View File
@@ -3,7 +3,6 @@ import CommonCrypto
import CoreFoundation
import CFNetwork
import Security
import Darwin
// Native MySQL wire protocol client. No external mysql executable is used.
// Initial scope: TCP, mysql_native_password and caching_sha2_password fast authentication.
@@ -136,14 +135,8 @@ private func authResponse(password: String, salt: [UInt8], plugin: String) throw
return zip(first, last).map { $0 ^ $1 }
}
if plugin == "caching_sha2_password" {
// MySQL caching_sha2_password authentication:
// XOR(SHA256(password),
// SHA256(SHA256(SHA256(password)) + challenge))
let first = sha256(pwd)
let second = sha256(first)
let third = sha256(second)
let last = sha256(third + salt)
let first = sha256(pwd), second = sha256(sha256(pwd))
let last = sha256(second + salt)
return zip(first, last).map { $0 ^ $1 }
}
throw MySQLWireError(message: "Authentication plugin \(plugin) is not yet supported")
@@ -205,141 +198,9 @@ private func mysqlRSAPublicKey(_ packet: [UInt8]) throws -> SecKey {
return key
}
// SecureTransport operates on the same socket before and after MySQL SSLRequest.
// CFStream SSL properties cannot reliably retrofit TLS onto already-open streams.
private final class MySQLSocketTransport {
let fd: Int32
private var ssl: SSLContext?
init(host: String, port: Int) throws {
var hints = addrinfo()
hints.ai_family = AF_UNSPEC
hints.ai_socktype = Int32(SOCK_STREAM)
hints.ai_protocol = Int32(IPPROTO_TCP)
var addresses: UnsafeMutablePointer<addrinfo>?
let result = getaddrinfo(host, String(port), &hints, &addresses)
guard result == 0 else { throw MySQLWireError(message: "Cannot resolve MySQL host: \(String(cString: gai_strerror(result)))") }
defer { freeaddrinfo(addresses) }
var candidate = addresses
var connected: Int32 = -1
while let item = candidate {
let info = item.pointee
let sock = Darwin.socket(info.ai_family, info.ai_socktype, info.ai_protocol)
if sock >= 0 {
if Darwin.connect(sock, info.ai_addr, info.ai_addrlen) == 0 {
connected = sock
break
}
_ = Darwin.close(sock)
}
candidate = info.ai_next
}
guard connected >= 0 else { throw MySQLWireError(message: "Cannot connect to MySQL at \(host):\(port): \(String(cString: strerror(errno)))") }
fd = connected
}
deinit {
if let ssl { _ = SSLClose(ssl) }
_ = Darwin.close(fd)
}
func read(_ buffer: UnsafeMutablePointer<UInt8>, count: Int) throws -> Int {
if let ssl {
var processed = 0
let status = SSLRead(ssl, buffer, count, &processed)
if status == errSecSuccess || (status == errSSLWouldBlock && processed > 0) { return processed }
throw MySQLWireError(message: "MySQL TLS read failed (OSStatus \(status))")
}
let n = Darwin.recv(fd, buffer, count, 0)
guard n > 0 else { throw MySQLWireError(message: n == 0 ? "MySQL connection closed" : "MySQL read failed: \(String(cString: strerror(errno)))") }
return n
}
func write(_ buffer: UnsafePointer<UInt8>, count: Int) throws -> Int {
if let ssl {
var processed = 0
let status = SSLWrite(ssl, buffer, count, &processed)
if status == errSecSuccess || (status == errSSLWouldBlock && processed > 0) { return processed }
throw MySQLWireError(message: "MySQL TLS write failed (OSStatus \(status))")
}
let n = Darwin.send(fd, buffer, count, 0)
guard n > 0 else { throw MySQLWireError(message: "MySQL write failed: \(String(cString: strerror(errno)))") }
return n
}
func startTLS(host: String, verify: Bool) throws {
NSLog("DBSyn TLS: host=%@ verify=%@", host, verify ? "ON" : "OFF")
guard let context = SSLCreateContext(nil, .clientSide, .streamType) else {
throw MySQLWireError(message: "Unable to create MySQL TLS context")
}
// SecureTransport callback connections refer to this retained transport.
let connection = Unmanaged.passUnretained(self).toOpaque()
var status = SSLSetIOFuncs(context, { connection, data, length in
let transport = Unmanaged<MySQLSocketTransport>.fromOpaque(connection).takeUnretainedValue()
let requested = length.pointee
let n = Darwin.recv(transport.fd, data, requested, 0)
if n > 0 { length.pointee = n; return errSecSuccess }
length.pointee = 0
return n == 0 ? errSSLClosedGraceful : errSecIO
}, { connection, data, length in
let transport = Unmanaged<MySQLSocketTransport>.fromOpaque(connection).takeUnretainedValue()
let n = Darwin.send(transport.fd, data, length.pointee, 0)
if n > 0 { length.pointee = n; return errSecSuccess }
length.pointee = 0
return errSecIO
})
guard status == errSecSuccess else { throw MySQLWireError(message: "TLS I/O setup failed (\(status))") }
status = SSLSetConnection(context, connection)
guard status == errSecSuccess else { throw MySQLWireError(message: "TLS socket setup failed (\(status))") }
status = host.withCString { SSLSetPeerDomainName(context, $0, host.utf8.count) }
guard status == errSecSuccess else { throw MySQLWireError(message: "TLS peer setup failed (\(status))") }
// REQUIRED encrypts but does not authenticate the server. Keep verification
// enabled for the verified mode; never silently fall back to REQUIRED.
// Certificate verification is handled after the TLS handshake.
status = SSLSetSessionOption(context, .breakOnServerAuth, true)
guard status == errSecSuccess else {
throw MySQLWireError(message: "TLS authentication setup failed (\(status))")
}
repeat {
status = SSLHandshake(context)
} while status == errSSLWouldBlock
if status == OSStatus(-9841) {
if verify {
var trust: SecTrust?
let trustStatus = SSLCopyPeerTrust(context, &trust)
guard trustStatus == errSecSuccess, let trust else {
throw MySQLWireError(message: "Unable to obtain MySQL server trust")
}
var trustError: CFError?
guard SecTrustEvaluateWithError(trust, &trustError) else {
throw MySQLWireError(
message: "MySQL server verification failed (verifyTLS=\(verify)): \(trustError?.localizedDescription ?? "untrusted server")"
)
}
}
repeat {
status = SSLHandshake(context)
} while status == errSSLWouldBlock
}
guard status == errSecSuccess else {
throw MySQLWireError(message: "MySQL TLS handshake failed (OSStatus \(status))")
}
ssl = context
}
}
final class MySQLWireConnection {
private let transport: MySQLSocketTransport
private let input: InputStream
private let output: OutputStream
private var sequence: UInt8 = 0
private let maxPacket = 16_777_215
private let serverHost: String
@@ -352,16 +213,32 @@ final class MySQLWireConnection {
self.requireTLS = requireTLS
self.verifyTLS = verifyTLS
guard (1...65535).contains(port) else { throw MySQLWireError(message: "Invalid port") }
transport = try MySQLSocketTransport(host: host, port: port)
var read: Unmanaged<CFReadStream>?
var write: Unmanaged<CFWriteStream>?
CFStreamCreatePairWithSocketToHost(nil, host as CFString, UInt32(port), &read, &write)
guard let r = read?.takeRetainedValue(), let w = write?.takeRetainedValue() else {
throw MySQLWireError(message: "Cannot create connection")
}
input = unsafeBitCast(r, to: InputStream.self)
output = unsafeBitCast(w, to: OutputStream.self)
input.open(); output.open()
// A CFStream may still be .opening immediately after open(). The
// first blocking read below waits for the server greeting.
if input.streamStatus == .error || output.streamStatus == .error {
throw MySQLWireError(message: input.streamError?.localizedDescription ?? output.streamError?.localizedDescription ?? "Could not open MySQL connection")
}
}
deinit { input.close(); output.close() }
private func readExact(_ count: Int) throws -> [UInt8] {
var buffer = [UInt8](repeating: 0, count: count)
var done = 0
while done < count {
let n = try buffer.withUnsafeMutableBufferPointer { ptr -> Int in
try transport.read(ptr.baseAddress!.advanced(by: done), count: count - done)
let n = buffer.withUnsafeMutableBufferPointer { ptr -> Int in
guard let base = ptr.baseAddress else { return -1 }
return input.read(base.advanced(by: done), maxLength: count - done)
}
guard n > 0 else { throw MySQLWireError(message: input.streamError?.localizedDescription ?? "MySQL connection closed") }
done += n
}
return buffer
@@ -370,7 +247,9 @@ final class MySQLWireConnection {
let header = try readExact(4)
let count = Int(header[0]) | Int(header[1]) << 8 | Int(header[2]) << 16
sequence = header[3] &+ 1
guard count <= maxPacket else { throw MySQLWireError(message: "MySQL packet exceeds protocol limit") }
guard count <= maxPacket else {
throw MySQLWireError(message: "MySQL packet exceeds protocol limit")
}
return try readExact(count)
}
private func send(_ payload: [UInt8]) throws {
@@ -380,14 +259,30 @@ final class MySQLWireConnection {
sequence = sequence &+ 1
var written = 0
while written < packet.count {
let n = try packet.withUnsafeBufferPointer { ptr -> Int in
try transport.write(ptr.baseAddress!.advanced(by: written), count: packet.count - written)
let n = packet.withUnsafeBufferPointer { ptr -> Int in
guard let base = ptr.baseAddress else { return -1 }
return output.write(base.advanced(by: written), maxLength: packet.count - written)
}
guard n > 0 else { throw MySQLWireError(message: output.streamError?.localizedDescription ?? "MySQL write failed") }
written += n
}
}
// MySQL TLS is negotiated AFTER the server greeting: first send CLIENT_SSL,
// then upgrade the existing TCP streams, then send the authentication packet.
// Never send credentials if TLS negotiation or certificate verification fails.
private func upgradeToTLS() throws {
try transport.startTLS(host: serverHost, verify: verifyTLS)
let settings: [String: Any] = [
kCFStreamSSLLevel as String: kCFStreamSocketSecurityLevelNegotiatedSSL,
kCFStreamSSLValidatesCertificateChain as String: verifyTLS,
kCFStreamSSLPeerName as String: verifyTLS ? serverHost : kCFNull as Any
]
let key = Stream.PropertyKey(rawValue: kCFStreamPropertySSLSettings as String)
guard input.setProperty(settings, forKey: key),
output.setProperty(settings, forKey: key) else {
throw MySQLWireError(message: "TLS stream upgrade unavailable; no credentials sent. Use SSH tunnelling for this server.")
}
// CFStream performs the TLS handshake before delivering encrypted data.
// Writing authentication triggers the handshake; any failure aborts it.
tlsActive = true
}
@@ -414,21 +309,7 @@ final class MySQLWireConnection {
_ = try c.take(10)
let salt2 = try c.take(max(13, authLen - 8))
let salt = salt1 + salt2.prefix(max(0, authLen - 9))
// MySQL authentication uses the 20-byte server challenge.
guard salt.count == 20 else {
throw MySQLWireError(
message: "Invalid MySQL authentication challenge length: \(salt.count)"
)
}
let plugin = (try? c.nulString()) ?? "mysql_native_password"
NSLog(
"DBSyn MySQL authentication: plugin=%@ saltLength=%d tlsRequired=%@",
plugin,
salt.count,
requireTLS ? "YES" : "NO"
)
if requireTLS {
let clientSSL = 0x00000800
guard capabilities & clientSSL != 0 else {
@@ -450,13 +331,6 @@ final class MySQLWireConnection {
body += Array(plugin.utf8) + [0]
try send(body)
var reply = try readPacket()
NSLog(
"DBSyn MySQL initial authentication response: type=0x%02X length=%d",
Int(reply.first ?? 0),
reply.count
)
try checkError(reply)
var currentPlugin = plugin
var currentSalt = Array(salt)
@@ -475,12 +349,6 @@ final class MySQLWireConnection {
guard currentPlugin == "caching_sha2_password", reply.count >= 2 else {
throw MySQLWireError(message: "Unsupported authentication continuation")
}
NSLog(
"DBSyn MySQL authentication continuation: code=0x%02X TLS=%@",
Int(reply[1]),
tlsActive ? "YES" : "NO"
)
switch reply[1] {
case 0x03: // Fast authentication accepted; wait for OK.
reply = try readPacket()
@@ -516,12 +384,6 @@ final class MySQLWireConnection {
throw MySQLWireError(message: "Unsupported caching_sha2_password response: \(reply[1])")
}
}
NSLog(
"DBSyn MySQL final authentication response: type=0x%02X length=%d",
Int(reply.first ?? 0),
reply.count
)
try checkError(reply)
guard reply.first == 0x00 else { throw MySQLWireError(message: "Authentication did not complete") }
if !database.isEmpty { _ = try query("USE `\(database.replacingOccurrences(of: "`", with: "``"))`") }
-9
View File
@@ -1,9 +0,0 @@
# DBSyn 1.1.1 (Build 83)
- Replace CFStream's post-open TLS property upgrade with a socket-backed SecureTransport handshake after MySQL SSLRequest.
- Preserve verified TLS and explicitly selected encrypted-only TLS modes.
- Do not change SSH tunnelling, saved connections, or database operations.
## Validation
This source patch has not been compiled on macOS or tested against Venus. Build and test in Xcode before release. SecureTransport is a deprecated Apple API and should be migrated to a maintained TLS implementation in a later release.