new tests
This commit is contained in:
@@ -11,15 +11,16 @@ struct ConnectionProfile: Identifiable, Codable, Hashable {
|
|||||||
var database = ""
|
var database = ""
|
||||||
// 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 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, sshHost, sshPort, sshUser, sshIdentityFile }
|
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, 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, 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.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 {
|
init(from decoder: Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
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)
|
username = try c.decode(String.self, forKey: .username)
|
||||||
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
|
||||||
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) ?? ""
|
||||||
|
|||||||
@@ -2506,6 +2506,8 @@ 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)
|
||||||
|
.disabled(profile.useSSHTunnel)
|
||||||
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)
|
||||||
@@ -2515,7 +2517,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 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)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-10
@@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CommonCrypto
|
import CommonCrypto
|
||||||
import CoreFoundation
|
import CoreFoundation
|
||||||
|
import CFNetwork
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
// Native MySQL wire protocol client. No external mysql executable is used.
|
// Native MySQL wire protocol client. No external mysql executable is used.
|
||||||
@@ -182,8 +183,13 @@ final class MySQLWireConnection {
|
|||||||
private let output: OutputStream
|
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 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") }
|
guard (1...65535).contains(port) else { throw MySQLWireError(message: "Invalid port") }
|
||||||
var read: Unmanaged<CFReadStream>?
|
var read: Unmanaged<CFReadStream>?
|
||||||
var write: Unmanaged<CFWriteStream>?
|
var write: Unmanaged<CFWriteStream>?
|
||||||
@@ -236,6 +242,25 @@ final class MySQLWireConnection {
|
|||||||
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 {
|
||||||
|
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 {
|
private func checkError(_ p: [UInt8]) throws {
|
||||||
guard p.first == 0xff else { return }
|
guard p.first == 0xff else { return }
|
||||||
let details = String(decoding: p.dropFirst(3), as: UTF8.self)
|
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 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))
|
||||||
let plugin = (try? c.nulString()) ?? "mysql_native_password"
|
let plugin = (try? c.nulString()) ?? "mysql_native_password"
|
||||||
// This initial implementation deliberately refuses cleartext remote credentials.
|
if requireTLS {
|
||||||
guard hostIsLoopback else { throw MySQLWireError(message: "Direct remote connections require TLS, which is not yet implemented. Use a local SSH tunnel.") }
|
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,
|
// CLIENT_LONG_PASSWORD, LONG_FLAG, PROTOCOL_41, TRANSACTIONS,
|
||||||
// SECURE_CONNECTION and PLUGIN_AUTH. Do not set DEPRECATE_EOF.
|
// 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)
|
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)
|
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
|
body += Array(user.utf8) + [0, UInt8(response.count)] + response
|
||||||
@@ -293,14 +328,15 @@ final class MySQLWireConnection {
|
|||||||
case 0x03: // Fast authentication accepted; wait for OK.
|
case 0x03: // Fast authentication accepted; wait for OK.
|
||||||
reply = try readPacket()
|
reply = try readPacket()
|
||||||
case 0x04: // Full authentication required.
|
case 0x04: // Full authentication required.
|
||||||
guard hostIsLoopback else {
|
guard hostIsLoopback || tlsActive else {
|
||||||
throw MySQLWireError(message: "Remote full authentication requires TLS (not implemented). Use a trusted local SSH tunnel.")
|
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])
|
try send([0])
|
||||||
} else {
|
} else {
|
||||||
// Request the server's RSA public key (0x02). This is
|
// RSA fallback is restricted to trusted loopback transport.
|
||||||
// intended ONLY for a trusted local loopback connection.
|
|
||||||
try send([0x02])
|
try send([0x02])
|
||||||
let keyPacket = try readPacket()
|
let keyPacket = try readPacket()
|
||||||
try checkError(keyPacket)
|
try checkError(keyPacket)
|
||||||
@@ -415,7 +451,7 @@ private final class PersistentMySQLSessions {
|
|||||||
|
|
||||||
if connections[profile.id] == nil {
|
if connections[profile.id] == nil {
|
||||||
let endpoint = try DBSynSSHTunnels.shared.endpoint(for: profile)
|
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)
|
connection.markHost(endpoint.0)
|
||||||
try connection.authenticate(user: profile.username,
|
try connection.authenticate(user: profile.username,
|
||||||
password: password, database: database)
|
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