Author SHA1 Message Date
Mark Gregory face625f23 row editing 2026-10-21 11:43:43 +01:00
Mark Gregory 1d8e8702a8 row editing 2026-10-21 10:39:23 +01:00
Mark Gregory 2aaeff3774 row editing 2026-10-21 10:30:18 +01:00
Mark Gregory 3f9f572434 row editing 2026-10-21 10:21:27 +01:00
Mark Gregory 2229238e52 row editing 2026-10-21 10:15:06 +01:00
Mark Gregory 60cbf790db row editing 2026-10-21 09:47:53 +01:00
6 changed files with 267 additions and 63 deletions
+4 -4
View File
@@ -221,8 +221,8 @@
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "DBSyn"; INFOPLIST_KEY_CFBundleDisplayName = "DBSyn";
MARKETING_VERSION = 1.0.8; MARKETING_VERSION = 1.1.1;
CURRENT_PROJECT_VERSION = 81; CURRENT_PROJECT_VERSION = 83;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved.";
@@ -244,8 +244,8 @@
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "DBSyn"; INFOPLIST_KEY_CFBundleDisplayName = "DBSyn";
MARKETING_VERSION = 1.0.8; MARKETING_VERSION = 1.1.1;
CURRENT_PROJECT_VERSION = 81; CURRENT_PROJECT_VERSION = 83;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved.";
+47 -9
View File
@@ -12,15 +12,16 @@ struct ConnectionProfile: Identifiable, Codable, Hashable {
// Optional SSH transport. Existing saved profiles decode with these defaults. // Optional SSH transport. Existing saved profiles decode with these defaults.
var useSSHTunnel = false var useSSHTunnel = false
var useTLS = true var useTLS = true
var verifyTLS = true
var sshHost = "" var sshHost = ""
var sshPort = 22 var sshPort = 22
var sshUser = "" var sshUser = ""
var sshIdentityFile = "" var sshIdentityFile = ""
enum CodingKeys: String, CodingKey { case id, name, host, port, username, database, useSSHTunnel, useTLS, sshHost, sshPort, sshUser, sshIdentityFile } enum CodingKeys: String, CodingKey { case id, name, host, port, username, database, useSSHTunnel, useTLS, verifyTLS, sshHost, sshPort, sshUser, sshIdentityFile }
init(id: UUID = UUID(), name: String = "Local MySQL", host: String = "127.0.0.1", port: Int = 3306, username: String = "root", database: String = "", useSSHTunnel: Bool = false, useTLS: Bool = true, sshHost: String = "", sshPort: Int = 22, sshUser: String = "", sshIdentityFile: String = "") { init(id: UUID = UUID(), name: String = "Local MySQL", host: String = "127.0.0.1", port: Int = 3306, username: String = "root", database: String = "", useSSHTunnel: Bool = false, useTLS: Bool = true, verifyTLS: Bool = true, sshHost: String = "", sshPort: Int = 22, sshUser: String = "", sshIdentityFile: String = "") {
self.id = id; self.name = name; self.host = host; self.port = port; self.username = username; self.database = database self.id = id; self.name = name; self.host = host; self.port = port; self.username = username; self.database = database
self.useSSHTunnel = useSSHTunnel; self.useTLS = useTLS; self.sshHost = sshHost; self.sshPort = sshPort; self.sshUser = sshUser; self.sshIdentityFile = sshIdentityFile self.useSSHTunnel = useSSHTunnel; self.useTLS = useTLS; self.verifyTLS = verifyTLS; self.sshHost = sshHost; self.sshPort = sshPort; self.sshUser = sshUser; self.sshIdentityFile = sshIdentityFile
} }
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
@@ -32,6 +33,7 @@ struct ConnectionProfile: Identifiable, Codable, Hashable {
database = try c.decode(String.self, forKey: .database) database = try c.decode(String.self, forKey: .database)
useSSHTunnel = try c.decodeIfPresent(Bool.self, forKey: .useSSHTunnel) ?? false useSSHTunnel = try c.decodeIfPresent(Bool.self, forKey: .useSSHTunnel) ?? false
useTLS = try c.decodeIfPresent(Bool.self, forKey: .useTLS) ?? true useTLS = try c.decodeIfPresent(Bool.self, forKey: .useTLS) ?? true
verifyTLS = try c.decodeIfPresent(Bool.self, forKey: .verifyTLS) ?? true
sshHost = try c.decodeIfPresent(String.self, forKey: .sshHost) ?? "" sshHost = try c.decodeIfPresent(String.self, forKey: .sshHost) ?? ""
sshPort = try c.decodeIfPresent(Int.self, forKey: .sshPort) ?? 22 sshPort = try c.decodeIfPresent(Int.self, forKey: .sshPort) ?? 22
sshUser = try c.decodeIfPresent(String.self, forKey: .sshUser) ?? "" sshUser = try c.decodeIfPresent(String.self, forKey: .sshUser) ?? ""
@@ -72,17 +74,53 @@ enum PasswordStore {
return "" return ""
} }
static func save(_ password: String, id: UUID) { @discardableResult
static func save(_ password: String, id: UUID) -> Bool {
let query: [String: Any] = [ let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service, kSecAttrService as String: service,
kSecAttrAccount as String: id.uuidString kSecAttrAccount as String: id.uuidString
] ]
SecItemDelete(query as CFDictionary)
guard !password.isEmpty else { return } if password.isEmpty {
var attributes = query let status = SecItemDelete(query as CFDictionary)
attributes[kSecValueData as String] = Data(password.utf8) return status == errSecSuccess || status == errSecItemNotFound
SecItemAdd(attributes as CFDictionary, nil) }
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
} }
static func delete(_ id: UUID) { static func delete(_ id: UUID) {
+6 -2
View File
@@ -2550,8 +2550,12 @@ struct ConnectionEditor: View {
TextField("Default Schema", text: $profile.database) TextField("Default Schema", text: $profile.database)
} }
Section("Secure Transport") { Section("Secure Transport") {
Toggle("Require verified TLS for direct remote connections", isOn: $profile.useTLS) Toggle("Verify server identity (recommended)", isOn: $profile.verifyTLS)
.disabled(profile.useSSHTunnel) .disabled(profile.useSSHTunnel)
if !profile.verifyTLS && !profile.useSSHTunnel {
Text("Encrypted TLS (MySQL REQUIRED): traffic is encrypted, but the server identity is NOT verified. Only use with servers you trust.")
.font(.caption).foregroundStyle(.orange)
}
Toggle("Connect through SSH tunnel", isOn: $profile.useSSHTunnel) Toggle("Connect through SSH tunnel", isOn: $profile.useSSHTunnel)
if profile.useSSHTunnel { if profile.useSSHTunnel {
TextField("SSH Host", text: $profile.sshHost) TextField("SSH Host", text: $profile.sshHost)
@@ -2561,7 +2565,7 @@ struct ConnectionEditor: View {
Text("Uses your system SSH keys and known_hosts. MySQL hostname is resolved from the SSH server.") Text("Uses your system SSH keys and known_hosts. MySQL hostname is resolved from the SSH server.")
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
} else { } else {
Text("Direct remote MySQL uses certificate-verified TLS. The server certificate must match its hostname and be trusted by macOS.") Text(profile.verifyTLS ? "Direct remote MySQL uses verified TLS." : "Direct remote MySQL uses encrypted TLS without server identity verification.")
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
} }
} }
+189 -48
View File
@@ -3,6 +3,7 @@ import CommonCrypto
import CoreFoundation import CoreFoundation
import CFNetwork import CFNetwork
import Security import Security
import Darwin
// Native MySQL wire protocol client. No external mysql executable is used. // Native MySQL wire protocol client. No external mysql executable is used.
// Initial scope: TCP, mysql_native_password and caching_sha2_password fast authentication. // Initial scope: TCP, mysql_native_password and caching_sha2_password fast authentication.
@@ -135,8 +136,14 @@ private func authResponse(password: String, salt: [UInt8], plugin: String) throw
return zip(first, last).map { $0 ^ $1 } return zip(first, last).map { $0 ^ $1 }
} }
if plugin == "caching_sha2_password" { if plugin == "caching_sha2_password" {
let first = sha256(pwd), second = sha256(sha256(pwd)) // MySQL caching_sha2_password authentication:
let last = sha256(second + salt) // 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)
return zip(first, last).map { $0 ^ $1 } return zip(first, last).map { $0 ^ $1 }
} }
throw MySQLWireError(message: "Authentication plugin \(plugin) is not yet supported") throw MySQLWireError(message: "Authentication plugin \(plugin) is not yet supported")
@@ -198,45 +205,163 @@ private func mysqlRSAPublicKey(_ packet: [UInt8]) throws -> SecKey {
return key 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 { final class MySQLWireConnection {
private let input: InputStream private let transport: MySQLSocketTransport
private let output: OutputStream
private var sequence: UInt8 = 0 private var sequence: UInt8 = 0
private let maxPacket = 16_777_215 private let maxPacket = 16_777_215
private let serverHost: String private let serverHost: String
private let requireTLS: Bool private let requireTLS: Bool
private let verifyTLS: Bool
private var tlsActive = false private var tlsActive = false
init(host: String, port: Int, requireTLS: Bool = false) throws { init(host: String, port: Int, requireTLS: Bool = false, verifyTLS: Bool = true) throws {
self.serverHost = host self.serverHost = host
self.requireTLS = requireTLS self.requireTLS = requireTLS
self.verifyTLS = verifyTLS
guard (1...65535).contains(port) else { throw MySQLWireError(message: "Invalid port") } guard (1...65535).contains(port) else { throw MySQLWireError(message: "Invalid port") }
var read: Unmanaged<CFReadStream>? transport = try MySQLSocketTransport(host: host, port: port)
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] { private func readExact(_ count: Int) throws -> [UInt8] {
var buffer = [UInt8](repeating: 0, count: count) var buffer = [UInt8](repeating: 0, count: count)
var done = 0 var done = 0
while done < count { while done < count {
let n = buffer.withUnsafeMutableBufferPointer { ptr -> Int in let n = try buffer.withUnsafeMutableBufferPointer { ptr -> Int in
guard let base = ptr.baseAddress else { return -1 } try transport.read(ptr.baseAddress!.advanced(by: done), count: count - done)
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 done += n
} }
return buffer return buffer
@@ -245,9 +370,7 @@ final class MySQLWireConnection {
let header = try readExact(4) let header = try readExact(4)
let count = Int(header[0]) | Int(header[1]) << 8 | Int(header[2]) << 16 let count = Int(header[0]) | Int(header[1]) << 8 | Int(header[2]) << 16
sequence = header[3] &+ 1 sequence = header[3] &+ 1
guard count <= maxPacket else { guard count <= maxPacket else { throw MySQLWireError(message: "MySQL packet exceeds protocol limit") }
throw MySQLWireError(message: "MySQL packet exceeds protocol limit")
}
return try readExact(count) return try readExact(count)
} }
private func send(_ payload: [UInt8]) throws { private func send(_ payload: [UInt8]) throws {
@@ -257,30 +380,14 @@ final class MySQLWireConnection {
sequence = sequence &+ 1 sequence = sequence &+ 1
var written = 0 var written = 0
while written < packet.count { while written < packet.count {
let n = packet.withUnsafeBufferPointer { ptr -> Int in let n = try packet.withUnsafeBufferPointer { ptr -> Int in
guard let base = ptr.baseAddress else { return -1 } try transport.write(ptr.baseAddress!.advanced(by: written), count: packet.count - written)
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 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 { private func upgradeToTLS() throws {
let settings: [String: Any] = [ try transport.startTLS(host: serverHost, verify: verifyTLS)
kCFStreamSSLLevel as String: kCFStreamSocketSecurityLevelNegotiatedSSL,
kCFStreamSSLValidatesCertificateChain as String: true,
kCFStreamSSLPeerName as String: serverHost
]
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 tlsActive = true
} }
@@ -307,7 +414,21 @@ final class MySQLWireConnection {
_ = try c.take(10) _ = try c.take(10)
let salt2 = try c.take(max(13, authLen - 8)) let salt2 = try c.take(max(13, authLen - 8))
let salt = salt1 + salt2.prefix(max(0, authLen - 9)) 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" let plugin = (try? c.nulString()) ?? "mysql_native_password"
NSLog(
"DBSyn MySQL authentication: plugin=%@ saltLength=%d tlsRequired=%@",
plugin,
salt.count,
requireTLS ? "YES" : "NO"
)
if requireTLS { if requireTLS {
let clientSSL = 0x00000800 let clientSSL = 0x00000800
guard capabilities & clientSSL != 0 else { guard capabilities & clientSSL != 0 else {
@@ -329,6 +450,13 @@ final class MySQLWireConnection {
body += Array(plugin.utf8) + [0] body += Array(plugin.utf8) + [0]
try send(body) try send(body)
var reply = try readPacket() var reply = try readPacket()
NSLog(
"DBSyn MySQL initial authentication response: type=0x%02X length=%d",
Int(reply.first ?? 0),
reply.count
)
try checkError(reply) try checkError(reply)
var currentPlugin = plugin var currentPlugin = plugin
var currentSalt = Array(salt) var currentSalt = Array(salt)
@@ -347,6 +475,12 @@ final class MySQLWireConnection {
guard currentPlugin == "caching_sha2_password", reply.count >= 2 else { guard currentPlugin == "caching_sha2_password", reply.count >= 2 else {
throw MySQLWireError(message: "Unsupported authentication continuation") throw MySQLWireError(message: "Unsupported authentication continuation")
} }
NSLog(
"DBSyn MySQL authentication continuation: code=0x%02X TLS=%@",
Int(reply[1]),
tlsActive ? "YES" : "NO"
)
switch reply[1] { switch reply[1] {
case 0x03: // Fast authentication accepted; wait for OK. case 0x03: // Fast authentication accepted; wait for OK.
reply = try readPacket() reply = try readPacket()
@@ -382,6 +516,12 @@ final class MySQLWireConnection {
throw MySQLWireError(message: "Unsupported caching_sha2_password response: \(reply[1])") 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) try checkError(reply)
guard reply.first == 0x00 else { throw MySQLWireError(message: "Authentication did not complete") } guard reply.first == 0x00 else { throw MySQLWireError(message: "Authentication did not complete") }
if !database.isEmpty { _ = try query("USE `\(database.replacingOccurrences(of: "`", with: "``"))`") } if !database.isEmpty { _ = try query("USE `\(database.replacingOccurrences(of: "`", with: "``"))`") }
@@ -509,8 +649,9 @@ private final class PersistentMySQLSessions {
let endpoint = try DBSynSSHTunnels.shared.endpoint(for: profile) let endpoint = try DBSynSSHTunnels.shared.endpoint(for: profile)
let connection = try MySQLWireConnection( let connection = try MySQLWireConnection(
host: endpoint.0, port: endpoint.1, host: endpoint.0, port: endpoint.1,
requireTLS: !profile.useSSHTunnel && profile.useTLS && requireTLS: !profile.useSSHTunnel &&
!["127.0.0.1", "localhost", "::1"].contains(endpoint.0.lowercased()) !["127.0.0.1", "localhost", "::1"].contains(endpoint.0.lowercased()),
verifyTLS: profile.verifyTLS
) )
connection.markHost(endpoint.0) connection.markHost(endpoint.0)
try connection.authenticate(user: profile.username, try connection.authenticate(user: profile.username,
+12
View File
@@ -0,0 +1,12 @@
# DBSyn 1.1.0 (Build 82)
## Connection security
- Added a per-connection **Verify server identity** option. Enabled by default, including for existing profiles.
- When disabled, direct remote connections still negotiate encrypted MySQL TLS, equivalent to `--ssl-mode=REQUIRED`, without validating the server identity.
- SSH tunnel behaviour is unchanged.
- The connection form warns when identity verification is disabled.
**Security:** Unverified TLS protects against passive interception but not an active impersonation attack. Use verified TLS whenever possible.
## Validation
Source changes inspected; macOS/Xcode build and live database connection testing must be performed on a Mac.
+9
View File
@@ -0,0 +1,9 @@
# 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.