new tests
This commit is contained in:
@@ -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) ?? ""
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user