diff --git a/DBSyn/DatabaseModel.swift b/DBSyn/DatabaseModel.swift index c09eaed..5f49079 100644 --- a/DBSyn/DatabaseModel.swift +++ b/DBSyn/DatabaseModel.swift @@ -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) ?? "" diff --git a/DBSyn/MainView.swift b/DBSyn/MainView.swift index 996af79..2b1ace5 100644 --- a/DBSyn/MainView.swift +++ b/DBSyn/MainView.swift @@ -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) } } diff --git a/DBSyn/MySQLProtocol.swift b/DBSyn/MySQLProtocol.swift index d7a05a5..9993ac5 100644 --- a/DBSyn/MySQLProtocol.swift +++ b/DBSyn/MySQLProtocol.swift @@ -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? var write: Unmanaged? @@ -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) diff --git a/docs/PHASE74-1-NATIVE-MYSQL-TLS.md b/docs/PHASE74-1-NATIVE-MYSQL-TLS.md new file mode 100644 index 0000000..cab1535 --- /dev/null +++ b/docs/PHASE74-1-NATIVE-MYSQL-TLS.md @@ -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.