From 5cdee345522d55f1c0d583e044fc5da5cd592de0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 24 Sep 2026 17:27:23 +0700 Subject: [PATCH 1/2] fix(plugin-mssql): encrypt SQL Server connections set to Required (skip verify) --- CHANGELOG.md | 3 + .../MSSQLConnectionOptions.swift | 8 +- .../MSSQLFreeTDSConfig.swift | 142 ++++- .../MSSQLFreeTDSConfigFile.swift | 99 ++++ .../MSSQLFreeTDSConfigFileTests.swift | 214 +++++++ .../CFreeTDS/include/sybdb.h | 8 +- .../MSSQLDriverPlugin/FreeTDSConnection.swift | 123 ++-- .../MSSQLLoginParameters.swift | 5 +- Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 2 +- .../MSSQLDriverPlugin/MSSQLSSLMapping.swift | 15 +- TablePro/Resources/Localizable.xcstrings | 64 +- .../ConnectionForm/Panes/SSLSections.swift | 5 +- .../CBridges/CFreeTDS/CFreeTDS.h | 4 + .../Drivers/DriverSSLConfiguration.swift | 7 +- .../TableProMobile/Drivers/MSSQLDriver.swift | 4 +- .../TableProMobile/Localizable.xcstrings | 24 + .../ViewModels/ConnectionFormViewModel.swift | 4 +- .../Views/ConnectionFormView.swift | 4 +- .../Drivers/DriverSSLConfigurationTests.swift | 13 +- .../Plugins/MSSQLFreeTDSConfigTests.swift | 181 +++++- .../Plugins/MSSQLLoginParametersTests.swift | 11 +- .../Plugins/MSSQLSSLMappingTests.swift | 15 +- docs/connections/ssl.mdx | 4 +- docs/databases/mssql.mdx | 14 +- scripts/check-mssql-encryption.sh | 546 ++++++++++++++++++ 25 files changed, 1318 insertions(+), 201 deletions(-) create mode 100644 Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfigFile.swift create mode 100644 Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLFreeTDSConfigFileTests.swift create mode 100755 scripts/check-mssql-encryption.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 556ed6c8d1..75a3d6acea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -491,6 +491,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Grid commands enabled in the CSV inspector that did nothing. - Changing a filter or sort leaving the CSV inspector on a later page. - CSV inspector windows always opening as tabs, ignoring the system's tab setting. +- SQL Server Verify CA refusing a certificate that does not name the host. +- SQL Server login failing without a reason for a password longer than 128 bytes. ### Security @@ -515,6 +517,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Writes hidden in a dollar-quoted string, a nested comment or a bracketed identifier skipped Safe Mode on iPhone and iPad. - A quoted Redis command such as `"FLUSHALL"` skipping Safe Mode and the MCP destructive-statement check. - `fts3_tokenizer` reachable from SQL on a libSQL Local File connection, where it could crash the app. +- SQL Server connections set to Required (skip verify) not encrypted past the login. ## [0.75.0] - 2026-09-18 diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift index 5bc9e3fc21..fae57a9b8a 100644 --- a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift @@ -7,7 +7,7 @@ public struct MSSQLConnectionOptions: Sendable, Equatable { public var password: String public var database: String public var schema: String - public var encryptionFlag: String + public var encryptionLevel: MSSQLEncryptionLevel public var applicationName: String public var loginTimeoutSeconds: Int public var authMethod: MSSQLAuthMethod @@ -27,7 +27,7 @@ public struct MSSQLConnectionOptions: Sendable, Equatable { public static let defaultPort = 1_433 public static let defaultSchema = "dbo" public static let defaultApplicationName = "TablePro" - public static let defaultEncryptionFlag = "off" + public static let defaultEncryptionLevel: MSSQLEncryptionLevel = .request public static let defaultLoginTimeoutSeconds = 30 public init( @@ -37,7 +37,7 @@ public struct MSSQLConnectionOptions: Sendable, Equatable { password: String, database: String, schema: String = MSSQLConnectionOptions.defaultSchema, - encryptionFlag: String = MSSQLConnectionOptions.defaultEncryptionFlag, + encryptionLevel: MSSQLEncryptionLevel = MSSQLConnectionOptions.defaultEncryptionLevel, applicationName: String = MSSQLConnectionOptions.defaultApplicationName, loginTimeoutSeconds: Int = MSSQLConnectionOptions.defaultLoginTimeoutSeconds, authMethod: MSSQLAuthMethod = .sqlServer, @@ -59,7 +59,7 @@ public struct MSSQLConnectionOptions: Sendable, Equatable { } self.database = database self.schema = schema - self.encryptionFlag = encryptionFlag + self.encryptionLevel = encryptionLevel self.applicationName = applicationName self.loginTimeoutSeconds = loginTimeoutSeconds } diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfig.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfig.swift index 642a1a3058..516bde038e 100644 --- a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfig.swift +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfig.swift @@ -2,10 +2,10 @@ // MSSQLFreeTDSConfig.swift // TableProMSSQLCore // -// FreeTDS db-lib exposes no per-connection API for certificate validation: DBSETENCRYPT only -// says whether to encrypt. The `ca file` and `check certificate hostname` settings live in -// freetds.conf, so a connection that must verify writes its own one-entry config and points -// FREETDSCONF at it for the duration of the dbopen call. +// db-lib has no per-connection setting for encryption: dbsetlname refuses DBSETENCRYPT with error 20043 and +// dbsetlbool leaves it unimplemented. The level, `ca file` and `check certificate hostname` exist only in +// freetds.conf, so every connection describes its server as one entry of a file the driver owns; see +// MSSQLFreeTDSConfigFile. // import Foundation @@ -22,43 +22,143 @@ public enum MSSQLCertificateVerification: Equatable, Sendable { public var needsAuthority: Bool { self != .none } } -public enum MSSQLFreeTDSConfig { - /// The name the generated entry carries, and the name handed to dbopen in place of host:port. - public static let serverEntryName = "TableProServer" +/// The `encryption` values libtds reads, which decide what the client offers in the TDS prelogin. +/// +/// libtds also takes `off`, which tells the server the client cannot encrypt at all. It is left out: the password then +/// crosses the network in the clear, and a server that forces encryption drops the connection. +public enum MSSQLEncryptionLevel: String, CaseIterable, Equatable, Sendable { + /// Encrypts the login, and the rest of the session only when the server forces encryption. + case request + /// Encrypts the whole session, and fails against a server that cannot. + case require +} + +public enum MSSQLFreeTDSConfigError: LocalizedError, Equatable, Sendable { + case invalidPort(Int) + case unreadableHost + case unreadableAuthorityPath + case unwritable(String) + + public var errorDescription: String? { + switch self { + case .invalidPort(let port): + return String(format: String(localized: "%ld is not a TCP port."), port) + case .unreadableHost: + return String(localized: """ + The host cannot be passed to FreeTDS. Use a host name or an IP address, without spaces, brackets, \ + “=”, “;” or “#”. + """) + case .unreadableAuthorityPath: + return String(localized: """ + The CA certificate path cannot be passed to FreeTDS. Use a shorter path, without “;”, “#” or \ + repeated spaces. + """) + case .unwritable(let detail): + return String(format: String(localized: "The FreeTDS configuration could not be written: %@"), detail) + } + } +} +public enum MSSQLFreeTDSConfig { /// macOS ships the system roots as a PEM bundle, which is what FreeTDS wants. Without this a /// verifying mode would need a CA file from the user even for a public certificate authority. public static let systemTrustStorePath = "/etc/ssl/cert.pem" + /// libtds reads freetds.conf a line at a time into a 256 byte buffer, so a longer line is cut. + static let maximumLineLength = 255 + public static func authorityPath(userSupplied: String?) -> String { guard let userSupplied, !userSupplied.trimmingCharacters(in: .whitespaces).isEmpty else { return systemTrustStorePath } return userSupplied } +} - public static func configuration( +/// One server as libtds reads it from freetds.conf. +/// +/// The entry is named after the host because db-lib sends the name dbopen was given as the server name in the login +/// packet, and that name has to stay the host. It states every option it depends on, even at libtds's own default: a +/// section called `global` holds the defaults for every other one, and a host can have that name. Every value is +/// checked against the way libtds reads the file: `;` and `#` start a comment, runs of white space collapse to one, a +/// `[` opens a section and `=` ends an option's name. A value that would read back differently is refused rather than +/// written. +public struct MSSQLFreeTDSServerEntry: Equatable, Sendable { + public let host: String + public let port: Int + public let encryption: MSSQLEncryptionLevel + public let verification: MSSQLCertificateVerification + public let authorityPath: String? + + public init( host: String, port: Int, - encryptionFlag: String, + encryption: MSSQLEncryptionLevel, verification: MSSQLCertificateVerification, caCertificatePath: String? - ) -> String { - var lines = [ - "[\(serverEntryName)]", + ) throws { + guard (1...65_535).contains(port) else { + throw MSSQLFreeTDSConfigError.invalidPort(port) + } + guard Self.isReadableHost(host) else { + throw MSSQLFreeTDSConfigError.unreadableHost + } + let authorityPath = verification.needsAuthority + ? MSSQLFreeTDSConfig.authorityPath(userSupplied: caCertificatePath) + : nil + if let authorityPath, !Self.isReadableValue(authorityPath, option: "ca file") { + throw MSSQLFreeTDSConfigError.unreadableAuthorityPath + } + self.host = host + self.port = port + self.encryption = encryption + self.verification = verification + self.authorityPath = authorityPath + } + + public init(options: MSSQLConnectionOptions) throws { + try self.init( + host: options.host, + port: options.port, + encryption: options.encryptionLevel, + verification: options.certificateVerification, + caCertificatePath: options.caCertificatePath + ) + } + + /// The server name dbopen is given, and the section libtds looks it up by. + public var name: String { host } + + public var text: String { + [ + "[\(name)]", "\thost = \(host)", "\tport = \(port)", "\ttds version = 7.4", - "\tencryption = \(encryptionFlag)", - ] + "\tencryption = \(encryption.rawValue)", + "\tca file = \(authorityPath ?? "")", + "\tcheck certificate hostname = \(verification.checksHostname ? "yes" : "no")" + ].joined(separator: "\n") + "\n" + } - if verification.needsAuthority { - lines.append("\tca file = \(authorityPath(userSupplied: caCertificatePath))") - } - if verification.checksHostname { - lines.append("\tcheck certificate hostname = yes") - } + private static let hostDelimiters = CharacterSet(charactersIn: "[]=;#") + .union(.whitespacesAndNewlines) + .union(.controlCharacters) + + private static func isReadableHost(_ host: String) -> Bool { + guard !host.isEmpty, host.rangeOfCharacter(from: hostDelimiters) == nil else { return false } + return fitsOnOneLine("[\(host)]") && fitsOnOneLine("\thost = \(host)") + } + + private static func isReadableValue(_ value: String, option: String) -> Bool { + guard value.rangeOfCharacter(from: CharacterSet(charactersIn: ";#").union(.controlCharacters)) == nil, + value == value.trimmingCharacters(in: .whitespaces), + !value.contains(" ") + else { return false } + return fitsOnOneLine("\t\(option) = \(value)") + } - return lines.joined(separator: "\n") + "\n" + private static func fitsOnOneLine(_ line: String) -> Bool { + line.utf8.count <= MSSQLFreeTDSConfig.maximumLineLength } } diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfigFile.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfigFile.swift new file mode 100644 index 0000000000..f6cac2a450 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfigFile.swift @@ -0,0 +1,99 @@ +// +// MSSQLFreeTDSConfigFile.swift +// TableProMSSQLCore +// + +import Foundation + +/// The freetds.conf every SQL Server connection in the process reads, named to db-lib once with dbsetifile. +/// +/// libtds reads the file when dbopen starts, so an entry is held for exactly as long as one dbopen. Entries for +/// different host names sit side by side, and a connection never waits on one to another host name, however long that +/// server takes to answer. libtds applies every section whose name matches, whatever its case, so two connections that +/// describe one host name differently, with another port or another mode, cannot both be in the file: the second waits +/// until the first dbopen returns. Every +/// change replaces the file whole, which leaves a dbopen that already opened it reading the version it opened, and the +/// file is removed once nothing holds an entry. +public final class MSSQLFreeTDSConfigFile: @unchecked Sendable { + public let path: String + + private let condition = NSCondition() + private var leases: [String: Lease] = [:] + + private struct Lease { + let entry: MSSQLFreeTDSServerEntry + var holders: Int + } + + public init(path: String) { + self.path = path + } + + public func withEntry(_ entry: MSSQLFreeTDSServerEntry, _ body: () throws -> T) throws -> T { + try acquire(entry) + defer { release(entry) } + return try body() + } + + private static func key(for entry: MSSQLFreeTDSServerEntry) -> String { + entry.name.lowercased() + } + + private func acquire(_ entry: MSSQLFreeTDSServerEntry) throws { + let key = Self.key(for: entry) + condition.lock() + defer { condition.unlock() } + while let lease = leases[key], lease.entry != entry { + condition.wait() + } + if var lease = leases[key] { + lease.holders += 1 + leases[key] = lease + return + } + leases[key] = Lease(entry: entry, holders: 1) + do { + try write() + } catch { + leases[key] = nil + condition.broadcast() + throw error + } + } + + private func release(_ entry: MSSQLFreeTDSServerEntry) { + let key = Self.key(for: entry) + condition.lock() + defer { condition.unlock() } + guard var lease = leases[key] else { return } + lease.holders -= 1 + guard lease.holders == 0 else { + leases[key] = lease + return + } + leases[key] = nil + try? write() + condition.broadcast() + } + + private func write() throws { + guard !leases.isEmpty else { + try? FileManager.default.removeItem(atPath: path) + return + } + let text = leases.keys.sorted().compactMap { leases[$0]?.entry.text }.joined(separator: "\n") + let staging = path + ".staging" + guard FileManager.default.createFile( + atPath: staging, + contents: Data(text.utf8), + attributes: [.posixPermissions: 0o600] + ) else { + throw MSSQLFreeTDSConfigError.unwritable(staging) + } + guard rename(staging, path) == 0 else { + let reason = String(cString: strerror(errno)) + try? FileManager.default.removeItem(atPath: staging) + throw MSSQLFreeTDSConfigError.unwritable(reason) + } + } +} diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLFreeTDSConfigFileTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLFreeTDSConfigFileTests.swift new file mode 100644 index 0000000000..ec2640e479 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLFreeTDSConfigFileTests.swift @@ -0,0 +1,214 @@ +import Foundation +import TableProMSSQLCore +import Testing + +@Suite("MSSQL FreeTDS config file") +struct MSSQLFreeTDSConfigFileTests { + private let directory: String + private let file: MSSQLFreeTDSConfigFile + + init() throws { + directory = (NSTemporaryDirectory() as NSString).appendingPathComponent("mssql-config-file-\(UUID().uuidString)") + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + file = MSSQLFreeTDSConfigFile(path: (directory as NSString).appendingPathComponent("freetds.conf")) + } + + private func entry( + _ host: String, + port: Int = 1_433, + encryption: MSSQLEncryptionLevel = .require + ) throws -> MSSQLFreeTDSServerEntry { + try MSSQLFreeTDSServerEntry( + host: host, + port: port, + encryption: encryption, + verification: .none, + caCertificatePath: nil + ) + } + + private func contents() -> String? { + try? String(contentsOfFile: file.path, encoding: .utf8) + } + + @Test("The entry is in the file while the body runs, and the file is gone after") + func entryLivesForTheBody() throws { + let server = try entry("db.example.com") + let seen = try file.withEntry(server) { contents() } + + #expect(seen == server.text) + #expect(!FileManager.default.fileExists(atPath: file.path)) + } + + @Test("Only the owner can read the file") + func fileIsPrivate() throws { + let permissions = try file.withEntry(try entry("db.example.com")) { + try FileManager.default.attributesOfItem(atPath: file.path)[.posixPermissions] as? Int + } + + #expect(permissions == 0o600) + } + + @Test("Entries for two hosts are in the file together") + func twoHostsSideBySide() throws { + let first = try entry("alpha.example.com", encryption: .request) + let second = try entry("beta.example.com", encryption: .require) + + let seen = try file.withEntry(first) { + try file.withEntry(second) { contents() } + } + + #expect(seen?.contains(first.text) == true) + #expect(seen?.contains(second.text) == true) + } + + @Test("A second holder of the same entry keeps it in the file until both are done") + func sharedEntryOutlivesTheInnerHolder() throws { + let server = try entry("db.example.com") + + let afterInner = try file.withEntry(server) { + try file.withEntry(server) {} + return contents() + } + + #expect(afterInner == server.text) + #expect(!FileManager.default.fileExists(atPath: file.path)) + } + + @Test("Host names that differ only in case are one entry, as libtds reads them") + func caseInsensitiveNames() throws { + let lower = try entry("db.example.com", encryption: .request) + let upper = try entry("DB.example.com", encryption: .require) + let order = OrderLog() + let released = DispatchSemaphore(value: 0) + let holding = DispatchSemaphore(value: 0) + + DispatchQueue.global().async { + try? file.withEntry(lower) { + order.append("lower in") + holding.signal() + released.wait() + order.append("lower out") + } + } + holding.wait() + let done = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + try? file.withEntry(upper) { order.append("upper in") } + done.signal() + } + Thread.sleep(forTimeInterval: 0.2) + released.signal() + done.wait() + + #expect(order.entries == ["lower in", "lower out", "upper in"]) + } + + @Test("A different entry for a host waits for the first dbopen, then finds its own settings") + func conflictingEntryWaits() throws { + let plain = try entry("db.example.com", encryption: .request) + let encrypted = try entry("db.example.com", encryption: .require) + let order = OrderLog() + let holding = DispatchSemaphore(value: 0) + let released = DispatchSemaphore(value: 0) + let done = DispatchSemaphore(value: 0) + let seenByEncrypted = Box(nil) + + DispatchQueue.global().async { + try? file.withEntry(plain) { + order.append("plain in") + holding.signal() + released.wait() + order.append("plain out") + } + } + holding.wait() + DispatchQueue.global().async { + try? file.withEntry(encrypted) { + order.append("encrypted in") + seenByEncrypted.value = contents() + } + done.signal() + } + Thread.sleep(forTimeInterval: 0.2) + #expect(order.entries == ["plain in"]) + released.signal() + done.wait() + + #expect(order.entries == ["plain in", "plain out", "encrypted in"]) + #expect(seenByEncrypted.value == encrypted.text) + } + + @Test("A connect to another host does not wait on one that has not returned") + func otherHostsDoNotWait() throws { + let slow = try entry("slow.example.com") + let fast = try entry("fast.example.com") + let holding = DispatchSemaphore(value: 0) + let released = DispatchSemaphore(value: 0) + let finished = DispatchSemaphore(value: 0) + + DispatchQueue.global().async { + try? file.withEntry(slow) { + holding.signal() + released.wait() + } + finished.signal() + } + holding.wait() + let ranWhileSlowHeld = try file.withEntry(fast) { contents()?.contains(fast.text) == true } + released.signal() + finished.wait() + + #expect(ranWhileSlowHeld) + } + + @Test("A file that cannot be written fails the connect and runs nothing") + func unwritableFileThrows() throws { + let missing = MSSQLFreeTDSConfigFile(path: (directory as NSString).appendingPathComponent("no/such/freetds.conf")) + var ran = false + + #expect(throws: MSSQLFreeTDSConfigError.self) { + try missing.withEntry(try entry("db.example.com")) { ran = true } + } + #expect(!ran) + } +} + +private final class OrderLog: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [String] = [] + + func append(_ entry: String) { + lock.lock() + recorded.append(entry) + lock.unlock() + } + + var entries: [String] { + lock.lock() + defer { lock.unlock() } + return recorded + } +} + +private final class Box: @unchecked Sendable { + private let lock = NSLock() + private var stored: Value + + init(_ value: Value) { + stored = value + } + + var value: Value { + get { + lock.lock() + defer { lock.unlock() } + return stored + } + set { + lock.lock() + stored = newValue + lock.unlock() + } + } +} diff --git a/Plugins/MSSQLDriverPlugin/CFreeTDS/include/sybdb.h b/Plugins/MSSQLDriverPlugin/CFreeTDS/include/sybdb.h index ca51ce6fc4..3297e0dcd5 100644 --- a/Plugins/MSSQLDriverPlugin/CFreeTDS/include/sybdb.h +++ b/Plugins/MSSQLDriverPlugin/CFreeTDS/include/sybdb.h @@ -53,7 +53,6 @@ typedef struct loginrec LOGINREC; #define DBSETNATLANG 7 #define DBSETCHARSET 10 #define DBSETPACKET 11 -#define DBSETENCRYPT 12 #define DBSETDBNAME 14 #define DBSETSERVERPRINCIPAL 103 @@ -66,9 +65,6 @@ typedef struct loginrec LOGINREC; // TDS version constants — verified against FreeTDS 1.4 sybdb.h #define DBVERSION_74 8 // TDS 7.4 (SQL Server 2012+) -// Encryption -#define ENCRYPT_OFF 0 - // Error handler return codes #define INT_CANCEL 2 #define INT_CONTINUE 1 @@ -89,6 +85,10 @@ extern void dbloginfree(LOGINREC *loginrec); extern RETCODE dbsetlname(LOGINREC *loginrec, const char *value, int which); extern RETCODE dbsetlversion(LOGINREC *loginrec, BYTE version); +// The file libtds reads a server's freetds.conf entry from before any other: the encryption level, the CA file and +// the hostname check exist only there, because dbsetlname has no field for them. +extern void dbsetifile(char *filename); + // Microsoft Entra ID access token, sent in the LOGIN7 FEDAUTH feature extension instead of a // user name and password. Added by scripts/patches/freetds/freetds-fedauth.patch; upstream // FreeTDS has no federated authentication. Separate from dbsetlname() because that caps values diff --git a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift index f200932478..c07e13e1a7 100644 --- a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift +++ b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift @@ -162,8 +162,13 @@ nonisolated private func freetdsUnregister(_ dbproc: UnsafeMutablePointer UnsafeMutablePointer { + let entry: MSSQLFreeTDSServerEntry + do { + entry = try MSSQLFreeTDSServerEntry(options: options) + } catch { + throw MSSQLCoreError.connectionFailed(error.localizedDescription) + } guard let login = dblogin() else { throw MSSQLCoreError.connectionFailed("Failed to create login") } defer { dbloginfree(login) } + try configure(login) + + let opened: UnsafeMutablePointer? + do { + opened = try freetdsConfigFile.withEntry(entry) { + freetdsClearError(for: nil) + return withKerberosEnvironmentIfNeeded { dbopen(login, entry.name) } + } + } catch { + throw MSSQLCoreError.connectionFailed(error.localizedDescription) + } + guard let proc = opened else { + throw openFailure() + } + return proc + } + private func configure(_ login: UnsafeMutablePointer) throws { for parameter in MSSQLLoginParameters.build( user: options.user, password: options.password, applicationName: options.applicationName, - encryptionFlag: options.encryptionFlag, database: options.database ) { - _ = dbsetlname(login, parameter.value, parameter.field.dbsetName) + guard dbsetlname(login, parameter.value, parameter.field.dbsetName) == SUCCEED else { + throw MSSQLCoreError.connectionFailed(parameter.field.refusal) + } + } + guard dbsetlversion(login, UInt8(DBVERSION_74)) == SUCCEED else { + throw MSSQLCoreError.connectionFailed(String(localized: "FreeTDS could not set up the login.")) } - _ = dbsetlversion(login, UInt8(DBVERSION_74)) _ = dbsetlogintime(Int32(options.loginTimeoutSeconds)) // Entra ID replaces the user name and password with an access token in the LOGIN7 @@ -336,68 +368,25 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { // resolve the canonical host + realm up front and hand FreeTDS the full SPN, so cross-realm // and short-name/CNAME hosts authenticate like the JDBC driver does. if options.authMethod == .windows, let spn = options.kerberosServicePrincipal, !spn.isEmpty { - _ = dbsetlname(login, spn, Int32(DBSETSERVERPRINCIPAL)) - } - #endif - - freetdsClearError(for: nil) - let verifies = options.certificateVerification != .none - let serverName = verifies ? MSSQLFreeTDSConfig.serverEntryName : "\(options.host):\(options.port)" - guard let proc = withFreeTDSConfigIfNeeded({ - self.withKerberosEnvironmentIfNeeded { dbopen(login, serverName) } - }) else { - let detail = freetdsGetError(for: nil) - let msg = detail.isEmpty ? "Check host, port, credentials, and TLS settings" : detail - if let kind = MSSQLTLSClassifier.classifySSLError(detail) { - throw MSSQLCoreError.tlsHandshakeFailed(kind: kind, serverMessage: detail) - } - if options.authMethod == .windows, let kind = MSSQLKerberosClassifier.classify(detail) { - throw MSSQLCoreError.kerberosAuthFailed(kind: kind, serverMessage: detail) + guard dbsetlname(login, spn, Int32(DBSETSERVERPRINCIPAL)) == SUCCEED else { + throw MSSQLCoreError.connectionFailed( + String(localized: "The Kerberos service principal name is longer than the 128 bytes FreeTDS takes.") + ) } - throw MSSQLCoreError.connectionFailed("Failed to connect to \(options.host):\(options.port): \(msg)") } - return proc + #endif } - /// A verifying mode needs `ca file` and `check certificate hostname`, which dblib cannot set. - /// The generated config is written 0600 and FREETDSCONF points at it only for this dbopen, so - /// a machine's own freetds.conf is untouched on every other connection. - private func withFreeTDSConfigIfNeeded( - _ body: () -> UnsafeMutablePointer? - ) -> UnsafeMutablePointer? { - guard options.certificateVerification != .none else { return body() } - - let contents = MSSQLFreeTDSConfig.configuration( - host: options.host, - port: options.port, - encryptionFlag: options.encryptionFlag, - verification: options.certificateVerification, - caCertificatePath: options.caCertificatePath - ) - - let path = NSTemporaryDirectory() + "tablepro-freetds-\(UUID().uuidString).conf" - guard let data = contents.data(using: .utf8), - FileManager.default.createFile( - atPath: path, - contents: data, - attributes: [.posixPermissions: 0o600] - ) else { - return body() + private func openFailure() -> MSSQLCoreError { + let detail = freetdsGetError(for: nil) + let msg = detail.isEmpty ? "Check host, port, credentials, and TLS settings" : detail + if let kind = MSSQLTLSClassifier.classifySSLError(detail) { + return .tlsHandshakeFailed(kind: kind, serverMessage: detail) } - - Self.freetdsConfEnvLock.lock() - let previous = getenv("FREETDSCONF").map { String(cString: $0) } - setenv("FREETDSCONF", path, 1) - defer { - if let previous { - setenv("FREETDSCONF", previous, 1) - } else { - unsetenv("FREETDSCONF") - } - Self.freetdsConfEnvLock.unlock() - try? FileManager.default.removeItem(atPath: path) + if options.authMethod == .windows, let kind = MSSQLKerberosClassifier.classify(detail) { + return .kerberosAuthFailed(kind: kind, serverMessage: detail) } - return body() + return .connectionFailed("Failed to connect to \(options.host):\(options.port): \(msg)") } private func withKerberosEnvironmentIfNeeded( @@ -755,10 +744,22 @@ nonisolated private extension MSSQLLoginField { case .application: return Int32(DBSETAPP) case .nationalLanguage: return Int32(DBSETNATLANG) case .charset: return Int32(DBSETCHARSET) - case .encryption: return Int32(DBSETENCRYPT) case .database: return Int32(DBSETDBNAME) } } + + var refusal: String { + switch self { + case .user: + return String(localized: "The user name is longer than the 128 bytes FreeTDS takes.") + case .password: + return String(localized: "The password is longer than the 128 bytes FreeTDS takes.") + case .database: + return String(localized: "The database name is longer than the 128 bytes FreeTDS takes.") + case .application, .nationalLanguage, .charset: + return String(localized: "FreeTDS could not set up the login.") + } + } } /// How much of a request one call keeps. diff --git a/Plugins/MSSQLDriverPlugin/MSSQLLoginParameters.swift b/Plugins/MSSQLDriverPlugin/MSSQLLoginParameters.swift index 8bcee0e049..f4ac99e134 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLLoginParameters.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLLoginParameters.swift @@ -6,7 +6,6 @@ nonisolated enum MSSQLLoginField: Equatable { case application case nationalLanguage case charset - case encryption case database } @@ -23,7 +22,6 @@ nonisolated enum MSSQLLoginParameters { user: String, password: String, applicationName: String, - encryptionFlag: String, database: String ) -> [MSSQLLoginParameter] { var parameters = [ @@ -31,8 +29,7 @@ nonisolated enum MSSQLLoginParameters { MSSQLLoginParameter(field: .password, value: password), MSSQLLoginParameter(field: .application, value: applicationName), MSSQLLoginParameter(field: .nationalLanguage, value: nationalLanguage), - MSSQLLoginParameter(field: .charset, value: charset), - MSSQLLoginParameter(field: .encryption, value: encryptionFlag) + MSSQLLoginParameter(field: .charset, value: charset) ] if !database.isEmpty { parameters.append(MSSQLLoginParameter(field: .database, value: database)) diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 34a6f67707..84e44a4688 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -365,7 +365,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { password: config.password, database: config.database, schema: _currentSchema, - encryptionFlag: MSSQLSSLMapping.freetdsEncryptionFlag(for: config.ssl.mode), + encryptionLevel: MSSQLSSLMapping.encryptionLevel(for: config.ssl.mode), authMethod: authMethod, kerberosCachePath: kerberosCachePath, kerberosServicePrincipal: kerberosServicePrincipal diff --git a/Plugins/MSSQLDriverPlugin/MSSQLSSLMapping.swift b/Plugins/MSSQLDriverPlugin/MSSQLSSLMapping.swift index 0eab283dbe..4e0d711f84 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLSSLMapping.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLSSLMapping.swift @@ -2,15 +2,16 @@ import Foundation import TableProMSSQLCore import TableProPluginKit -/// FreeTDS dblib reads the encryption level via DBSETENCRYPT. Accepted values come from libtds: -/// "off", "request", "require", "strict". Certificate validation is not reachable through dblib -/// at all, so a verifying mode also produces a generated freetds.conf; see MSSQLFreeTDSConfig. +/// The FreeTDS encryption level and certificate checks behind each SSL mode. libtds reads both from the connection's +/// freetds.conf entry and from nowhere else; see MSSQLFreeTDSServerEntry. +/// +/// Disabled asks for `request`, as SQL Server's own drivers do with encryption off: the login is encrypted and the rest +/// of the session is not, unless the server forces encryption. enum MSSQLSSLMapping { - static func freetdsEncryptionFlag(for mode: SSLMode) -> String { + static func encryptionLevel(for mode: SSLMode) -> MSSQLEncryptionLevel { switch mode { - case .disabled: return "off" - case .preferred: return "request" - case .required, .verifyCa, .verifyIdentity: return "require" + case .disabled, .preferred: return .request + case .required, .verifyCa, .verifyIdentity: return .require } } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index cba8f37b3e..9656139ca9 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -146427,40 +146427,6 @@ }, "SQL Server bacpac" : { - }, - "SQL Server connections use the system trust store. Per-connection CA and client certificate paths are not supported by FreeTDS dblib; configure them in `freetds.conf` if you need a custom trust anchor." : { - "localizations" : { - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "SQL Server 연결은 시스템 신뢰 저장소를 사용합니다. FreeTDS dblib은 연결별 CA 및 클라이언트 인증서 경로를 지원하지 않습니다. 사용자 지정 신뢰 앵커가 필요한 경우 `freetds.conf`에서 구성하십시오." - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "SQL Server bağlantıları sistem güven deposunu kullanır. FreeTDS dblib, bağlantıya özel CA ve istemci sertifikası yollarını desteklemez; özel bir güven kaynağına ihtiyacınız varsa bunları `freetds.conf` dosyasında yapılandırın." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kết nối SQL Server dùng kho tin cậy của hệ thống. FreeTDS dblib không hỗ trợ đường dẫn CA và chứng chỉ client theo từng kết nối; hãy cấu hình trong `freetds.conf` nếu bạn cần trust anchor tùy chỉnh." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "SQL Server 连接使用系统信任存储。FreeTDS dblib 不支持按连接配置 CA 和客户端证书路径;如需自定义信任锚,请在 `freetds.conf` 中配置。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "SQL Server 連線使用系統信任存放區。FreeTDS dblib 不支援按連線設定 CA 和用戶端憑證路徑;如果你需要自訂信任錨點,請在 `freetds.conf` 中設定。" - } - } - } }, "SQL statements" : { @@ -184122,6 +184088,36 @@ }, "%d more errors were not kept." : { + }, + "FreeTDS could not set up the login." : { + + }, + "The Kerberos service principal name is longer than the 128 bytes FreeTDS takes." : { + + }, + "The user name is longer than the 128 bytes FreeTDS takes." : { + + }, + "The password is longer than the 128 bytes FreeTDS takes." : { + + }, + "The database name is longer than the 128 bytes FreeTDS takes." : { + + }, + "%ld is not a TCP port." : { + + }, + "The host cannot be passed to FreeTDS. Use a host name or an IP address, without spaces, brackets, “=”, “;” or “#”." : { + + }, + "The CA certificate path cannot be passed to FreeTDS. Use a shorter path, without “;”, “#” or repeated spaces." : { + + }, + "The FreeTDS configuration could not be written: %@" : { + + }, + "Verify CA and Verify Identity check the server certificate against the system trust store. A custom CA and client certificates are not available for SQL Server." : { + } }, "version" : "1.1" diff --git a/TablePro/Views/ConnectionForm/Panes/SSLSections.swift b/TablePro/Views/ConnectionForm/Panes/SSLSections.swift index a35aaa5872..fe7ecffbd2 100644 --- a/TablePro/Views/ConnectionForm/Panes/SSLSections.swift +++ b/TablePro/Views/ConnectionForm/Panes/SSLSections.swift @@ -59,9 +59,8 @@ struct SSLSections: View { if !supportsPerConnectionCertPaths { Section { Text(String(localized: """ - SQL Server connections use the system trust store. Per-connection CA and client certificate \ - paths are not supported by FreeTDS dblib; configure them in `freetds.conf` if you need a \ - custom trust anchor. + Verify CA and Verify Identity check the server certificate against the system trust \ + store. A custom CA and client certificates are not available for SQL Server. """)) .font(.caption) .foregroundStyle(.secondary) diff --git a/TableProMobile/TableProMobile/CBridges/CFreeTDS/CFreeTDS.h b/TableProMobile/TableProMobile/CBridges/CFreeTDS/CFreeTDS.h index 3b9e41487d..0729f54bc5 100644 --- a/TableProMobile/TableProMobile/CBridges/CFreeTDS/CFreeTDS.h +++ b/TableProMobile/TableProMobile/CBridges/CFreeTDS/CFreeTDS.h @@ -10,4 +10,8 @@ // for iOS. extern DBINT dbcount(DBPROCESS *dbproc); +// Missing from the same header, and in the library the same way. FreeTDSConnection.swift names the freetds.conf that +// carries every connection's encryption level with it, because dbsetlname has no field for one. +extern void dbsetifile(char *filename); + #endif diff --git a/TableProMobile/TableProMobile/Drivers/DriverSSLConfiguration.swift b/TableProMobile/TableProMobile/Drivers/DriverSSLConfiguration.swift index dd46bff803..813dcae809 100644 --- a/TableProMobile/TableProMobile/Drivers/DriverSSLConfiguration.swift +++ b/TableProMobile/TableProMobile/Drivers/DriverSSLConfiguration.swift @@ -1,5 +1,6 @@ import Foundation import TableProModels +import TableProMSSQLCore import TableProOracleCore nonisolated struct DriverSSLConfiguration: Equatable, Sendable { @@ -58,10 +59,10 @@ nonisolated struct DriverSSLConfiguration: Equatable, Sendable { } } - var freetdsEncryptionFlag: String { + var mssqlEncryptionLevel: MSSQLEncryptionLevel { switch mode { - case .disable: return "off" - case .require, .verifyCa, .verifyFull: return "require" + case .disable: return .request + case .require, .verifyCa, .verifyFull: return .require } } diff --git a/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift index bc374f296b..379d1497ce 100644 --- a/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift @@ -41,10 +41,10 @@ nonisolated final class MSSQLDriver: DatabaseDriver, @unchecked Sendable { password: password ?? "", database: connection.database, schema: MSSQLConnectionOptions.schema(from: connection.additionalFields), - encryptionFlag: DriverSSLConfiguration( + encryptionLevel: DriverSSLConfiguration( sslEnabled: connection.sslEnabled, configuration: connection.sslConfiguration - ).freetdsEncryptionFlag, + ).mssqlEncryptionLevel, loginTimeoutSeconds: Int(connection.additionalFields["mssqlLoginTimeout"] ?? "") ?? MSSQLConnectionOptions.defaultLoginTimeoutSeconds, authMethod: authMethod ) diff --git a/TableProMobile/TableProMobile/Localizable.xcstrings b/TableProMobile/TableProMobile/Localizable.xcstrings index 119b78a36d..9da753336b 100644 --- a/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/TableProMobile/TableProMobile/Localizable.xcstrings @@ -21066,6 +21066,30 @@ } } } + }, + "%ld is not a TCP port." : { + + }, + "The host cannot be passed to FreeTDS. Use a host name or an IP address, without spaces, brackets, “=”, “;” or “#”." : { + + }, + "The CA certificate path cannot be passed to FreeTDS. Use a shorter path, without “;”, “#” or repeated spaces." : { + + }, + "The FreeTDS configuration could not be written: %@" : { + + }, + "FreeTDS could not set up the login." : { + + }, + "The user name is longer than the 128 bytes FreeTDS takes." : { + + }, + "The password is longer than the 128 bytes FreeTDS takes." : { + + }, + "The database name is longer than the 128 bytes FreeTDS takes." : { + } }, "version" : "1.0" diff --git a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift index 58f23cfba1..b7731325f2 100644 --- a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift @@ -137,8 +137,8 @@ final class ConnectionFormViewModel { username = conn.username database = conn.database sslEnabled = conn.sslEnabled - // Coerce verify modes to .require: FreeTDS doesn't honor per-connection cert verification - // (MSSQLSSLMapping treats verify* as "require"). Matches what the driver actually does. + // Coerce verify modes to .require: the iOS driver encrypts without checking the certificate + // (DriverSSLConfiguration maps verify* to "require"). Matches what the driver actually does. let storedMode = conn.sslConfiguration?.mode ?? .disable mssqlSSLMode = (storedMode == .verifyCa || storedMode == .verifyFull) ? .require : storedMode oracleSSLMode = storedMode diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index 2f7b86c517..3c3d7a8c4f 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -104,8 +104,8 @@ struct ConnectionFormView: View { Text(String(localized: "Verify Identity")).tag(SSLConfiguration.SSLMode.verifyFull) } } else if viewModel.type == .mssql { - // FreeTDS db-lib only honors on/off encryption (DBSETENCRYPT). Per-connection - // cert chain verification is not exposed, so only Disabled and Required are listed. + // The iOS driver encrypts without checking the certificate, because this form collects + // no CA file for FreeTDS, so only Disabled and Required are listed. // See Plugins/MSSQLDriverPlugin/MSSQLSSLMapping.swift for the FreeTDS contract. Picker(String(localized: "SSL Mode"), selection: $viewModel.mssqlSSLMode) { Text(String(localized: "Disabled")).tag(SSLConfiguration.SSLMode.disable) diff --git a/TableProMobile/TableProMobileTests/Drivers/DriverSSLConfigurationTests.swift b/TableProMobile/TableProMobileTests/Drivers/DriverSSLConfigurationTests.swift index 09d2513fc3..56520ca230 100644 --- a/TableProMobile/TableProMobileTests/Drivers/DriverSSLConfigurationTests.swift +++ b/TableProMobile/TableProMobileTests/Drivers/DriverSSLConfigurationTests.swift @@ -1,6 +1,7 @@ import Foundation @testable import TableProMobile import TableProModels +import TableProMSSQLCore import Testing @Suite("DriverSSLConfiguration") @@ -53,12 +54,12 @@ struct DriverSSLConfigurationTests { #expect(DriverSSLConfiguration(mode: .verifyFull).postgresSSLMode == "verify-full") } - @Test("freetds encryption flag never downgrades verify modes to plaintext") - func freetdsMapping() { - #expect(DriverSSLConfiguration(mode: .disable).freetdsEncryptionFlag == "off") - #expect(DriverSSLConfiguration(mode: .require).freetdsEncryptionFlag == "require") - #expect(DriverSSLConfiguration(mode: .verifyCa).freetdsEncryptionFlag == "require") - #expect(DriverSSLConfiguration(mode: .verifyFull).freetdsEncryptionFlag == "require") + @Test("SQL Server encryption never downgrades verify modes to plaintext, and disable still encrypts the login") + func mssqlMapping() { + #expect(DriverSSLConfiguration(mode: .disable).mssqlEncryptionLevel == .request) + #expect(DriverSSLConfiguration(mode: .require).mssqlEncryptionLevel == .require) + #expect(DriverSSLConfiguration(mode: .verifyCa).mssqlEncryptionLevel == .require) + #expect(DriverSSLConfiguration(mode: .verifyFull).mssqlEncryptionLevel == .require) } @Test("CA path is ignored for non-verify modes") diff --git a/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift b/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift index c90a1b680a..2cae68258f 100644 --- a/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift +++ b/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift @@ -10,19 +10,25 @@ import Testing @Suite("MSSQL FreeTDS config") struct MSSQLFreeTDSConfigTests { - private func configuration( - verification: MSSQLCertificateVerification, + private func entry( + host: String = "db.example.com", + port: Int = 1_433, + mode: SSLMode, caCertificatePath: String? = nil - ) -> String { - MSSQLFreeTDSConfig.configuration( - host: "db.example.com", - port: 1_433, - encryptionFlag: "require", - verification: verification, + ) throws -> MSSQLFreeTDSServerEntry { + try MSSQLFreeTDSServerEntry( + host: host, + port: port, + encryption: MSSQLSSLMapping.encryptionLevel(for: mode), + verification: MSSQLSSLMapping.certificateVerification(for: mode), caCertificatePath: caCertificatePath ) } + private func lines(_ entry: MSSQLFreeTDSServerEntry) -> [String] { + entry.text.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) } + } + @Test("Every SSL mode maps to the verification it advertises") func modeMapping() { #expect(MSSQLSSLMapping.certificateVerification(for: .disabled) == .none) @@ -32,32 +38,75 @@ struct MSSQLFreeTDSConfigTests { #expect(MSSQLSSLMapping.certificateVerification(for: .verifyIdentity) == .chainAndHostname) } - @Test("Verify CA pins an authority but does not check the hostname") - func verifyCaConfiguration() { - let text = configuration(verification: .chain) + @Test("Every SSL mode writes an entry, and each carries the level its mode maps to") + func everyModeCarriesItsLevel() throws { + for mode in SSLMode.allCases { + let level = MSSQLSSLMapping.encryptionLevel(for: mode).rawValue + #expect(lines(try entry(mode: mode)).contains("encryption = \(level)"), "\(mode)") + } + #expect(lines(try entry(mode: .required)).contains("encryption = require")) + } + + @Test("Each level is written as libtds spells it") + func levelSpelling() throws { + let spellings: [(level: MSSQLEncryptionLevel, spelling: String)] = [ + (.request, "request"), (.require, "require") + ] + for (level, spelling) in spellings { + let server = try MSSQLFreeTDSServerEntry( + host: "db", port: 1_433, encryption: level, verification: .none, caCertificatePath: nil + ) + #expect(lines(server).contains("encryption = \(spelling)"), "\(level)") + } + } + + @Test("The entry is named after the host, so the login packet carries the host as its server name") + func namedAfterTheHost() throws { + let server = try entry(host: "myserver.database.windows.net", port: 1_433, mode: .required) + + #expect(server.name == "myserver.database.windows.net") + #expect(lines(server).prefix(4) == [ + "[myserver.database.windows.net]", + "host = myserver.database.windows.net", + "port = 1433", + "tds version = 7.4" + ]) + } + + @Test("Every entry states the authority and the hostname check, so a section named global cannot lend its own") + func everyEntryIsSelfContained() throws { + for mode in SSLMode.allCases { + let options = lines(try entry(mode: mode)).dropFirst().map { + $0.split(separator: "=", maxSplits: 1).first.map { $0.trimmingCharacters(in: .whitespaces) } ?? "" + } + #expect(options == ["host", "port", "tds version", "encryption", "ca file", "check certificate hostname"], + "\(mode)") + } + } + + @Test("Verify CA pins an authority and turns off the hostname check libtds makes by default") + func verifyCaConfiguration() throws { + let text = lines(try entry(mode: .verifyCa)) - #expect(text.contains("[\(MSSQLFreeTDSConfig.serverEntryName)]")) - #expect(text.contains("host = db.example.com")) - #expect(text.contains("port = 1433")) #expect(text.contains("encryption = require")) #expect(text.contains("ca file = \(MSSQLFreeTDSConfig.systemTrustStorePath)")) - #expect(!text.contains("check certificate hostname")) + #expect(text.contains("check certificate hostname = no")) } @Test("Verify Identity also checks the hostname") - func verifyIdentityConfiguration() { - let text = configuration(verification: .chainAndHostname) + func verifyIdentityConfiguration() throws { + let text = lines(try entry(mode: .verifyIdentity)) - #expect(text.contains("ca file = ")) + #expect(text.contains("ca file = \(MSSQLFreeTDSConfig.systemTrustStorePath)")) #expect(text.contains("check certificate hostname = yes")) } @Test("A user supplied authority wins over the system trust store") - func userSuppliedAuthority() { - let text = configuration(verification: .chain, caCertificatePath: "/Users/me/corp-ca.pem") + func userSuppliedAuthority() throws { + let text = lines(try entry(mode: .verifyCa, caCertificatePath: "/Users/me/My Certs/corp-ca.pem")) - #expect(text.contains("ca file = /Users/me/corp-ca.pem")) - #expect(!text.contains(MSSQLFreeTDSConfig.systemTrustStorePath)) + #expect(text.contains("ca file = /Users/me/My Certs/corp-ca.pem")) + #expect(!text.contains("ca file = \(MSSQLFreeTDSConfig.systemTrustStorePath)")) } @Test("A blank authority path falls back to the system trust store") @@ -67,12 +116,90 @@ struct MSSQLFreeTDSConfigTests { #expect(MSSQLFreeTDSConfig.authorityPath(userSupplied: "/tmp/ca.pem") == "/tmp/ca.pem") } - @Test("A non-verifying mode writes no authority line") - func nonVerifyingConfiguration() { - let text = configuration(verification: .none) + @Test("A non-verifying mode names no authority and checks no hostname, even when a path is set") + func nonVerifyingConfiguration() throws { + for mode in [SSLMode.disabled, .preferred, .required] { + let text = lines(try entry(mode: mode, caCertificatePath: "/tmp/ca.pem")) + #expect(text.contains("ca file ="), "\(mode)") + #expect(text.contains("check certificate hostname = no"), "\(mode)") + } + } + + @Test("The entry for a connection carries its level, its checks and its authority") + func entryFromConnectionOptions() throws { + var options = MSSQLConnectionOptions( + host: "db.example.com", + port: 14_330, + user: "sa", + password: "secret", + database: "app", + encryptionLevel: MSSQLSSLMapping.encryptionLevel(for: .verifyIdentity) + ) + options.certificateVerification = MSSQLSSLMapping.certificateVerification(for: .verifyIdentity) + options.caCertificatePath = "/certs/corp.pem" + + let text = lines(try MSSQLFreeTDSServerEntry(options: options)) + + #expect(text == [ + "[db.example.com]", + "host = db.example.com", + "port = 14330", + "tds version = 7.4", + "encryption = require", + "ca file = /certs/corp.pem", + "check certificate hostname = yes" + ]) + } + + @Test("A connection built without a level asks for request, never off") + func defaultLevel() { + let options = MSSQLConnectionOptions(host: "db", user: "sa", password: "secret", database: "app") + #expect(options.encryptionLevel == .request) + } + + @Test("A host that would read back as something else is refused", arguments: [ + "", + "db.example.com\n\tencryption = off", + "db.example.com\r", + "db example.com", + "[db.example.com]", + "db=example.com", + "db.example.com;comment", + "db.example.com#comment", + String(repeating: "a", count: 250) + ]) + func unreadableHosts(host: String) { + #expect(throws: MSSQLFreeTDSConfigError.unreadableHost) { + try entry(host: host, mode: .required) + } + } + + @Test("Host names and IP addresses are written as given", arguments: [ + "localhost", "127.0.0.1", "::1", "fe80::1%en0", "sql-01.corp.example.com", "MyServer" + ]) + func readableHosts(host: String) throws { + #expect(try entry(host: host, mode: .required).name == host) + } + + @Test("A port outside 1 to 65535 is refused", arguments: [0, -1, 65_536]) + func invalidPorts(port: Int) { + #expect(throws: MSSQLFreeTDSConfigError.invalidPort(port)) { + try entry(port: port, mode: .required) + } + } - #expect(!text.contains("ca file")) - #expect(!text.contains("check certificate hostname")) + @Test("An authority path libtds would cut short or reshape is refused", arguments: [ + "/certs/ca;old.pem", + "/certs/#1.pem", + "/certs/ca.pem ", + "/certs/two spaces.pem", + "/certs/ca\n\tcheck certificate hostname = no", + "/" + String(repeating: "c", count: 250) + ]) + func unreadableAuthorityPaths(path: String) { + #expect(throws: MSSQLFreeTDSConfigError.unreadableAuthorityPath) { + try entry(mode: .verifyCa, caCertificatePath: path) + } } @Test("The system trust store is present on this machine") diff --git a/TableProTests/Plugins/MSSQLLoginParametersTests.swift b/TableProTests/Plugins/MSSQLLoginParametersTests.swift index 38582560ed..369c987326 100644 --- a/TableProTests/Plugins/MSSQLLoginParametersTests.swift +++ b/TableProTests/Plugins/MSSQLLoginParametersTests.swift @@ -13,7 +13,6 @@ struct MSSQLLoginParametersTests { user: "carrier", password: "secret", applicationName: "TablePro", - encryptionFlag: "require", database: database ) } @@ -30,12 +29,17 @@ struct MSSQLLoginParametersTests { #expect(!fields.contains(.database)) } - @Test("carries the credentials and encryption flag") + @Test("carries the credentials") func carriesCredentials() { let parameters = build(database: "tmsdevdb1") #expect(parameters.contains(MSSQLLoginParameter(field: .user, value: "carrier"))) #expect(parameters.contains(MSSQLLoginParameter(field: .password, value: "secret"))) - #expect(parameters.contains(MSSQLLoginParameter(field: .encryption, value: "require"))) + } + + @Test("sets only fields dbsetlname accepts: db-lib refuses an encryption level there with error 20043") + func setsOnlyFieldsDbLibAccepts() { + let fields = build(database: "tmsdevdb1").map(\.field) + #expect(fields == [.user, .password, .application, .nationalLanguage, .charset, .database]) } @Test("sets us_english language to settle the initial login state") @@ -50,7 +54,6 @@ struct MSSQLLoginParametersTests { user: "", password: "", applicationName: "TablePro", - encryptionFlag: "require", database: "app" ) #expect(parameters.contains(MSSQLLoginParameter(field: .user, value: ""))) diff --git a/TableProTests/Plugins/MSSQLSSLMappingTests.swift b/TableProTests/Plugins/MSSQLSSLMappingTests.swift index 05769e12ad..8717211381 100644 --- a/TableProTests/Plugins/MSSQLSSLMappingTests.swift +++ b/TableProTests/Plugins/MSSQLSSLMappingTests.swift @@ -4,33 +4,34 @@ // import Foundation +import TableProMSSQLCore import TableProPluginKit import Testing -@Suite("MSSQLSSLMapping.freetdsEncryptionFlag") +@Suite("MSSQLSSLMapping.encryptionLevel") struct MSSQLSSLMappingTests { - @Test("disabled maps to off") + @Test("disabled maps to request: off would send the login unencrypted, and a server that forces encryption drops it") func disabled() { - #expect(MSSQLSSLMapping.freetdsEncryptionFlag(for: .disabled) == "off") + #expect(MSSQLSSLMapping.encryptionLevel(for: .disabled) == .request) } @Test("preferred maps to request") func preferred() { - #expect(MSSQLSSLMapping.freetdsEncryptionFlag(for: .preferred) == "request") + #expect(MSSQLSSLMapping.encryptionLevel(for: .preferred) == .request) } @Test("required maps to require") func required() { - #expect(MSSQLSSLMapping.freetdsEncryptionFlag(for: .required) == "require") + #expect(MSSQLSSLMapping.encryptionLevel(for: .required) == .require) } @Test("verifyCa maps to require") func verifyCa() { - #expect(MSSQLSSLMapping.freetdsEncryptionFlag(for: .verifyCa) == "require") + #expect(MSSQLSSLMapping.encryptionLevel(for: .verifyCa) == .require) } @Test("verifyIdentity maps to require") func verifyIdentity() { - #expect(MSSQLSSLMapping.freetdsEncryptionFlag(for: .verifyIdentity) == "require") + #expect(MSSQLSSLMapping.encryptionLevel(for: .verifyIdentity) == .require) } } diff --git a/docs/connections/ssl.mdx b/docs/connections/ssl.mdx index 81b6c35e8e..edd2a9672f 100644 --- a/docs/connections/ssl.mdx +++ b/docs/connections/ssl.mdx @@ -31,7 +31,7 @@ The certificate rows appear only once **SSL Mode** is anything but Disabled. Eac | **Client Key** | Any mode but Disabled | The matching private key. Required once a client certificate is set | | **Key Passphrase** | Once **Client Key** has a path, on drivers that support it (Cassandra and ScyllaDB) | The passphrase of an encrypted key. Stored in the Keychain | -SQL Server shows no certificate fields at all. FreeTDS takes no per-connection paths and trusts the system store; a custom trust anchor goes in `freetds.conf` instead. +SQL Server shows no certificate fields at all and verifies against the system trust store. See [SQL Server](/databases/mssql#limitations). ## Per-driver defaults @@ -41,7 +41,7 @@ A new connection starts on the mode that matches the driver's own default, and t |---|---|---| | PostgreSQL, Redshift, CockroachDB | Preferred | libpq's own `sslmode=prefer` fallback to plain TCP. Same as `psql` and DataGrip | | MySQL, MariaDB | Preferred | Tries TLS, then retries plain on an SSL handshake error. Auth and network errors are not retried | -| SQL Server | Preferred | FreeTDS `encryption=request`, falls back to plain | +| SQL Server | Preferred | FreeTDS `encryption=request`: the login over TLS, the rest in plain TCP unless the server forces encryption | | Teradata | Disabled | Opens a TLS transport, retries on a plain socket if it fails to come up | | MongoDB, Redis, Cassandra, ClickHouse, Elasticsearch, Typesense, SurrealDB, Weaviate | Disabled | Nothing. No fallback exists, so Preferred forces TLS exactly like Required | | etcd | Disabled | Nothing. The driver never reads these fields. Set **TLS Mode** on the Options section instead, and see [etcd](/databases/etcd) | diff --git a/docs/databases/mssql.mdx b/docs/databases/mssql.mdx index d67c5f52f7..6591c1e0f0 100644 --- a/docs/databases/mssql.mdx +++ b/docs/databases/mssql.mdx @@ -125,15 +125,15 @@ Every result set a batch returns opens in its own result tab. In a batch of plai ## SSL/TLS -New connections start on **Preferred**. +New connections start on **Preferred**, which encrypts the login and leaves queries and results in plain TCP unless the server forces encryption. Pick **Required (skip verify)** or stricter to encrypt the whole session. | Mode | Behavior | |---|---| -| **Disabled** | Plain TCP | -| **Preferred** | TLS, falling back to plain when the server cannot | -| **Required (skip verify)** | TLS, certificate unchecked | -| **Verify CA** | TLS, certificate chained to a trusted authority | -| **Verify Identity** | TLS, chain checked, and the certificate names the host you dialled | +| **Disabled** | The login over TLS when the server offers it, then plain TCP. A server that forces encryption gets TLS throughout | +| **Preferred** | Same as Disabled | +| **Required (skip verify)** | TLS throughout, certificate unchecked. A server without TLS is refused | +| **Verify CA** | TLS throughout, certificate chained to a trusted authority. The host name is not checked | +| **Verify Identity** | TLS throughout, chain checked, and the certificate names the host you dialled | Verification runs against the macOS system roots at `/etc/ssl/cert.pem`, which covers Azure SQL and anything else publicly trusted. See [SSL/TLS](/connections/ssl). @@ -144,7 +144,7 @@ SQL Server connections can also run through the [Cloud SQL Auth Proxy](/connecti - Named instances are not supported. Give the host and the TCP port that instance listens on. - NTLM is not supported: Windows Authentication on macOS is Kerberos only. - Entra ID connections are created on the Mac only, through the device code flow only. iPhone and iPad take them over sync and prompt to sign in when you open one. -- The SSL pane offers no certificate fields here. Verification runs against the system trust store; a private trust anchor goes in your own `freetds.conf`. +- The SSL pane offers no certificate fields here, and your own `freetds.conf` is not read. A server certificate from a private CA fails Verify CA and Verify Identity: use **Required (skip verify)** for that server. - A result set from a batch offers **Fetch All**, and sorts on the server, only when its query can run again on its own. One that reads a variable, or comes from a procedure call or a loop, stays at the row limit and sorts the rows already shown: raise **Row cap** in [Settings](/customization/settings), or run the query by itself. - A batch keeps its first 100 result sets. The statements behind the rest still run, and the status bar says how many result sets were not kept. diff --git a/scripts/check-mssql-encryption.sh b/scripts/check-mssql-encryption.sh new file mode 100755 index 0000000000..a35657e560 --- /dev/null +++ b/scripts/check-mssql-encryption.sh @@ -0,0 +1,546 @@ +#!/usr/bin/env bash +# +# Check that every SQL Server SSL mode gets the encryption it names, against a real server, through the driver's own +# sources. +# +# db-lib has no per-connection encryption setting. dbsetlname refuses DBSETENCRYPT with error 20043, and the driver +# threw that answer away, so Disabled, Preferred and Required (skip verify) all ran at FreeTDS's default, which encrypts +# the login and nothing after it: sys.dm_exec_connections read encrypt_option FALSE for a Required connection. libtds +# reads the level, the CA file and the hostname check from freetds.conf alone, so the driver now writes each +# connection's server there, and this checks what the server says it got: +# +# - Disabled and Preferred encrypt the login only, so encrypt_option reads FALSE, unless the server forces encryption. +# - Required reads TRUE. +# - Verify CA and Verify Identity read TRUE with the authority that signed the server's certificate, and are refused +# without it. Verify Identity is refused for a host name the certificate does not carry, and Verify CA is not. +# - Connections to one host with different modes, opened at the same time, each get their own mode. +# - A connect to a server that never answers does not hold up a connect to another host. +# - A password longer than db-lib takes fails the connect instead of logging in without one. +# - Against a server that cannot encrypt, which the check plays itself, Disabled and Preferred connect and Required is +# refused, and only Required asks for encryption in the prelogin. +# +# Usage: +# scripts/check-mssql-encryption.sh [host] [port] [user] +# +# The password comes from MSSQL_SA_PASSWORD, and the login needs VIEW SERVER STATE to read sys.dm_exec_connections. +# MSSQL_CA_FILE names the PEM the server's certificate chains to, MSSQL_CERT_HOST a host name the certificate carries +# (the host by default) and MSSQL_MISMATCH_HOST a name that reaches the server but is not in the certificate. Without +# MSSQL_CA_FILE the server is taken to present SQL Server's own self-signed certificate, which both verifying modes +# must refuse. Set MSSQL_FORCES_ENCRYPTION=1 for a server with forced encryption on. +# +# With no server listening on host:port, the script starts mcr.microsoft.com/azure-sql-edge in Docker as +# tablepro-mssql-encryption-check (or TP_MSSQL_CONTAINER), with a certificate for localhost from a CA it generates and +# keeps inside the container, and leaves it running for the next run. Exits 1 when a check fails, 3 when it cannot run. + +set -uo pipefail + +HOST="${1:-127.0.0.1}" +PORT="${2:-14338}" +USER_NAME="${3:-sa}" +PASSWORD="${MSSQL_SA_PASSWORD:-}" +CA_FILE="${MSSQL_CA_FILE:-}" +CERT_HOST="${MSSQL_CERT_HOST:-$HOST}" +MISMATCH_HOST="${MSSQL_MISMATCH_HOST:-}" +FORCES_ENCRYPTION="${MSSQL_FORCES_ENCRYPTION:-0}" +DATABASE="${MSSQL_DATABASE:-master}" +CONTAINER="${TP_MSSQL_CONTAINER:-tablepro-mssql-encryption-check}" +IMAGE="mcr.microsoft.com/azure-sql-edge:latest" +CONTAINER_CA="/var/opt/mssql/tablepro-check-ca.pem" + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +[ -f "$ROOT/Libs/libsybdb.a" ] || { + echo "not found: Libs/libsybdb.a (run scripts/download-libs.sh)" >&2 + exit 3 +} + +listening() { + nc -z "$HOST" "$PORT" > /dev/null 2>&1 +} + +owns_container() { + command -v docker > /dev/null 2>&1 && docker ps -a --format '{{.Names}}' | grep -x "$CONTAINER" > /dev/null +} + +serves_address() { + docker port "$CONTAINER" 1433/tcp 2> /dev/null | grep -x "$HOST:$PORT" > /dev/null +} + +create_container() { + local certs="$WORK/certs" + mkdir -p "$certs" + openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -subj "/CN=TablePro check CA" \ + -keyout "$certs/ca.key" -out "$certs/ca.pem" > /dev/null 2>&1 || return 1 + openssl req -newkey rsa:2048 -nodes -subj "/CN=localhost" \ + -keyout "$certs/server.key" -out "$certs/server.csr" > /dev/null 2>&1 || return 1 + printf 'subjectAltName=DNS:localhost\nextendedKeyUsage=serverAuth\n' > "$certs/server.ext" + openssl x509 -req -days 3650 -in "$certs/server.csr" -CA "$certs/ca.pem" -CAkey "$certs/ca.key" \ + -CAcreateserial -extfile "$certs/server.ext" -out "$certs/server.pem" > /dev/null 2>&1 || return 1 + chmod 644 "$certs/server.key" + printf '[network]\ntlscert = /var/opt/mssql/server.pem\ntlskey = /var/opt/mssql/server.key\ntlsprotocols = 1.2\n' \ + > "$certs/mssql.conf" + + docker create --name "$CONTAINER" -e ACCEPT_EULA=1 -e "MSSQL_SA_PASSWORD=$PASSWORD" \ + -p "$HOST:$PORT:1433" "$IMAGE" > /dev/null || return 1 + docker cp "$certs/mssql.conf" "$CONTAINER:/var/opt/mssql/mssql.conf" && + docker cp "$certs/server.pem" "$CONTAINER:/var/opt/mssql/server.pem" && + docker cp "$certs/server.key" "$CONTAINER:/var/opt/mssql/server.key" && + docker cp "$certs/ca.pem" "$CONTAINER:$CONTAINER_CA" +} + +if ! listening; then + command -v docker > /dev/null 2>&1 || { + echo "no SQL Server at $HOST:$PORT and no docker to start one" >&2 + exit 3 + } + if owns_container; then + echo "starting container $CONTAINER" + else + if [ -z "$PASSWORD" ]; then + PASSWORD="TpCheck#$(openssl rand -hex 8)" + echo "generated a password for $CONTAINER; export MSSQL_SA_PASSWORD='$PASSWORD' to reuse it" + fi + echo "creating $CONTAINER from $IMAGE on $HOST:$PORT with a certificate for localhost" + create_container || { + echo "could not create $CONTAINER" >&2 + exit 3 + } + fi + docker start "$CONTAINER" > /dev/null || exit 3 + for _ in $(seq 1 60); do + listening && break + sleep 2 + done +fi + +if [ -z "$CA_FILE" ] && owns_container && serves_address; then + CA_FILE="$WORK/ca.pem" + docker cp "$CONTAINER:$CONTAINER_CA" "$CA_FILE" > /dev/null || exit 3 + CERT_HOST="${MSSQL_CERT_HOST:-localhost}" + MISMATCH_HOST="${MSSQL_MISMATCH_HOST:-127.0.0.1}" +fi + +[ -n "$PASSWORD" ] || { + echo "no password: set MSSQL_SA_PASSWORD for the server at $HOST:$PORT" >&2 + exit 3 +} + +mkdir -p "$WORK/Sources/Check" +ln -s "$ROOT/Plugins/MSSQLDriverPlugin/CFreeTDS" "$WORK/CFreeTDS" +for source in "$ROOT"/Plugins/MSSQLDriverPlugin/*.swift; do + ln -s "$source" "$WORK/Sources/Check/$(basename "$source")" +done + +cat > "$WORK/Package.swift" << MANIFEST +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "MSSQLEncryptionCheck", + platforms: [.macOS(.v14)], + dependencies: [.package(path: "$ROOT/Packages/TableProCore")], + targets: [ + .systemLibrary(name: "CFreeTDS", path: "CFreeTDS"), + .executableTarget( + name: "Check", + dependencies: [ + "CFreeTDS", + .product(name: "TableProPluginKit", package: "TableProCore"), + .product(name: "TableProCoreTypes", package: "TableProCore"), + .product(name: "TableProMSSQLCore", package: "TableProCore"), + .product(name: "TableProLogRedaction", package: "TableProCore"), + ], + path: "Sources/Check", + swiftSettings: [.swiftLanguageMode(.v6)], + linkerSettings: [.unsafeFlags([ + "-L$ROOT/Libs", "-L$ROOT/Libs/dylibs", "-lsybdb", "-lssl.3", "-lcrypto.3", "-liconv", + "-framework", "GSS", "-lcom_err", "-Xlinker", "-rpath", "-Xlinker", "$ROOT/Libs/dylibs", + ])] + ), + ] +) +MANIFEST + +cat > "$WORK/Sources/Check/Check.swift" << 'SWIFT' +import Darwin +import Foundation +import TableProPluginKit + +@main +enum Check { + nonisolated(unsafe) static var failures = 0 + + static let environment = ProcessInfo.processInfo.environment + static let host = environment["TP_CHECK_HOST"] ?? "127.0.0.1" + static let port = Int(environment["TP_CHECK_PORT"] ?? "") ?? 1433 + static let caFile = environment["TP_CHECK_CA_FILE"].flatMap { $0.isEmpty ? nil : $0 } + static let certHost = environment["TP_CHECK_CERT_HOST"].flatMap { $0.isEmpty ? nil : $0 } ?? host + static let mismatchHost = environment["TP_CHECK_MISMATCH_HOST"].flatMap { $0.isEmpty ? nil : $0 } + static let forcesEncryption = environment["TP_CHECK_FORCES_ENCRYPTION"] == "1" + static let database = environment["TP_CHECK_DATABASE"] ?? "master" + + static func expect(_ condition: Bool, _ label: String, _ detail: @autoclosure () -> String = "") { + if condition { + print("PASS: \(label)") + } else { + failures += 1 + print("FAIL: \(label) \(detail())") + } + } + + static func config( + _ mode: SSLMode, + host: String = Check.host, + port: Int = Check.port, + password: String? = nil, + authority: String? = nil + ) -> DriverConnectionConfig { + DriverConnectionConfig( + host: host, + port: port, + username: environment["TP_CHECK_USER"] ?? "sa", + password: password ?? environment["TP_CHECK_PASSWORD"] ?? "", + database: database, + ssl: SSLConfiguration(mode: mode, caCertificatePath: authority ?? "") + ) + } + + static func encryption(_ config: DriverConnectionConfig) async throws -> String { + let driver = MSSQLPluginDriver(config: config) + try await driver.connect() + defer { driver.disconnect() } + let result = try await driver.execute( + query: "SELECT encrypt_option FROM sys.dm_exec_connections WHERE session_id = @@SPID" + ) + return result.rows.first?.first?.asText ?? "no row" + } + + static func outcome(_ config: DriverConnectionConfig) async -> String { + do { + return try await encryption(config) + } catch { + return "refused: \(error.localizedDescription)" + } + } + + static func waitForServer() async throws { + let deadline = Date().addingTimeInterval(120) + while true { + do { + _ = try await encryption(config(.required)) + return + } catch where Date() < deadline { + try await Task.sleep(nanoseconds: 2_000_000_000) + } + } + } + + static func main() async { + setvbuf(stdout, nil, _IOLBF, 0) + do { + try await waitForServer() + try await run() + } catch { + failures += 1 + print("FAIL: unexpected error \(error)") + } + print(failures == 0 ? "OK: every check passed" : "\(failures) check(s) failed") + exit(failures == 0 ? 0 : 1) + } + + static func run() async throws { + await eachModeGetsItsEncryption() + await verifyingModesCheckTheCertificate() + await concurrentModesOnOneHost() + try await unansweredConnectHoldsUpNoOtherHost() + await overlongPasswordFailsTheConnect() + await serverThatCannotEncrypt() + } + + static func eachModeGetsItsEncryption() async { + let unforced = forcesEncryption ? "TRUE" : "FALSE" + for (mode, expected) in [(SSLMode.disabled, unforced), (.preferred, unforced), (.required, "TRUE")] { + let seen = await outcome(config(mode)) + expect(seen == expected, "\(mode.rawValue) reads encrypt_option \(expected)", "got \(seen)") + } + } + + static func verifyingModesCheckTheCertificate() async { + guard let caFile else { + for mode in [SSLMode.verifyCa, .verifyIdentity] { + let seen = await outcome(config(mode)) + expect(seen.hasPrefix("refused"), "\(mode.rawValue) refuses a certificate the system roots do not vouch for", + "got \(seen)") + } + return + } + let chained = await outcome(config(.verifyCa, authority: caFile)) + expect(chained == "TRUE", "Verify CA with the signing authority reads encrypt_option TRUE", "got \(chained)") + let untrusted = await outcome(config(.verifyCa)) + expect(untrusted.hasPrefix("refused"), "Verify CA refuses the certificate against the system roots alone", + "got \(untrusted)") + let named = await outcome(config(.verifyIdentity, host: certHost, authority: caFile)) + expect(named == "TRUE", "Verify Identity through \(certHost), a name the certificate carries, reads TRUE", + "got \(named)") + if let mismatchHost { + let unnamed = await outcome(config(.verifyIdentity, host: mismatchHost, authority: caFile)) + expect(unnamed.hasPrefix("refused"), + "Verify Identity through \(mismatchHost), a name the certificate does not carry, is refused", + "got \(unnamed)") + let chainOnly = await outcome(config(.verifyCa, host: mismatchHost, authority: caFile)) + expect(chainOnly == "TRUE", "Verify CA through \(mismatchHost) does not check the name", "got \(chainOnly)") + } + } + + static func concurrentModesOnOneHost() async { + let unforced = forcesEncryption ? "TRUE" : "FALSE" + let modes = (0..<8).map { $0.isMultiple(of: 2) ? SSLMode.disabled : .required } + let seen = await withTaskGroup(of: (Int, String).self) { group in + for (index, mode) in modes.enumerated() { + group.addTask { (index, await outcome(config(mode))) } + } + var answers = [String](repeating: "", count: modes.count) + for await (index, answer) in group { + answers[index] = answer + } + return answers + } + let expected = modes.map { $0 == .required ? "TRUE" : unforced } + expect(seen == expected, "8 connections to one host opened together, alternating Disabled and Required, each get their own", + "got \(seen)") + } + + static func unansweredConnectHoldsUpNoOtherHost() async throws { + let listener = socket(AF_INET, SOCK_STREAM, 0) + var address = sockaddr_in() + address.sin_family = sa_family_t(AF_INET) + address.sin_addr.s_addr = inet_addr("127.0.0.1") + var length = socklen_t(MemoryLayout.size) + let bound = withUnsafeMutablePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { generic in + bind(listener, generic, length) == 0 && listen(listener, 8) == 0 + && getsockname(listener, generic, &length) == 0 + } + } + guard bound else { + expect(false, "a listener that never answers could be opened") + return + } + let silentPort = Int(UInt16(bigEndian: address.sin_port)) + let silentHost = host == "127.0.0.1" ? "localhost" : "127.0.0.1" + + let silentFinished = Flag() + let silent = Task { + let answer = await outcome(config(.required, host: silentHost, port: silentPort)) + silentFinished.set() + return answer + } + try await Task.sleep(nanoseconds: 1_000_000_000) + let started = Date() + let live = await outcome(config(.required)) + let elapsed = Date().timeIntervalSince(started) + let silentStillWaiting = !silentFinished.isSet + close(listener) + let silentOutcome = await silent.value + + expect(live == "TRUE" && elapsed < 10 && silentStillWaiting, + "a connect to \(host) finishes while one to \(silentHost), which never answers, is still waiting", + String(format: "live=%@ in %.1fs", live, elapsed)) + expect(silentOutcome.hasPrefix("refused"), "the connect that never got an answer fails once its server goes", + "got \(silentOutcome)") + } + + static func overlongPasswordFailsTheConnect() async { + let seen = await outcome(config(.required, password: String(repeating: "p", count: 200))) + expect(seen.hasPrefix("refused") && seen.contains("128 bytes"), + "a password db-lib refuses fails the connect and says why", "got \(seen)") + } + + static func connectFailure(_ config: DriverConnectionConfig) async -> String? { + let driver = MSSQLPluginDriver(config: config) + do { + try await driver.connect() + driver.disconnect() + return nil + } catch { + return error.localizedDescription + } + } + + static func serverThatCannotEncrypt() async { + guard let server = ServerWithoutEncryption() else { + expect(false, "a server that cannot encrypt could be started") + return + } + server.start() + defer { server.stop() } + for mode in [SSLMode.disabled, .preferred] { + let failure = await connectFailure(config(mode, host: "127.0.0.1", port: server.port)) + expect(failure == nil, "\(mode.rawValue) connects to a server that cannot encrypt", "got \(failure ?? "")") + } + let required = await connectFailure(config(.required, host: "127.0.0.1", port: server.port)) + expect(required != nil, "Required refuses a server that cannot encrypt", "it connected") + expect(server.encryptionOffers == [0, 0, 1], + "the prelogin offers encryption off for Disabled and Preferred and on for Required", + "got \(server.encryptionOffers)") + } +} + +/// Answers the TDS prelogin with ENCRYPT_NOT_SUP, the login with a LOGINACK and every other request with a DONE, which +/// is all the driver needs to connect. It records the encryption byte each prelogin offers. +final class ServerWithoutEncryption: @unchecked Sendable { + let port: Int + private let listener: Int32 + private let lock = NSLock() + private var offers: [UInt8] = [] + + private static let preloginReply: [UInt8] = [ + 0x00, 0x00, 0x0B, 0x00, 0x06, 0x01, 0x00, 0x11, 0x00, 0x01, 0xFF, + 15, 0, 0x07, 0xD0, 0, 0, + 0x02, + ] + private static let loginAck: [UInt8] = [ + 0xAD, 18, 0, 1, 0x74, 0, 0, 4, 4, 0x66, 0, 0x61, 0, 0x6B, 0, 0x65, 0, 15, 0, 0, 0, + ] + private static let packetSizeChange: [UInt8] = [ + 0xE3, 19, 0, 4, 4, 0x34, 0, 0x30, 0, 0x39, 0, 0x36, 0, 4, 0x34, 0, 0x30, 0, 0x39, 0, 0x36, 0, + ] + private static let done: [UInt8] = [0xFD, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + + init?() { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + var address = sockaddr_in() + address.sin_family = sa_family_t(AF_INET) + address.sin_addr.s_addr = inet_addr("127.0.0.1") + var length = socklen_t(MemoryLayout.size) + let bound = withUnsafeMutablePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { generic in + bind(fd, generic, length) == 0 && listen(fd, 8) == 0 && getsockname(fd, generic, &length) == 0 + } + } + guard bound else { + close(fd) + return nil + } + listener = fd + port = Int(UInt16(bigEndian: address.sin_port)) + } + + var encryptionOffers: [UInt8] { + lock.lock() + defer { lock.unlock() } + return offers + } + + func start() { + Thread.detachNewThread { [self] in + while true { + let client = accept(listener, nil, nil) + guard client >= 0 else { return } + Thread.detachNewThread { [self] in serve(client) } + } + } + } + + func stop() { + shutdown(listener, SHUT_RDWR) + close(listener) + } + + private func serve(_ client: Int32) { + defer { close(client) } + while let (type, body) = readMessage(client) { + switch type { + case 0x12: + if let offer = Self.encryptionOffer(in: body) { + lock.lock() + offers.append(offer) + lock.unlock() + } + send(Self.preloginReply, to: client) + case 0x10: + send(Self.loginAck + Self.packetSizeChange + Self.done, to: client) + default: + send(Self.done, to: client) + } + } + } + + private static func encryptionOffer(in body: [UInt8]) -> UInt8? { + var index = 0 + while index + 4 < body.count, body[index] != 0xFF { + let offset = Int(body[index + 1]) << 8 | Int(body[index + 2]) + if body[index] == 0x01, offset < body.count { + return body[offset] + } + index += 5 + } + return nil + } + + private func readMessage(_ client: Int32) -> (UInt8, [UInt8])? { + var body: [UInt8] = [] + while true { + guard let header = read(8, from: client) else { return nil } + let length = Int(header[2]) << 8 | Int(header[3]) + guard length >= 8, let payload = read(length - 8, from: client) else { return nil } + body += payload + if header[1] & 0x01 != 0 { + return (header[0], body) + } + } + } + + private func read(_ count: Int, from client: Int32) -> [UInt8]? { + var bytes = [UInt8](repeating: 0, count: count) + var received = 0 + while received < count { + let chunk = bytes.withUnsafeMutableBytes { buffer -> Int in + guard let base = buffer.baseAddress else { return -1 } + return recv(client, base + received, count - received, 0) + } + guard chunk > 0 else { return nil } + received += chunk + } + return bytes + } + + private func send(_ payload: [UInt8], to client: Int32) { + let length = payload.count + 8 + let packet: [UInt8] = [0x04, 0x01, UInt8(length >> 8), UInt8(length & 0xFF), 0, 0, 1, 0] + payload + _ = packet.withUnsafeBytes { Darwin.send(client, $0.baseAddress, packet.count, 0) } + } +} + +final class Flag: @unchecked Sendable { + private let lock = NSLock() + private var raised = false + + func set() { + lock.lock() + raised = true + lock.unlock() + } + + var isSet: Bool { + lock.lock() + defer { lock.unlock() } + return raised + } +} +SWIFT + +export DEVELOPER_DIR="${DEVELOPER_DIR:-$(xcode-select -p)}" +swift build --package-path "$WORK" --scratch-path "$WORK/.build" > "$WORK/build.log" 2>&1 || { + echo "the check failed to build" >&2 + grep -E "error:" "$WORK/build.log" >&2 + exit 3 +} + +TP_CHECK_HOST="$HOST" TP_CHECK_PORT="$PORT" TP_CHECK_USER="$USER_NAME" TP_CHECK_PASSWORD="$PASSWORD" \ + TP_CHECK_CA_FILE="$CA_FILE" TP_CHECK_CERT_HOST="$CERT_HOST" TP_CHECK_MISMATCH_HOST="$MISMATCH_HOST" \ + TP_CHECK_FORCES_ENCRYPTION="$FORCES_ENCRYPTION" TP_CHECK_DATABASE="$DATABASE" \ + "$WORK/.build/debug/Check" From b989a71faf4e3cf8a7c5f42c98ebfc1682cc0ad9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 24 Sep 2026 18:51:34 +0700 Subject: [PATCH 2/2] fix(plugin-mssql): delete the Kerberos ticket cache on every failed connect, pass long SPNs through freetds.conf, and stop tunneled connects waiting on each other --- CHANGELOG.md | 1 + .../CancellableBlockingWork.swift | 6 + .../MSSQLFreeTDSConfig.swift | 76 +++++-- .../MSSQLFreeTDSConfigFile.swift | 89 ++++++-- .../TableProMSSQLCore/MSSQLKerberosSPN.swift | 4 +- .../CancellableBlockingWorkTests.swift | 13 ++ .../MSSQLFreeTDSConfigFileTests.swift | 179 +++++++++++++-- .../MSSQLDriverPlugin/FreeTDSConnection.swift | 72 +++--- .../MSSQLKerberosRealmResolver.swift | 4 +- Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 7 + TablePro/Resources/Localizable.xcstrings | 9 +- .../TableProMobile/Localizable.xcstrings | 6 + .../Plugins/MSSQLFreeTDSConfigTests.swift | 98 ++++++++- scripts/check-mssql-encryption.sh | 207 ++++++++++++++++-- 14 files changed, 667 insertions(+), 104 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3616bdd8f7..5b3b2ae67a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -494,6 +494,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SQL Server login failing without a reason for a password longer than 128 bytes. - SQL Server connection that never answered again after Stop was pressed during a query. - SQL Server query cut at the row limit left running on the server, holding its locks and stalling the next query. +- SQL Server Windows Authentication to another realm failing when the service principal name is over 128 bytes. ### Security diff --git a/Packages/TableProCore/Sources/TableProCoreTypes/CancellableBlockingWork.swift b/Packages/TableProCore/Sources/TableProCoreTypes/CancellableBlockingWork.swift index e1f860bfc8..9cd5f45e53 100644 --- a/Packages/TableProCore/Sources/TableProCoreTypes/CancellableBlockingWork.swift +++ b/Packages/TableProCore/Sources/TableProCoreTypes/CancellableBlockingWork.swift @@ -8,6 +8,12 @@ public final class SingleResumeGate: @unchecked Sendable { public init() {} + public var isSettled: Bool { + lock.lock() + defer { lock.unlock() } + return settled + } + public func install(_ continuation: CheckedContinuation, alreadyCancelled: Bool) { lock.lock() if alreadyCancelled, !settled { diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfig.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfig.swift index 516bde038e..c3272d606d 100644 --- a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfig.swift +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfig.swift @@ -37,6 +37,8 @@ public enum MSSQLFreeTDSConfigError: LocalizedError, Equatable, Sendable { case invalidPort(Int) case unreadableHost case unreadableAuthorityPath + case unreadableServicePrincipal + case nameInUse(String) case unwritable(String) public var errorDescription: String? { @@ -53,6 +55,18 @@ public enum MSSQLFreeTDSConfigError: LocalizedError, Equatable, Sendable { The CA certificate path cannot be passed to FreeTDS. Use a shorter path, without “;”, “#” or \ repeated spaces. """) + case .unreadableServicePrincipal: + return String(localized: """ + The Kerberos service principal name for this server cannot be passed to FreeTDS. Connect through a \ + shorter host name. + """) + case .nameInUse(let name): + return String( + format: String(localized: """ + Another connection to %@ with other settings is still logging in. Try again once it finishes. + """), + name + ) case .unwritable(let detail): return String(format: String(localized: "The FreeTDS configuration could not be written: %@"), detail) } @@ -77,30 +91,43 @@ public enum MSSQLFreeTDSConfig { /// One server as libtds reads it from freetds.conf. /// -/// The entry is named after the host because db-lib sends the name dbopen was given as the server name in the login -/// packet, and that name has to stay the host. It states every option it depends on, even at libtds's own default: a -/// section called `global` holds the defaults for every other one, and a host can have that name. Every value is -/// checked against the way libtds reads the file: `;` and `#` start a comment, runs of white space collapse to one, a -/// `[` opens a section and `=` ends an option's name. A value that would read back differently is refused rather than -/// written. +/// The section's name is the server name db-lib sends in the login packet, because dbopen is given that name and +/// libtds finds the section by it. For a host name it has to stay the host: FreeTDS sends no TLS server name, so an +/// Azure SQL gateway learns which server a login is for from this field alone. An IP address names no server a gateway +/// could route by, so there the port joins the name the way SQL Server writes one, `10.0.0.5,1433`, and connects to +/// other ports on one address, every SSH tunnel on 127.0.0.1 among them, never share a name. +/// +/// The entry states every option it depends on, even at libtds's own default: a section called `global` holds the +/// defaults for every other one, and a host can have that name. Every value is checked against the way libtds reads the +/// file: `;` and `#` start a comment, runs of white space collapse to one, a `[` opens a section and `=` ends an +/// option's name. A value that would read back differently is refused rather than written. public struct MSSQLFreeTDSServerEntry: Equatable, Sendable { + /// The server name dbopen is given, and the section libtds looks it up by. + public let name: String + /// The host libtds dials, without the brackets an IPv6 address is often written in, which libtds drops too. public let host: String public let port: Int public let encryption: MSSQLEncryptionLevel public let verification: MSSQLCertificateVerification public let authorityPath: String? + /// The Kerberos service principal libtds asks a ticket for. Without one it builds `MSSQLSvc/:` in the + /// default realm. It is written here rather than set on the login, whose setter takes no more than 128 bytes. + public let servicePrincipal: String? public init( host: String, port: Int, encryption: MSSQLEncryptionLevel, verification: MSSQLCertificateVerification, - caCertificatePath: String? + caCertificatePath: String?, + servicePrincipal: String? = nil ) throws { guard (1...65_535).contains(port) else { throw MSSQLFreeTDSConfigError.invalidPort(port) } - guard Self.isReadableHost(host) else { + let dialled = Self.withoutBrackets(host) + let name = Self.isNumericAddress(dialled) ? "\(dialled),\(port)" : dialled + guard Self.isReadableHost(dialled), Self.fitsOnOneLine("[\(name)]") else { throw MSSQLFreeTDSConfigError.unreadableHost } let authorityPath = verification.needsAuthority @@ -109,11 +136,17 @@ public struct MSSQLFreeTDSServerEntry: Equatable, Sendable { if let authorityPath, !Self.isReadableValue(authorityPath, option: "ca file") { throw MSSQLFreeTDSConfigError.unreadableAuthorityPath } - self.host = host + let principal = servicePrincipal.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 } + if let principal, !Self.isReadableValue(principal, option: "spn") { + throw MSSQLFreeTDSConfigError.unreadableServicePrincipal + } + self.name = name + self.host = dialled self.port = port self.encryption = encryption self.verification = verification self.authorityPath = authorityPath + self.servicePrincipal = principal } public init(options: MSSQLConnectionOptions) throws { @@ -122,13 +155,11 @@ public struct MSSQLFreeTDSServerEntry: Equatable, Sendable { port: options.port, encryption: options.encryptionLevel, verification: options.certificateVerification, - caCertificatePath: options.caCertificatePath + caCertificatePath: options.caCertificatePath, + servicePrincipal: options.authMethod == .windows ? options.kerberosServicePrincipal : nil ) } - /// The server name dbopen is given, and the section libtds looks it up by. - public var name: String { host } - public var text: String { [ "[\(name)]", @@ -137,7 +168,8 @@ public struct MSSQLFreeTDSServerEntry: Equatable, Sendable { "\ttds version = 7.4", "\tencryption = \(encryption.rawValue)", "\tca file = \(authorityPath ?? "")", - "\tcheck certificate hostname = \(verification.checksHostname ? "yes" : "no")" + "\tcheck certificate hostname = \(verification.checksHostname ? "yes" : "no")", + "\tspn = \(servicePrincipal ?? "")" ].joined(separator: "\n") + "\n" } @@ -147,7 +179,21 @@ public struct MSSQLFreeTDSServerEntry: Equatable, Sendable { private static func isReadableHost(_ host: String) -> Bool { guard !host.isEmpty, host.rangeOfCharacter(from: hostDelimiters) == nil else { return false } - return fitsOnOneLine("[\(host)]") && fitsOnOneLine("\thost = \(host)") + return fitsOnOneLine("\thost = \(host)") + } + + private static func withoutBrackets(_ host: String) -> String { + guard host.hasPrefix("["), host.hasSuffix("]"), host.count > 2 else { return host } + return String(host.dropFirst().dropLast()) + } + + private static func isNumericAddress(_ host: String) -> Bool { + var hints = addrinfo() + hints.ai_flags = AI_NUMERICHOST + var result: UnsafeMutablePointer? + guard getaddrinfo(host, nil, &hints, &result) == 0 else { return false } + freeaddrinfo(result) + return true } private static func isReadableValue(_ value: String, option: String) -> Bool { diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfigFile.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfigFile.swift index f6cac2a450..63e48e059f 100644 --- a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfigFile.swift +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfigFile.swift @@ -7,18 +7,21 @@ import Foundation /// The freetds.conf every SQL Server connection in the process reads, named to db-lib once with dbsetifile. /// -/// libtds reads the file when dbopen starts, so an entry is held for exactly as long as one dbopen. Entries for -/// different host names sit side by side, and a connection never waits on one to another host name, however long that -/// server takes to answer. libtds applies every section whose name matches, whatever its case, so two connections that -/// describe one host name differently, with another port or another mode, cannot both be in the file: the second waits -/// until the first dbopen returns. Every -/// change replaces the file whole, which leaves a dbopen that already opened it reading the version it opened, and the -/// file is removed once nothing holds an entry. +/// libtds reads the file when dbopen starts, so an entry is held for exactly as long as one dbopen. Entries with +/// different names sit side by side, and a connection never waits on one with another name, however long that server +/// takes to answer. libtds applies every section whose name matches, whatever its case, so two entries that share a +/// name and differ in anything else cannot both be in the file: the later one waits until the dbopen holding the name +/// returns. Connections to one name take it in the order they asked, so identical connections that keep arriving +/// cannot keep a different one waiting for ever. A wait has a limit, and a connection whose caller gave up leaves +/// the line once `interruptWaits` wakes it. Every change replaces the file whole, which leaves a dbopen that already +/// opened it reading the version it opened, and the file is removed once nothing holds an entry. public final class MSSQLFreeTDSConfigFile: @unchecked Sendable { public let path: String private let condition = NSCondition() private var leases: [String: Lease] = [:] + private var queues: [String: [Int]] = [:] + private var nextTicket = 0 private struct Lease { let entry: MSSQLFreeTDSServerEntry @@ -29,23 +32,77 @@ public final class MSSQLFreeTDSConfigFile: @unchecked Sendable { self.path = path } - public func withEntry(_ entry: MSSQLFreeTDSServerEntry, _ body: () throws -> T) throws -> T { - try acquire(entry) + public func withEntry( + _ entry: MSSQLFreeTDSServerEntry, + waitingAtMost timeout: TimeInterval, + givingUpWhen isAbandoned: () -> Bool = { false }, + _ body: () throws -> T + ) throws -> T { + try acquire(entry, waitingUntil: Date(timeIntervalSinceNow: timeout), givingUpWhen: isAbandoned) defer { release(entry) } return try body() } + /// Wakes every waiting connection so one whose caller has given up can leave the line. + public func interruptWaits() { + condition.lock() + condition.broadcast() + condition.unlock() + } + + internal func waitingConnections(named name: String) -> Int { + condition.lock() + defer { condition.unlock() } + return queues[name.lowercased()]?.count ?? 0 + } + private static func key(for entry: MSSQLFreeTDSServerEntry) -> String { entry.name.lowercased() } - private func acquire(_ entry: MSSQLFreeTDSServerEntry) throws { + private func acquire( + _ entry: MSSQLFreeTDSServerEntry, + waitingUntil deadline: Date, + givingUpWhen isAbandoned: () -> Bool + ) throws { let key = Self.key(for: entry) condition.lock() defer { condition.unlock() } - while let lease = leases[key], lease.entry != entry { - condition.wait() + let ticket = nextTicket + nextTicket += 1 + queues[key, default: []].append(ticket) + defer { + leaveQueue(key, ticket: ticket) + condition.broadcast() + } + try waitForTurn(ticket, entry: entry, key: key, until: deadline, givingUpWhen: isAbandoned) + try hold(entry, key: key) + } + + private func waitForTurn( + _ ticket: Int, + entry: MSSQLFreeTDSServerEntry, + key: String, + until deadline: Date, + givingUpWhen isAbandoned: () -> Bool + ) throws { + while !isAdmitted(ticket, entry: entry, key: key) { + if isAbandoned() { + throw CancellationError() + } + if !condition.wait(until: deadline), !isAdmitted(ticket, entry: entry, key: key) { + throw MSSQLFreeTDSConfigError.nameInUse(entry.name) + } } + } + + private func isAdmitted(_ ticket: Int, entry: MSSQLFreeTDSServerEntry, key: String) -> Bool { + guard queues[key]?.first == ticket else { return false } + guard let lease = leases[key] else { return true } + return lease.entry == entry + } + + private func hold(_ entry: MSSQLFreeTDSServerEntry, key: String) throws { if var lease = leases[key] { lease.holders += 1 leases[key] = lease @@ -56,11 +113,17 @@ public final class MSSQLFreeTDSConfigFile: @unchecked Sendable { try write() } catch { leases[key] = nil - condition.broadcast() throw error } } + private func leaveQueue(_ key: String, ticket: Int) { + queues[key]?.removeAll { $0 == ticket } + if queues[key]?.isEmpty == true { + queues[key] = nil + } + } + private func release(_ entry: MSSQLFreeTDSServerEntry) { let key = Self.key(for: entry) condition.lock() diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLKerberosSPN.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLKerberosSPN.swift index 4a8ddd09c2..27af42d1fd 100644 --- a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLKerberosSPN.swift +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLKerberosSPN.swift @@ -5,8 +5,8 @@ import Foundation /// FreeTDS otherwise constructs an unrealmed `MSSQLSvc/host:port`, which macOS Heimdal resolves /// against the client's `default_realm` with no cross-realm referral. Windows Authentication then /// fails (`KRB5KDC_ERR_S_PRINCIPAL_UNKNOWN`) whenever the SQL Server's realm differs from the Mac's -/// default realm. Supplying the realm-qualified SPN via `DBSETSERVERPRINCIPAL` makes Heimdal request -/// the service ticket from the correct realm. +/// default realm. Supplying the realm-qualified SPN as the `spn` of the connection's freetds.conf +/// entry makes Heimdal request the service ticket from the correct realm. public enum MSSQLKerberosSPN { /// Returns `MSSQLSvc/:@`, or `nil` when no realm is set (letting FreeTDS keep /// its default, unrealmed SPN). The realm is upper-cased to match Active Directory convention. diff --git a/Packages/TableProCore/Tests/TableProCoreTypesTests/CancellableBlockingWorkTests.swift b/Packages/TableProCore/Tests/TableProCoreTypesTests/CancellableBlockingWorkTests.swift index c328647d2b..2ddb12567d 100644 --- a/Packages/TableProCore/Tests/TableProCoreTypesTests/CancellableBlockingWorkTests.swift +++ b/Packages/TableProCore/Tests/TableProCoreTypesTests/CancellableBlockingWorkTests.swift @@ -127,6 +127,19 @@ struct CancellableBlockingWorkTests { #expect(discardCount.value <= 1) } } + + @Test("A gate reports itself settled once the caller has an answer, whichever side gave it") + func gateReportsSettled() { + let won = SingleResumeGate() + #expect(!won.isSettled) + #expect(won.win(1)) + #expect(won.isSettled) + + let failed = SingleResumeGate() + failed.fail(CancellationError()) + #expect(failed.isSettled) + #expect(!failed.win(1)) + } } private final class CountBox: @unchecked Sendable { diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLFreeTDSConfigFileTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLFreeTDSConfigFileTests.swift index ec2640e479..66fe89a8d5 100644 --- a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLFreeTDSConfigFileTests.swift +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLFreeTDSConfigFileTests.swift @@ -1,5 +1,5 @@ import Foundation -import TableProMSSQLCore +@testable import TableProMSSQLCore import Testing @Suite("MSSQL FreeTDS config file") @@ -34,7 +34,7 @@ struct MSSQLFreeTDSConfigFileTests { @Test("The entry is in the file while the body runs, and the file is gone after") func entryLivesForTheBody() throws { let server = try entry("db.example.com") - let seen = try file.withEntry(server) { contents() } + let seen = try file.withEntry(server, waitingAtMost: 10) { contents() } #expect(seen == server.text) #expect(!FileManager.default.fileExists(atPath: file.path)) @@ -42,7 +42,7 @@ struct MSSQLFreeTDSConfigFileTests { @Test("Only the owner can read the file") func fileIsPrivate() throws { - let permissions = try file.withEntry(try entry("db.example.com")) { + let permissions = try file.withEntry(try entry("db.example.com"), waitingAtMost: 10) { try FileManager.default.attributesOfItem(atPath: file.path)[.posixPermissions] as? Int } @@ -54,8 +54,8 @@ struct MSSQLFreeTDSConfigFileTests { let first = try entry("alpha.example.com", encryption: .request) let second = try entry("beta.example.com", encryption: .require) - let seen = try file.withEntry(first) { - try file.withEntry(second) { contents() } + let seen = try file.withEntry(first, waitingAtMost: 10) { + try file.withEntry(second, waitingAtMost: 10) { contents() } } #expect(seen?.contains(first.text) == true) @@ -66,8 +66,8 @@ struct MSSQLFreeTDSConfigFileTests { func sharedEntryOutlivesTheInnerHolder() throws { let server = try entry("db.example.com") - let afterInner = try file.withEntry(server) { - try file.withEntry(server) {} + let afterInner = try file.withEntry(server, waitingAtMost: 10) { + try file.withEntry(server, waitingAtMost: 10) {} return contents() } @@ -84,7 +84,7 @@ struct MSSQLFreeTDSConfigFileTests { let holding = DispatchSemaphore(value: 0) DispatchQueue.global().async { - try? file.withEntry(lower) { + try? file.withEntry(lower, waitingAtMost: 10) { order.append("lower in") holding.signal() released.wait() @@ -94,7 +94,7 @@ struct MSSQLFreeTDSConfigFileTests { holding.wait() let done = DispatchSemaphore(value: 0) DispatchQueue.global().async { - try? file.withEntry(upper) { order.append("upper in") } + try? file.withEntry(upper, waitingAtMost: 10) { order.append("upper in") } done.signal() } Thread.sleep(forTimeInterval: 0.2) @@ -115,7 +115,7 @@ struct MSSQLFreeTDSConfigFileTests { let seenByEncrypted = Box(nil) DispatchQueue.global().async { - try? file.withEntry(plain) { + try? file.withEntry(plain, waitingAtMost: 10) { order.append("plain in") holding.signal() released.wait() @@ -124,7 +124,7 @@ struct MSSQLFreeTDSConfigFileTests { } holding.wait() DispatchQueue.global().async { - try? file.withEntry(encrypted) { + try? file.withEntry(encrypted, waitingAtMost: 10) { order.append("encrypted in") seenByEncrypted.value = contents() } @@ -148,32 +148,183 @@ struct MSSQLFreeTDSConfigFileTests { let finished = DispatchSemaphore(value: 0) DispatchQueue.global().async { - try? file.withEntry(slow) { + try? file.withEntry(slow, waitingAtMost: 10) { holding.signal() released.wait() } finished.signal() } holding.wait() - let ranWhileSlowHeld = try file.withEntry(fast) { contents()?.contains(fast.text) == true } + let ranWhileSlowHeld = try file.withEntry(fast, waitingAtMost: 10) { contents()?.contains(fast.text) == true } released.signal() finished.wait() #expect(ranWhileSlowHeld) } + @Test("Connects to two ports on one address, as every SSH tunnel is, do not wait on each other") + func portsOnOneAddressDoNotWait() throws { + let silent = try entry("127.0.0.1", port: 50_001) + let live = try entry("127.0.0.1", port: 50_002, encryption: .request) + let holding = DispatchSemaphore(value: 0) + let released = DispatchSemaphore(value: 0) + let silentFinished = DispatchSemaphore(value: 0) + let liveFinished = DispatchSemaphore(value: 0) + let seen = Box(nil) + + DispatchQueue.global().async { + try? file.withEntry(silent, waitingAtMost: 10) { + holding.signal() + released.wait() + } + silentFinished.signal() + } + holding.wait() + DispatchQueue.global().async { + try? file.withEntry(live, waitingAtMost: 10) { seen.value = contents() } + liveFinished.signal() + } + let liveRanWhileSilentHeld = liveFinished.wait(timeout: .now() + 2) == .success + released.signal() + silentFinished.wait() + if !liveRanWhileSilentHeld { + liveFinished.wait() + } + + #expect(liveRanWhileSilentHeld) + #expect(seen.value?.contains(silent.text) == true) + #expect(seen.value?.contains(live.text) == true) + } + + @Test("A connect waiting for a name goes before a later one that matches the entry holding it") + func waitingEntryIsNotOvertaken() throws { + let plain = try entry("db.example.com", encryption: .request) + let encrypted = try entry("db.example.com", encryption: .require) + let order = OrderLog() + let holding = DispatchSemaphore(value: 0) + let released = DispatchSemaphore(value: 0) + let done = DispatchSemaphore(value: 0) + + DispatchQueue.global().async { + try? file.withEntry(plain, waitingAtMost: 10) { + order.append("first plain in") + holding.signal() + released.wait() + order.append("first plain out") + } + } + holding.wait() + DispatchQueue.global().async { + try? file.withEntry(encrypted, waitingAtMost: 10) { order.append("encrypted in") } + done.signal() + } + #expect(waitUntil { file.waitingConnections(named: "db.example.com") == 1 }) + DispatchQueue.global().async { + try? file.withEntry(plain, waitingAtMost: 10) { order.append("second plain in") } + done.signal() + } + #expect(waitUntil { file.waitingConnections(named: "db.example.com") == 2 }) + #expect(order.entries == ["first plain in"]) + released.signal() + done.wait() + done.wait() + + #expect(order.entries == ["first plain in", "first plain out", "encrypted in", "second plain in"]) + } + + @Test("A connect that cannot have the name in time gives up with the reason and leaves the line") + func boundedWaitGivesUp() throws { + let plain = try entry("db.example.com", encryption: .request) + let encrypted = try entry("db.example.com", encryption: .require) + let holding = DispatchSemaphore(value: 0) + let released = DispatchSemaphore(value: 0) + let finished = DispatchSemaphore(value: 0) + + DispatchQueue.global().async { + try? file.withEntry(plain, waitingAtMost: 10) { + holding.signal() + released.wait() + } + finished.signal() + } + holding.wait() + #expect(throws: MSSQLFreeTDSConfigError.nameInUse("db.example.com")) { + try file.withEntry(encrypted, waitingAtMost: 0.2) {} + } + let waitingAfterGivingUp = file.waitingConnections(named: "db.example.com") + let joinedTheHolder = try file.withEntry(plain, waitingAtMost: 0.2) { true } + released.signal() + finished.wait() + + #expect(waitingAfterGivingUp == 0) + #expect(joinedTheHolder) + } + + @Test("A connect whose caller gave up leaves the line as soon as the waits are interrupted") + func abandonedWaitLeavesTheLine() throws { + let plain = try entry("db.example.com", encryption: .request) + let encrypted = try entry("db.example.com", encryption: .require) + let holding = DispatchSemaphore(value: 0) + let released = DispatchSemaphore(value: 0) + let holderFinished = DispatchSemaphore(value: 0) + let waiterFinished = DispatchSemaphore(value: 0) + let abandoned = Box(false) + let waiterError = Box(nil) + let ran = Box(false) + + DispatchQueue.global().async { + try? file.withEntry(plain, waitingAtMost: 10) { + holding.signal() + released.wait() + } + holderFinished.signal() + } + holding.wait() + DispatchQueue.global().async { + do { + try file.withEntry(encrypted, waitingAtMost: 30, givingUpWhen: { abandoned.value }) { ran.value = true } + } catch { + waiterError.value = error + } + waiterFinished.signal() + } + #expect(waitUntil { file.waitingConnections(named: "db.example.com") == 1 }) + abandoned.value = true + file.interruptWaits() + let leftInTime = waiterFinished.wait(timeout: .now() + 2) == .success + released.signal() + holderFinished.wait() + if !leftInTime { + waiterFinished.wait() + } + + #expect(leftInTime) + #expect(waiterError.value is CancellationError) + #expect(!ran.value) + #expect(file.waitingConnections(named: "db.example.com") == 0) + } + @Test("A file that cannot be written fails the connect and runs nothing") func unwritableFileThrows() throws { let missing = MSSQLFreeTDSConfigFile(path: (directory as NSString).appendingPathComponent("no/such/freetds.conf")) var ran = false #expect(throws: MSSQLFreeTDSConfigError.self) { - try missing.withEntry(try entry("db.example.com")) { ran = true } + try missing.withEntry(try entry("db.example.com"), waitingAtMost: 10) { ran = true } } #expect(!ran) } } +private func waitUntil(_ condition: () -> Bool) -> Bool { + let deadline = Date(timeIntervalSinceNow: 5) + while !condition() { + guard Date() < deadline else { return false } + Thread.sleep(forTimeInterval: 0.01) + } + return true +} + private final class OrderLog: @unchecked Sendable { private let lock = NSLock() private var recorded: [String] = [] diff --git a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift index e6e8c00ff4..6897bde28c 100644 --- a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift +++ b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift @@ -287,38 +287,39 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { } func connect() async throws { - let gate = SingleResumeGate() - let isKerberos = options.authMethod == .windows - let deadline = DispatchTimeInterval.seconds(options.loginTimeoutSeconds + Self.connectDeadlineMarginSeconds) - - Self.deadlineQueue.asyncAfter(deadline: .now() + deadline) { - gate.fail(MSSQLCoreError.connectionTimedOut(isKerberos: isKerberos)) - } + let attempt = SingleResumeGate() try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - gate.install(continuation, alreadyCancelled: Task.isCancelled) + attempt.install(continuation, alreadyCancelled: Task.isCancelled) queue.async { [self] in do { - let proc = try openConnection() - if gate.win(()) { + let proc = try openConnection(for: attempt) + if attempt.win(()) { adopt(proc) } else { teardown(proc) } } catch { - gate.fail(error) + attempt.fail(error) } } } } onCancel: { - gate.fail(CancellationError()) + attempt.fail(CancellationError()) + freetdsConfigFile.interruptWaits() } } - /// db-lib reads the encryption level and the certificate checks from freetds.conf and from nowhere else, so the - /// server is described there rather than on the login. - private func openConnection() throws -> UnsafeMutablePointer { + /// db-lib reads the encryption level, the certificate checks and the service principal from freetds.conf and from + /// nowhere else, so the server is described there rather than on the login. Waiting for the entry and logging in + /// are bounded apart, each by the login timeout: the wait is for another connection's dbopen to the same server + /// name, and a single deadline over both would fail this one as a timeout without ever trying it. + /// + /// The Kerberos ticket cache handed over for this connect is deleted here, however the connect ends, because this + /// is the one place that runs to the end of the attempt: the caller can give up while dbopen still reads the cache. + private func openConnection(for attempt: SingleResumeGate) throws -> UnsafeMutablePointer { + defer { discardKerberosCache() } let entry: MSSQLFreeTDSServerEntry do { entry = try MSSQLFreeTDSServerEntry(options: options) @@ -333,11 +334,17 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { let opened: UnsafeMutablePointer? do { - opened = try freetdsConfigFile.withEntry(entry) { + opened = try freetdsConfigFile.withEntry( + entry, + waitingAtMost: TimeInterval(connectDeadlineSeconds), + givingUpWhen: { attempt.isSettled } + ) { + guard !attempt.isSettled else { throw CancellationError() } + armDeadline(for: attempt) freetdsClearError(for: nil) return withKerberosEnvironmentIfNeeded { dbopen(login, entry.name) } } - } catch { + } catch let error as MSSQLFreeTDSConfigError { throw MSSQLCoreError.connectionFailed(error.localizedDescription) } guard let proc = opened else { @@ -346,6 +353,22 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { return proc } + private var connectDeadlineSeconds: Int { + options.loginTimeoutSeconds + Self.connectDeadlineMarginSeconds + } + + private func armDeadline(for attempt: SingleResumeGate) { + let isKerberos = options.authMethod == .windows + Self.deadlineQueue.asyncAfter(deadline: .now() + .seconds(connectDeadlineSeconds)) { + attempt.fail(MSSQLCoreError.connectionTimedOut(isKerberos: isKerberos)) + } + } + + private func discardKerberosCache() { + guard let cachePath = options.kerberosCachePath else { return } + try? FileManager.default.removeItem(atPath: cachePath) + } + private func configure(_ login: UnsafeMutablePointer) throws { for parameter in MSSQLLoginParameters.build( user: options.user, @@ -376,20 +399,6 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { ) } } - - #if os(macOS) - // Windows Auth cross-realm: FreeTDS otherwise builds its own SPN and only canonicalizes a - // short hostname (via getaddrinfo), never applying [domain_realm] to pick the realm. We - // resolve the canonical host + realm up front and hand FreeTDS the full SPN, so cross-realm - // and short-name/CNAME hosts authenticate like the JDBC driver does. - if options.authMethod == .windows, let spn = options.kerberosServicePrincipal, !spn.isEmpty { - guard dbsetlname(login, spn, Int32(DBSETSERVERPRINCIPAL)) == SUCCEED else { - throw MSSQLCoreError.connectionFailed( - String(localized: "The Kerberos service principal name is longer than the 128 bytes FreeTDS takes.") - ) - } - } - #endif } private func openFailure() -> MSSQLCoreError { @@ -418,7 +427,6 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { unsetenv("KRB5CCNAME") } Self.kerberosEnvLock.unlock() - try? FileManager.default.removeItem(atPath: cachePath) } return body() } diff --git a/Plugins/MSSQLDriverPlugin/MSSQLKerberosRealmResolver.swift b/Plugins/MSSQLDriverPlugin/MSSQLKerberosRealmResolver.swift index d598f2535e..b28702dbed 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLKerberosRealmResolver.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLKerberosRealmResolver.swift @@ -6,8 +6,8 @@ import TableProMSSQLCore /// /// macOS Heimdal does not apply the system Kerberos configuration (`[domain_realm]`) when FreeTDS /// builds its own SPN string, so a cross-realm host fails with `KRB5KDC_ERR_S_PRINCIPAL_UNKNOWN`. -/// We resolve the realm here (like the JDBC driver) and hand FreeTDS an explicit SPN via -/// `DBSETSERVERPRINCIPAL`. +/// We resolve the realm here (like the JDBC driver) and hand FreeTDS an explicit SPN as the `spn` +/// of the connection's freetds.conf entry. /// /// Once an explicit SPN is set, FreeTDS stops canonicalizing a short hostname to its FQDN (which it /// otherwise does with `getaddrinfo` for dot-less names). To avoid regressing those connections we diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 84e44a4688..77f3bc12d6 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -356,6 +356,12 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let conn: FreeTDSConnection do { let kerberosCachePath = try await acquireKerberosTicketIfNeeded(authMethod: authMethod) + var connectionOwnsKerberosCache = false + defer { + if !connectionOwnsKerberosCache, let kerberosCachePath { + try? FileManager.default.removeItem(atPath: kerberosCachePath) + } + } let kerberosServicePrincipal = try await resolveKerberosServicePrincipal(authMethod: authMethod) let fedAuthToken = try await resolveEntraTokenIfNeeded(authMethod: authMethod) var options = MSSQLConnectionOptions( @@ -374,6 +380,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { options.caCertificatePath = config.ssl.caCertificatePath options.fedAuthToken = fedAuthToken conn = FreeTDSConnection(options: options) + connectionOwnsKerberosCache = true try await conn.connect() } catch let error as MSSQLCoreError { switch error { diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 9656139ca9..e6018b2de4 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -184091,9 +184091,6 @@ }, "FreeTDS could not set up the login." : { - }, - "The Kerberos service principal name is longer than the 128 bytes FreeTDS takes." : { - }, "The user name is longer than the 128 bytes FreeTDS takes." : { @@ -184112,6 +184109,12 @@ }, "The CA certificate path cannot be passed to FreeTDS. Use a shorter path, without “;”, “#” or repeated spaces." : { + }, + "The Kerberos service principal name for this server cannot be passed to FreeTDS. Connect through a shorter host name." : { + + }, + "Another connection to %@ with other settings is still logging in. Try again once it finishes." : { + }, "The FreeTDS configuration could not be written: %@" : { diff --git a/TableProMobile/TableProMobile/Localizable.xcstrings b/TableProMobile/TableProMobile/Localizable.xcstrings index 9da753336b..e271d6421a 100644 --- a/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/TableProMobile/TableProMobile/Localizable.xcstrings @@ -21075,6 +21075,12 @@ }, "The CA certificate path cannot be passed to FreeTDS. Use a shorter path, without “;”, “#” or repeated spaces." : { + }, + "The Kerberos service principal name for this server cannot be passed to FreeTDS. Connect through a shorter host name." : { + + }, + "Another connection to %@ with other settings is still logging in. Try again once it finishes." : { + }, "The FreeTDS configuration could not be written: %@" : { diff --git a/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift b/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift index 2cae68258f..8b702323f8 100644 --- a/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift +++ b/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift @@ -73,14 +73,94 @@ struct MSSQLFreeTDSConfigTests { ]) } - @Test("Every entry states the authority and the hostname check, so a section named global cannot lend its own") + @Test("An IP address is named with its port, so connects to other ports on it, every tunnel among them, never share a name") + func addressNamedWithItsPort() throws { + let cases: [(host: String, port: Int, name: String, dialled: String)] = [ + ("127.0.0.1", 54_321, "127.0.0.1,54321", "127.0.0.1"), + ("10.0.0.5", 1_433, "10.0.0.5,1433", "10.0.0.5"), + ("::1", 1_433, "::1,1433", "::1"), + ("[::1]", 1_433, "::1,1433", "::1"), + ("fe80::1%en0", 14_330, "fe80::1%en0,14330", "fe80::1%en0") + ] + for (host, port, name, dialled) in cases { + let server = try entry(host: host, port: port, mode: .required) + #expect(server.name == name, "\(host)") + #expect(lines(server).prefix(3) == ["[\(name)]", "host = \(dialled)", "port = \(port)"], "\(host)") + } + } + + @Test("A host name in brackets is read without them, as libtds reads one") + func bracketsAroundAHostAreDropped() throws { + let server = try entry(host: "[db.example.com]", mode: .required) + + #expect(server.name == "db.example.com") + #expect(lines(server).prefix(2) == ["[db.example.com]", "host = db.example.com"]) + } + + @Test("Every entry states the authority, the hostname check and the service principal, so a section named global cannot lend its own") func everyEntryIsSelfContained() throws { for mode in SSLMode.allCases { let options = lines(try entry(mode: mode)).dropFirst().map { $0.split(separator: "=", maxSplits: 1).first.map { $0.trimmingCharacters(in: .whitespaces) } ?? "" } - #expect(options == ["host", "port", "tds version", "encryption", "ca file", "check certificate hostname"], - "\(mode)") + #expect(options == [ + "host", "port", "tds version", "encryption", "ca file", "check certificate hostname", "spn" + ], "\(mode)") + } + } + + @Test("Windows Authentication writes its service principal into the entry, past the 128 bytes a login field takes") + func servicePrincipalIsWritten() throws { + let host = "sql-prod-availability-group-listener-001.finance.emea.corp.contoso-international.com" + let principal = "MSSQLSvc/\(host):1433@CORP.CONTOSO-INTERNATIONAL.COM" + let options = MSSQLConnectionOptions( + host: host, + user: "", + password: "", + database: "app", + authMethod: .windows, + kerberosServicePrincipal: principal + ) + + let server = try MSSQLFreeTDSServerEntry(options: options) + + #expect(principal.utf8.count > 128) + #expect(server.servicePrincipal == principal) + #expect(lines(server).last == "spn = \(principal)") + } + + @Test("A service principal is written only for Windows Authentication") + func servicePrincipalNeedsWindowsAuthentication() throws { + let options = MSSQLConnectionOptions( + host: "db.example.com", + user: "sa", + password: "secret", + database: "app", + kerberosServicePrincipal: "MSSQLSvc/db.example.com:1433@EXAMPLE.COM" + ) + + let server = try MSSQLFreeTDSServerEntry(options: options) + + #expect(server.servicePrincipal == nil) + #expect(lines(server).last == "spn =") + } + + @Test("A service principal libtds would cut short or reshape is refused", arguments: [ + "MSSQLSvc/" + String(repeating: "h", count: 240) + ":1433@EXAMPLE.COM", + "MSSQLSvc/db.example.com:1433@EXAMPLE;COM", + "MSSQLSvc/db.example.com:1433@EXAMPLE#COM", + "MSSQLSvc/db.example.com:1433\n\tencryption = off" + ]) + func unreadableServicePrincipals(principal: String) { + #expect(throws: MSSQLFreeTDSConfigError.unreadableServicePrincipal) { + try MSSQLFreeTDSServerEntry( + host: "db.example.com", + port: 1_433, + encryption: .require, + verification: .none, + caCertificatePath: nil, + servicePrincipal: principal + ) } } @@ -147,7 +227,8 @@ struct MSSQLFreeTDSConfigTests { "tds version = 7.4", "encryption = require", "ca file = /certs/corp.pem", - "check certificate hostname = yes" + "check certificate hostname = yes", + "spn =" ]) } @@ -162,7 +243,10 @@ struct MSSQLFreeTDSConfigTests { "db.example.com\n\tencryption = off", "db.example.com\r", "db example.com", - "[db.example.com]", + "[db.example.com", + "db.example.com]", + "[]", + "[[::1]]", "db=example.com", "db.example.com;comment", "db.example.com#comment", @@ -174,8 +258,8 @@ struct MSSQLFreeTDSConfigTests { } } - @Test("Host names and IP addresses are written as given", arguments: [ - "localhost", "127.0.0.1", "::1", "fe80::1%en0", "sql-01.corp.example.com", "MyServer" + @Test("Host names are written as given", arguments: [ + "localhost", "sql-01.corp.example.com", "MyServer", "myserver.database.windows.net" ]) func readableHosts(host: String) throws { #expect(try entry(host: host, mode: .required).name == host) diff --git a/scripts/check-mssql-encryption.sh b/scripts/check-mssql-encryption.sh index a35657e560..47e2ab931d 100755 --- a/scripts/check-mssql-encryption.sh +++ b/scripts/check-mssql-encryption.sh @@ -14,8 +14,13 @@ # - Verify CA and Verify Identity read TRUE with the authority that signed the server's certificate, and are refused # without it. Verify Identity is refused for a host name the certificate does not carry, and Verify CA is not. # - Connections to one host with different modes, opened at the same time, each get their own mode. -# - A connect to a server that never answers does not hold up a connect to another host. +# - A connect to a server that never answers does not hold up a connect to another host, nor one to another port on +# the same address, which is what every SSH tunnel on 127.0.0.1 is. +# - A connect that waits for another connect to the same host name logs in once that one ends, and one that waits +# longer than its login timeout gives up saying another connection holds the name, not that the server timed out. # - A password longer than db-lib takes fails the connect instead of logging in without one. +# - A Windows Authentication connect deletes the Kerberos ticket cache it was handed however it fails, and a service +# principal longer than the 128 bytes a login field takes reaches Kerberos. # - Against a server that cannot encrypt, which the check plays itself, Disabled and Preferred connect and Required is # refused, and only Required asks for encryption in the prelogin. # @@ -166,6 +171,7 @@ MANIFEST cat > "$WORK/Sources/Check/Check.swift" << 'SWIFT' import Darwin import Foundation +import TableProMSSQLCore import TableProPluginKit @main @@ -255,7 +261,11 @@ enum Check { await verifyingModesCheckTheCertificate() await concurrentModesOnOneHost() try await unansweredConnectHoldsUpNoOtherHost() + try await unansweredConnectHoldsUpNoOtherPortOnTheAddress() + try await waitForAHostNameIsBoundedAndSaysWhy() await overlongPasswordFailsTheConnect() + await kerberosCacheIsDeletedWhateverEndsTheConnect() + await longServicePrincipalReachesKerberos() await serverThatCannotEncrypt() } @@ -313,27 +323,15 @@ enum Check { } static func unansweredConnectHoldsUpNoOtherHost() async throws { - let listener = socket(AF_INET, SOCK_STREAM, 0) - var address = sockaddr_in() - address.sin_family = sa_family_t(AF_INET) - address.sin_addr.s_addr = inet_addr("127.0.0.1") - var length = socklen_t(MemoryLayout.size) - let bound = withUnsafeMutablePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { generic in - bind(listener, generic, length) == 0 && listen(listener, 8) == 0 - && getsockname(listener, generic, &length) == 0 - } - } - guard bound else { + guard let listener = SilentListener() else { expect(false, "a listener that never answers could be opened") return } - let silentPort = Int(UInt16(bigEndian: address.sin_port)) let silentHost = host == "127.0.0.1" ? "localhost" : "127.0.0.1" let silentFinished = Flag() let silent = Task { - let answer = await outcome(config(.required, host: silentHost, port: silentPort)) + let answer = await outcome(config(.required, host: silentHost, port: listener.port)) silentFinished.set() return answer } @@ -342,7 +340,7 @@ enum Check { let live = await outcome(config(.required)) let elapsed = Date().timeIntervalSince(started) let silentStillWaiting = !silentFinished.isSet - close(listener) + listener.close() let silentOutcome = await silent.value expect(live == "TRUE" && elapsed < 10 && silentStillWaiting, @@ -352,12 +350,158 @@ enum Check { "got \(silentOutcome)") } + static func unansweredConnectHoldsUpNoOtherPortOnTheAddress() async throws { + guard host == "127.0.0.1" else { + print("SKIP: connects to other ports on one address need the server on 127.0.0.1") + return + } + guard let first = SilentListener(), let second = SilentListener() else { + expect(false, "two listeners that never answer could be opened") + return + } + let silentFinished = Flag() + let silent = Task { + async let one = outcome(config(.preferred, host: "127.0.0.1", port: first.port)) + async let two = outcome(config(.required, host: "127.0.0.1", port: second.port)) + let answers = await [one, two] + silentFinished.set() + return answers + } + try await Task.sleep(nanoseconds: 1_500_000_000) + let started = Date() + let live = await outcome(config(.required)) + let elapsed = Date().timeIntervalSince(started) + let silentStillWaiting = !silentFinished.isSet + first.close() + second.close() + let silentOutcomes = await silent.value + + expect(live == "TRUE" && elapsed < 10 && silentStillWaiting, + "a connect to 127.0.0.1:\(port) finishes while two to other ports on 127.0.0.1, which never answer, wait", + String(format: "live=%@ in %.1fs", live, elapsed)) + expect(silentOutcomes.allSatisfy { $0.hasPrefix("refused") }, + "the connects that never got an answer fail once their servers go", "got \(silentOutcomes)") + } + + static func waitForAHostNameIsBoundedAndSaysWhy() async throws { + guard host == "127.0.0.1" else { + print("SKIP: a wait for localhost needs the server on 127.0.0.1") + return + } + guard let first = SilentListener(), let second = SilentListener() else { + expect(false, "two listeners that never answer could be opened") + return + } + + let holder = Task { await outcome(config(.required, host: "localhost", port: first.port)) } + try await Task.sleep(nanoseconds: 1_000_000_000) + var started = Date() + let waiter = Task { await outcome(config(.required, host: "localhost")) } + try await Task.sleep(nanoseconds: 10_000_000_000) + first.close() + let waited = await waiter.value + var elapsed = Date().timeIntervalSince(started) + _ = await holder.value + expect(waited == "TRUE" && elapsed > 9, + "a connect to localhost that waits for another to localhost logs in once that one ends", + String(format: "live=%@ after %.1fs", waited, elapsed)) + + guard let third = SilentListener() else { + expect(false, "a third listener that never answers could be opened") + return + } + let holders = Task { + async let one = outcome(config(.required, host: "localhost", port: second.port)) + try? await Task.sleep(nanoseconds: 500_000_000) + async let two = outcome(config(.required, host: "localhost", port: third.port)) + return await [one, two] + } + try await Task.sleep(nanoseconds: 1_500_000_000) + started = Date() + let starved = await outcome(config(.required, host: "localhost")) + elapsed = Date().timeIntervalSince(started) + second.close() + third.close() + let holderOutcomes = await holders.value + + let limit = Double(MSSQLConnectionOptions.defaultLoginTimeoutSeconds + 5) + expect(starved.contains("Another connection to localhost") && elapsed >= limit - 1 && elapsed < limit + 5, + "a connect to localhost behind two others that never answer gives up at its limit and says why", + String(format: "got %@ after %.1fs", starved, elapsed)) + expect(holderOutcomes.allSatisfy { $0.hasPrefix("refused") }, + "the connects that held localhost fail once their servers go", "got \(holderOutcomes)") + } + static func overlongPasswordFailsTheConnect() async { let seen = await outcome(config(.required, password: String(repeating: "p", count: 200))) expect(seen.hasPrefix("refused") && seen.contains("128 bytes"), "a password db-lib refuses fails the connect and says why", "got \(seen)") } + static func windowsOptions( + host: String = Check.host, + database: String = Check.database, + servicePrincipal: String? = nil, + cachePath: String? = nil + ) -> MSSQLConnectionOptions { + MSSQLConnectionOptions( + host: host, + port: port, + user: "", + password: "", + database: database, + encryptionLevel: .require, + authMethod: .windows, + kerberosCachePath: cachePath, + kerberosServicePrincipal: servicePrincipal + ) + } + + static func windowsConnectFailure(_ options: MSSQLConnectionOptions) async -> String? { + let connection = FreeTDSConnection(options: options) + do { + try await connection.connect() + connection.disconnect() + return nil + } catch { + return error.localizedDescription + } + } + + static func kerberosCacheIsDeletedWhateverEndsTheConnect() async { + let tooLongPrincipal = "MSSQLSvc/" + String(repeating: "h", count: 240) + ":\(port)@EXAMPLE.COM" + let cases: [(label: String, options: (String) -> MSSQLConnectionOptions)] = [ + ("a database name db-lib refuses", { windowsOptions(database: String(repeating: "d", count: 129), cachePath: $0) }), + ("a host FreeTDS cannot be given", { windowsOptions(host: "[\(host)", cachePath: $0) }), + ("a service principal FreeTDS cannot be given", + { windowsOptions(servicePrincipal: tooLongPrincipal, cachePath: $0) }), + ("a login Kerberos refuses", { windowsOptions(cachePath: $0) }) + ] + for (label, options) in cases { + let cachePath = (NSTemporaryDirectory() as NSString) + .appendingPathComponent("tablepro-krb5-check-\(UUID().uuidString)") + guard FileManager.default.createFile(atPath: cachePath, contents: Data("ticket".utf8)) else { + expect(false, "a stand-in ticket cache could be written") + return + } + let failure = await windowsConnectFailure(options(cachePath)) + let survived = FileManager.default.fileExists(atPath: cachePath) + try? FileManager.default.removeItem(atPath: cachePath) + expect(failure != nil && !survived, "the ticket cache is gone after \(label) fails the connect", + "failure=\(failure ?? "none") cache survived=\(survived)") + } + } + + static func longServicePrincipalReachesKerberos() async { + let principal = "MSSQLSvc/sql-prod-availability-group-listener-001.finance.emea.corp.contoso-international.com" + + ":\(port)@CORP.CONTOSO-INTERNATIONAL.COM" + let failure = await windowsConnectFailure(windowsOptions(servicePrincipal: principal)) ?? "connected" + let refusedBeforeKerberos = failure.contains("128 bytes") || failure.contains("cannot be passed to FreeTDS") + expect(principal.utf8.count > 128 && failure != "connected" && !refusedBeforeKerberos, + "a \(principal.utf8.count)-byte service principal is handed to Kerberos, which fails without a ticket", + "got \(failure)") + } + static func connectFailure(_ config: DriverConnectionConfig) async -> String? { let driver = MSSQLPluginDriver(config: config) do { @@ -515,6 +659,37 @@ final class ServerWithoutEncryption: @unchecked Sendable { } } +/// Takes connections on 127.0.0.1 and never answers, which is what a tunnel to a server that has gone quiet does. +/// Closing it resets every connection still waiting on it. +final class SilentListener: @unchecked Sendable { + let port: Int + private let descriptor: Int32 + + init?() { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + var address = sockaddr_in() + address.sin_family = sa_family_t(AF_INET) + address.sin_addr.s_addr = inet_addr("127.0.0.1") + var length = socklen_t(MemoryLayout.size) + let bound = withUnsafeMutablePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { generic in + bind(fd, generic, length) == 0 && listen(fd, 8) == 0 && getsockname(fd, generic, &length) == 0 + } + } + guard bound else { + Darwin.close(fd) + return nil + } + descriptor = fd + port = Int(UInt16(bigEndian: address.sin_port)) + } + + func close() { + Darwin.close(descriptor) + } +} + final class Flag: @unchecked Sendable { private let lock = NSLock() private var raised = false