Files
SQLWorkbench/WorkbenchReplacement/MySQLProtocol.swift
T
Mark Gregory 0d0b46761f The second file contains a SHA-256 checksum of the SQL backup.
This allows the application to detect whether the backup file has changed or become corrupted.

2. Verify Backup button

The Import/Export window now includes Verify Backup….

Select an exported SQL file, and the application calculates its checksum and compares it with the accompanying checksum file.

3. Automatic verification before import

When importing an SQL file, WorkbenchReplacement checks for an accompanying .sha256 file.

If one exists, its checksum must match before SQL execution begins.

Third-party SQL files without checksum files can still be imported.

4. Large-file handling

Checksum calculation reads files in 1 MB blocks rather than loading the entire file into memory.

This reduces memory usage when verifying large backups.
2026-10-16 22:06:06 +01:00

449 lines
20 KiB
Swift

import Foundation
import CommonCrypto
import CoreFoundation
import Security
// Native MySQL wire protocol client. No external mysql executable is used.
// Initial scope: TCP, mysql_native_password and caching_sha2_password fast authentication.
struct MySQLWireError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
private struct ByteCursor {
let bytes: [UInt8]
var offset = 0
mutating func take(_ n: Int) throws -> [UInt8] {
guard n >= 0, offset + n <= bytes.count else { throw MySQLWireError(message: "Malformed MySQL packet") }
defer { offset += n }
return Array(bytes[offset..<(offset+n)])
}
mutating func u8() throws -> UInt8 { try take(1)[0] }
mutating func u16() throws -> Int { let b = try take(2); return Int(b[0]) | (Int(b[1]) << 8) }
mutating func u32() throws -> Int { let b = try take(4); return Int(b[0]) | (Int(b[1]) << 8) | (Int(b[2]) << 16) | (Int(b[3]) << 24) }
mutating func nulString() throws -> String {
let start = offset
while offset < bytes.count && bytes[offset] != 0 { offset += 1 }
guard offset < bytes.count else { throw MySQLWireError(message: "Unterminated server string") }
let s = String(decoding: bytes[start..<offset], as: UTF8.self)
offset += 1
return s
}
mutating func lenInt() throws -> Int? {
let first = try u8()
switch first {
case 0xfb: return nil
case 0xfc: return try u16()
case 0xfd:
let b = try take(3); return Int(b[0]) | Int(b[1]) << 8 | Int(b[2]) << 16
case 0xfe:
let b = try take(8)
var v: UInt64 = 0
for (i, byte) in b.enumerated() { v |= UInt64(byte) << (8 * i) }
guard v <= UInt64(Int.max) else { throw MySQLWireError(message: "Field too large") }
return Int(v)
default: return Int(first)
}
}
mutating func lenString() throws -> String? {
guard let n = try lenInt() else { return nil }
return String(decoding: try take(n), as: UTF8.self)
}
}
private func le32(_ v: Int) -> [UInt8] { (0..<4).map { UInt8(truncatingIfNeeded: v >> ($0 * 8)) } }
private func sha1(_ data: [UInt8]) -> [UInt8] {
var result = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
data.withUnsafeBytes { ptr in _ = CC_SHA1(ptr.baseAddress, CC_LONG(data.count), &result) }
return result
}
private func sha256(_ data: [UInt8]) -> [UInt8] {
var result = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
data.withUnsafeBytes { ptr in _ = CC_SHA256(ptr.baseAddress, CC_LONG(data.count), &result) }
return result
}
private func authResponse(password: String, salt: [UInt8], plugin: String) throws -> [UInt8] {
if password.isEmpty { return [] }
let pwd = Array(password.utf8)
if plugin == "mysql_native_password" {
let first = sha1(pwd), second = sha1(sha1(pwd))
let last = sha1(salt + second)
return zip(first, last).map { $0 ^ $1 }
}
if plugin == "caching_sha2_password" {
let first = sha256(pwd), second = sha256(sha256(pwd))
let last = sha256(second + salt)
return zip(first, last).map { $0 ^ $1 }
}
throw MySQLWireError(message: "Authentication plugin \(plugin) is not yet supported")
}
// Parse an RSA SubjectPublicKeyInfo PEM supplied by MySQL. Security.framework
// expects the inner PKCS#1 RSAPublicKey DER for SecKeyCreateWithData.
private func mysqlRSAPublicKey(_ packet: [UInt8]) throws -> SecKey {
guard packet.first == 0x01 else {
throw MySQLWireError(message: "Expected caching_sha2_password public-key response")
}
let pem = String(decoding: packet.dropFirst(), as: UTF8.self)
let base64 = pem.components(separatedBy: .newlines)
.filter { !$0.hasPrefix("-----") }.joined()
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let spki = Data(base64Encoded: base64) else {
throw MySQLWireError(message: "Invalid MySQL RSA public key")
}
let der = [UInt8](spki)
func length(_ index: inout Int) throws -> Int {
guard index < der.count else { throw MySQLWireError(message: "Invalid RSA DER length") }
let first = Int(der[index]); index += 1
if first < 128 { return first }
let bytes = first & 0x7f
guard bytes > 0, bytes <= 4, index + bytes <= der.count else {
throw MySQLWireError(message: "Invalid RSA DER length")
}
var value = 0
for _ in 0..<bytes { value = (value << 8) | Int(der[index]); index += 1 }
return value
}
func element(_ index: inout Int, tag: UInt8) throws -> Range<Int> {
guard index < der.count, der[index] == tag else {
throw MySQLWireError(message: "Invalid RSA DER element")
}
index += 1
let count = try length(&index)
guard count <= der.count - index else { throw MySQLWireError(message: "Truncated RSA DER") }
let range = index..<(index + count)
index += count
return range
}
var i = 0
let outer = try element(&i, tag: 0x30)
i = outer.lowerBound
_ = try element(&i, tag: 0x30) // AlgorithmIdentifier
let bits = try element(&i, tag: 0x03)
guard bits.count > 1, der[bits.lowerBound] == 0 else {
throw MySQLWireError(message: "Invalid RSA public key bit string")
}
let pkcs1 = Data(der[(bits.lowerBound + 1)..<bits.upperBound])
var error: Unmanaged<CFError>?
guard let key = SecKeyCreateWithData(pkcs1 as CFData, [
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
kSecAttrKeyClass as String: kSecAttrKeyClassPublic
] as CFDictionary, &error) else {
throw MySQLWireError(message: "Cannot import MySQL RSA public key: \(error?.takeRetainedValue().localizedDescription ?? "unknown error")")
}
return key
}
final class MySQLWireConnection {
private let input: InputStream
private let output: OutputStream
private var sequence: UInt8 = 0
private let maxPacket = 16_777_215
init(host: String, port: Int) throws {
guard (1...65535).contains(port) else { throw MySQLWireError(message: "Invalid port") }
var read: Unmanaged<CFReadStream>?
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] {
var buffer = [UInt8](repeating: 0, count: count)
var done = 0
while done < count {
let n = buffer.withUnsafeMutableBufferPointer { ptr -> Int in
guard let base = ptr.baseAddress else { return -1 }
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
}
return buffer
}
private func readPacket() throws -> [UInt8] {
let header = try readExact(4)
let count = Int(header[0]) | Int(header[1]) << 8 | Int(header[2]) << 16
sequence = header[3] &+ 1
return try readExact(count)
}
private func send(_ payload: [UInt8]) throws {
guard payload.count < maxPacket else { throw MySQLWireError(message: "Query exceeds initial client packet limit") }
let count = payload.count
let packet = [UInt8(count & 255), UInt8((count >> 8) & 255), UInt8((count >> 16) & 255), sequence] + payload
sequence = sequence &+ 1
var written = 0
while written < packet.count {
let n = packet.withUnsafeBufferPointer { ptr -> Int in
guard let base = ptr.baseAddress else { return -1 }
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
}
}
private func checkError(_ p: [UInt8]) throws {
guard p.first == 0xff else { return }
let details = String(decoding: p.dropFirst(3), as: UTF8.self)
throw MySQLWireError(message: "MySQL error: \(details)")
}
func authenticate(user: String, password: String, database: String) throws {
let hello = try readPacket()
var c = ByteCursor(bytes: hello)
let version = try c.u8()
guard version == 10 else { throw MySQLWireError(message: "Unsupported MySQL handshake") }
_ = try c.nulString()
_ = try c.u32()
let salt1 = try c.take(8)
_ = try c.u8()
let lower = try c.u16()
_ = try c.u8() // character set
_ = try c.u16() // status
let upper = try c.u16()
let capabilities = lower | (upper << 16)
let authLen = Int(try c.u8())
_ = try c.take(10)
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.") }
// 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 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
body += Array(plugin.utf8) + [0]
try send(body)
var reply = try readPacket()
try checkError(reply)
var currentPlugin = plugin
var currentSalt = Array(salt)
if reply.first == 0xfe {
var switchCursor = ByteCursor(bytes: Array(reply.dropFirst()))
let newPlugin = try switchCursor.nulString()
let rawSalt = Array(reply.dropFirst(1 + newPlugin.utf8.count + 1))
let newSalt = rawSalt.last == 0 ? Array(rawSalt.dropLast()) : rawSalt
currentPlugin = newPlugin
currentSalt = newSalt
try send(authResponse(password: password, salt: newSalt, plugin: newPlugin))
reply = try readPacket()
try checkError(reply)
}
if reply.first == 0x01 {
guard currentPlugin == "caching_sha2_password", reply.count >= 2 else {
throw MySQLWireError(message: "Unsupported authentication continuation")
}
switch reply[1] {
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.")
}
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.
try send([0x02])
let keyPacket = try readPacket()
try checkError(keyPacket)
let key = try mysqlRSAPublicKey(keyPacket)
guard !currentSalt.isEmpty else {
throw MySQLWireError(message: "Missing MySQL authentication nonce")
}
var scrambled = Array(password.utf8) + [0]
for i in scrambled.indices { scrambled[i] ^= currentSalt[i % currentSalt.count] }
var rsaError: Unmanaged<CFError>?
guard let encrypted = SecKeyCreateEncryptedData(
key, .rsaEncryptionOAEPSHA1, Data(scrambled) as CFData, &rsaError
) as Data? else {
throw MySQLWireError(message: "MySQL RSA encryption failed: \(rsaError?.takeRetainedValue().localizedDescription ?? "unknown error")")
}
try send(Array(encrypted))
}
reply = try readPacket()
default:
throw MySQLWireError(message: "Unsupported caching_sha2_password response: \(reply[1])")
}
}
try checkError(reply)
guard reply.first == 0x00 else { throw MySQLWireError(message: "Authentication did not complete") }
if !database.isEmpty { _ = try query("USE `\(database.replacingOccurrences(of: "`", with: "``"))`") }
}
private var hostIsLoopback = false
func markHost(_ host: String) { hostIsLoopback = ["localhost", "127.0.0.1", "::1"].contains(host.lowercased()) }
// Streams result rows without the query editor's 100,000-row limit.
// The callback is invoked on the session's serial background queue.
func streamFirstColumn(_ sql: String, onRow: (String) throws -> Void) throws -> Int64 {
sequence = 0
try send([0x03] + Array(sql.utf8))
let first = try readPacket()
try checkError(first)
guard first.first != 0x00 else { return 0 }
var cursor = ByteCursor(bytes: first)
guard let columnCount = try cursor.lenInt(), columnCount == 1 else {
throw MySQLWireError(message: "Export query must return exactly one column")
}
_ = try readPacket() // Column metadata
_ = try readPacket() // Column EOF
var count: Int64 = 0
while true {
let packet = try readPacket()
try checkError(packet)
if packet.first == 0xfe && packet.count < 9 { break }
var row = ByteCursor(bytes: packet)
guard let value = try row.lenString() else {
throw MySQLWireError(message: "Unexpected NULL export row")
}
try onRow(value)
count += 1
}
return count
}
func query(_ sql: String) throws -> QueryResult {
sequence = 0
try send([0x03] + Array(sql.utf8))
let first = try readPacket()
try checkError(first)
if first.first == 0x00 { return QueryResult(message: "Statement executed successfully") }
var c = ByteCursor(bytes: first)
guard let columnCount = try c.lenInt(), columnCount <= 4096 else { throw MySQLWireError(message: "Invalid column count") }
var names: [String] = []
for _ in 0..<columnCount {
var field = ByteCursor(bytes: try readPacket())
for _ in 0..<4 { _ = try field.lenString() }
names.append(try field.lenString() ?? "")
}
_ = try readPacket() // column EOF (initial protocol requires EOF)
var rows: [[String]] = []
while true {
let packet = try readPacket()
try checkError(packet)
if packet.first == 0xfe && packet.count < 9 { break }
var row = ByteCursor(bytes: packet)
var values: [String] = []
for _ in 0..<columnCount { values.append(try row.lenString() ?? "NULL") }
rows.append(values)
if rows.count > 100_000 { throw MySQLWireError(message: "Result exceeds 100,000 row safety limit") }
}
return QueryResult(columns: names, rows: rows, message: "\(rows.count) row(s)")
}
}
// A session owns one authenticated TCP connection. Access is serialized by the
// queue below because MySQLWireConnection is not safe for concurrent queries.
private final class PersistentMySQLSessions {
static let shared = PersistentMySQLSessions()
private struct Entry {
let profile: ConnectionProfile
let password: String
let connection: MySQLWireConnection
}
private let queue = DispatchQueue(label: "WorkbenchReplacement.MySQLSessions")
private var connections: [UUID: Entry] = [:]
func execute(profile: ConnectionProfile, database: String,
password: String, sql: String) throws -> QueryResult {
try queue.sync {
// A changed connection profile or password requires a new session.
if let previous = connections[profile.id],
(previous.profile != profile || previous.password != password) {
connections.removeValue(forKey: profile.id)
}
if connections[profile.id] == nil {
let connection = try MySQLWireConnection(host: profile.host, port: profile.port)
connection.markHost(profile.host)
try connection.authenticate(user: profile.username,
password: password, database: database)
connections[profile.id] = Entry(profile: profile,
password: password,
connection: connection)
}
guard let entry = connections[profile.id] else {
throw MySQLWireError(message: "MySQL session was not created")
}
do {
// Crucially, no new authentication takes place here. USE,
// session variables and transactions survive between queries.
return try entry.connection.query(sql)
} catch {
// The wire protocol can be out of sync after a failed query.
// Discard it rather than silently issuing commands on a bad socket.
connections.removeValue(forKey: profile.id)
throw error
}
}
}
func stream(profile: ConnectionProfile, database: String,
password: String, sql: String,
onRow: (String) throws -> Void) throws -> Int64 {
try queue.sync {
guard let entry = connections[profile.id],
entry.profile == profile, entry.password == password else {
throw MySQLWireError(message: "Export session is no longer connected")
}
do {
return try entry.connection.streamFirstColumn(sql, onRow: onRow)
} catch {
connections.removeValue(forKey: profile.id)
throw error
}
}
}
func disconnect(profileID: UUID) {
queue.sync { _ = connections.removeValue(forKey: profileID) }
}
}
enum NativeMySQLRunner {
static func run(profile: ConnectionProfile, database: String,
password: String, sql: String) async throws -> QueryResult {
try await Task.detached(priority: .userInitiated) {
try PersistentMySQLSessions.shared.execute(
profile: profile, database: database, password: password, sql: sql
)
}.value
}
static func stream(profile: ConnectionProfile, database: String,
password: String, sql: String,
onRow: @escaping (String) throws -> Void) async throws -> Int64 {
try await Task.detached(priority: .userInitiated) {
try PersistentMySQLSessions.shared.stream(
profile: profile, database: database, password: password,
sql: sql, onRow: { row in
try Task<Never, Never>.checkCancellation()
try onRow(row)
}
)
}.value
}
static func disconnect(profileID: UUID) {
PersistentMySQLSessions.shared.disconnect(profileID: profileID)
}
}