new tests

This commit is contained in:
Mark Gregory
2026-10-19 10:46:22 +01:00
parent 3307bd90e4
commit 8d33346993
4 changed files with 61 additions and 14 deletions
+5 -3
View File
@@ -11,15 +11,16 @@ struct ConnectionProfile: Identifiable, Codable, Hashable {
var database = ""
// Optional SSH transport. Existing saved profiles decode with these defaults.
var useSSHTunnel = false
var useTLS = true
var sshHost = ""
var sshPort = 22
var sshUser = ""
var sshIdentityFile = ""
enum CodingKeys: String, CodingKey { case id, name, host, port, username, database, useSSHTunnel, 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, sshHost: String = "", sshPort: Int = 22, sshUser: String = "", sshIdentityFile: String = "") {
enum CodingKeys: String, CodingKey { case id, name, host, port, username, database, useSSHTunnel, useTLS, 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 = "") {
self.id = id; self.name = name; self.host = host; self.port = port; self.username = username; self.database = database
self.useSSHTunnel = useSSHTunnel; self.sshHost = sshHost; self.sshPort = sshPort; self.sshUser = sshUser; self.sshIdentityFile = sshIdentityFile
self.useSSHTunnel = useSSHTunnel; self.useTLS = useTLS; self.sshHost = sshHost; self.sshPort = sshPort; self.sshUser = sshUser; self.sshIdentityFile = sshIdentityFile
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
@@ -30,6 +31,7 @@ struct ConnectionProfile: Identifiable, Codable, Hashable {
username = try c.decode(String.self, forKey: .username)
database = try c.decode(String.self, forKey: .database)
useSSHTunnel = try c.decodeIfPresent(Bool.self, forKey: .useSSHTunnel) ?? false
useTLS = try c.decodeIfPresent(Bool.self, forKey: .useTLS) ?? true
sshHost = try c.decodeIfPresent(String.self, forKey: .sshHost) ?? ""
sshPort = try c.decodeIfPresent(Int.self, forKey: .sshPort) ?? 22
sshUser = try c.decodeIfPresent(String.self, forKey: .sshUser) ?? ""
+3 -1
View File
@@ -2506,6 +2506,8 @@ struct ConnectionEditor: View {
TextField("Default Schema", text: $profile.database)
}
Section("Secure Transport") {
Toggle("Require verified TLS for direct remote connections", isOn: $profile.useTLS)
.disabled(profile.useSSHTunnel)
Toggle("Connect through SSH tunnel", isOn: $profile.useSSHTunnel)
if profile.useSSHTunnel {
TextField("SSH Host", text: $profile.sshHost)
@@ -2515,7 +2517,7 @@ struct ConnectionEditor: View {
Text("Uses your system SSH keys and known_hosts. MySQL hostname is resolved from the SSH server.")
.font(.caption).foregroundStyle(.secondary)
} else {
Text("Direct remote MySQL requires TLS, which DBSyn does not yet implement. Enable SSH for remote servers.")
Text("Direct remote MySQL uses certificate-verified TLS. The server certificate must match its hostname and be trusted by macOS.")
.font(.caption).foregroundStyle(.secondary)
}
}
+46 -10
View File
@@ -1,6 +1,7 @@
import Foundation
import CommonCrypto
import CoreFoundation
import CFNetwork
import Security
// Native MySQL wire protocol client. No external mysql executable is used.
@@ -182,8 +183,13 @@ final class MySQLWireConnection {
private let output: OutputStream
private var sequence: UInt8 = 0
private let maxPacket = 16_777_215
private let serverHost: String
private let requireTLS: Bool
private var tlsActive = false
init(host: String, port: Int) throws {
init(host: String, port: Int, requireTLS: Bool = false) throws {
self.serverHost = host
self.requireTLS = requireTLS
guard (1...65535).contains(port) else { throw MySQLWireError(message: "Invalid port") }
var read: Unmanaged<CFReadStream>?
var write: Unmanaged<CFWriteStream>?
@@ -236,6 +242,25 @@ final class MySQLWireConnection {
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 {
let settings: [String: Any] = [
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
}
private func checkError(_ p: [UInt8]) throws {
guard p.first == 0xff else { return }
let details = String(decoding: p.dropFirst(3), as: UTF8.self)
@@ -260,11 +285,21 @@ final class MySQLWireConnection {
let salt2 = try c.take(max(13, authLen - 8))
let salt = salt1 + salt2.prefix(max(0, authLen - 9))
let plugin = (try? c.nulString()) ?? "mysql_native_password"
// This initial implementation deliberately refuses cleartext remote credentials.
guard hostIsLoopback else { throw MySQLWireError(message: "Direct remote connections require TLS, which is not yet implemented. Use a local SSH tunnel.") }
if requireTLS {
let clientSSL = 0x00000800
guard capabilities & clientSSL != 0 else {
throw MySQLWireError(message: "MySQL server does not advertise TLS; refusing to send credentials")
}
let sslFlags = 0x00000001 | 0x00000004 | 0x00000200 | 0x00000800 | 0x00002000 | 0x00008000 | 0x00080000
let request = le32(sslFlags & capabilities) + le32(16_777_216) + [UInt8(45)] + [UInt8](repeating: 0, count: 23)
try send(request)
try upgradeToTLS()
} else if !hostIsLoopback {
throw MySQLWireError(message: "Remote connections require verified TLS or an SSH tunnel")
}
// CLIENT_LONG_PASSWORD, LONG_FLAG, PROTOCOL_41, TRANSACTIONS,
// SECURE_CONNECTION and PLUGIN_AUTH. Do not set DEPRECATE_EOF.
let clientFlags = 0x00000001 | 0x00000004 | 0x00000200 | 0x00002000 | 0x00008000 | 0x00080000
let clientFlags = 0x00000001 | 0x00000004 | 0x00000200 | (requireTLS ? 0x00000800 : 0) | 0x00002000 | 0x00008000 | 0x00080000
let response = try authResponse(password: password, salt: Array(salt), plugin: plugin)
var body = le32(clientFlags & capabilities) + le32(16_777_216) + [UInt8(45)] + [UInt8](repeating: 0, count: 23)
body += Array(user.utf8) + [0, UInt8(response.count)] + response
@@ -293,14 +328,15 @@ final class MySQLWireConnection {
case 0x03: // Fast authentication accepted; wait for OK.
reply = try readPacket()
case 0x04: // Full authentication required.
guard hostIsLoopback else {
throw MySQLWireError(message: "Remote full authentication requires TLS (not implemented). Use a trusted local SSH tunnel.")
guard hostIsLoopback || tlsActive else {
throw MySQLWireError(message: "Full authentication requires encrypted transport")
}
if password.isEmpty {
if tlsActive {
try send(Array(password.utf8) + [0])
} else if password.isEmpty {
try send([0])
} else {
// Request the server's RSA public key (0x02). This is
// intended ONLY for a trusted local loopback connection.
// RSA fallback is restricted to trusted loopback transport.
try send([0x02])
let keyPacket = try readPacket()
try checkError(keyPacket)
@@ -415,7 +451,7 @@ private final class PersistentMySQLSessions {
if connections[profile.id] == nil {
let endpoint = try DBSynSSHTunnels.shared.endpoint(for: profile)
let connection = try MySQLWireConnection(host: endpoint.0, port: endpoint.1)
let connection = try MySQLWireConnection(host: endpoint.0, port: endpoint.1, requireTLS: !profile.useSSHTunnel && profile.useTLS && !["127.0.0.1", "localhost", "::1"].contains(endpoint.0.lowercased()))
connection.markHost(endpoint.0)
try connection.authenticate(user: profile.username,
password: password, database: database)
+7
View File
@@ -0,0 +1,7 @@
# Phase 74.1 — Direct MySQL TLS (experimental)
MySQL protocol flow: read plaintext greeting, require server CLIENT_SSL capability, send SSLRequest, set CFStream TLS security properties with peer-name and certificate-chain validation, send handshake response only after requesting TLS upgrade. caching_sha2_password full auth sends the password over the encrypted stream. Existing SSH tunnel and loopback workflows remain available.
Security: Direct remote connections with TLS disabled are rejected. Certificate verification is mandatory. For an IP connection the certificate must be valid for that IP; prefer a DNS hostname matching the certificate. No trust-all or self-signed certificate bypass is provided.
Caveat: macOS CFStream may not support setting SSL settings on streams already opened for plaintext greeting. In that case the client fails closed before sending credentials. This implementation is syntax-checked only; macOS build and live TLS interoperability have not been verified. For production reliability, a transport implemented with Secure Transport/Network.framework supporting an explicit in-place TLS upgrade may be required.