From 5c63e117a96e884bf47ff724567bfdcf5e1456b8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 15:27:07 +0700 Subject: [PATCH 01/22] fix(ios): send the Redis ACL username so Valkey and Redis 6 users can sign in --- CHANGELOG.md | 7 +- .../RedisDriverPlugin/RedisAuthCommand.swift | 30 +++++ .../RedisDatabaseIndex.swift | 10 ++ .../RedisPluginConnection.swift | 8 +- .../RedisDriverPlugin/RedisPluginDriver.swift | 2 +- .../Drivers/DriverSSLConfiguration.swift | 16 ++- .../TableProMobile/Drivers/RedisDriver.swift | 111 ++++++++++++++---- .../Platform/IOSDriverFactory.swift | 6 +- .../Views/ConnectionFormView.swift | 10 +- .../Drivers/DriverSSLConfigurationTests.swift | 32 +++++ TableProMobile/project.yml | 3 + .../Plugins/RedisAuthCommandTests.swift | 82 +++++++++++++ .../Plugins/RedisDatabaseIndexTests.swift | 27 +++++ docs/databases/redis.mdx | 4 + project.yml | 2 + 15 files changed, 315 insertions(+), 35 deletions(-) create mode 100644 Plugins/RedisDriverPlugin/RedisAuthCommand.swift create mode 100644 Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift create mode 100644 TableProTests/Plugins/RedisAuthCommandTests.swift create mode 100644 TableProTests/Plugins/RedisDatabaseIndexTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d26c51f48..9be0b5699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- TablePro Mobile keeps remote connections open when you switch apps, so coming back no longer reconnects and reloads everything. +- Mobile keeps remote connections open when you switch apps. ### Fixed -- TablePro Mobile no longer gets killed by iOS when you leave the app with a DuckDB file open. +- Mobile no longer gets killed by iOS when you leave the app with a DuckDB file open. +- Redis and Valkey ACL users can sign in on mobile. The username was dropped, so every login was rejected. +- A rejected Redis login now says what to change. +- Mobile opens the Redis database index saved on a connection, not always database 0. ## [0.64.0] - 2026-08-10 diff --git a/Plugins/RedisDriverPlugin/RedisAuthCommand.swift b/Plugins/RedisDriverPlugin/RedisAuthCommand.swift new file mode 100644 index 000000000..94ba19b35 --- /dev/null +++ b/Plugins/RedisDriverPlugin/RedisAuthCommand.swift @@ -0,0 +1,30 @@ +import Foundation + +enum RedisAuthCommand { + enum Failure: Equatable, Sendable { + case rejectedCredentials + case rejectedWithoutUsername + case serverHasNoPassword + case usernameUnsupported + case unrecognized + } + + static func arguments(username: String?, password: String?) -> [String]? { + guard let password, !password.isEmpty else { return nil } + guard let username, !username.isEmpty else { return ["AUTH", password] } + return ["AUTH", username, password] + } + + static func failure(serverError: String, hadUsername: Bool) -> Failure { + let text = serverError.lowercased() + if text.contains("wrong number of arguments") { return .usernameUnsupported } + if text.contains("without any password configured") || text.contains("no password is set") { + return .serverHasNoPassword + } + if text.contains("invalid password") { return .rejectedCredentials } + if text.contains("wrongpass") { + return hadUsername ? .rejectedCredentials : .rejectedWithoutUsername + } + return .unrecognized + } +} diff --git a/Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift b/Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift new file mode 100644 index 000000000..162fe096d --- /dev/null +++ b/Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift @@ -0,0 +1,10 @@ +import Foundation + +enum RedisDatabaseIndex { + static let fieldName = "redisDatabase" + + static func resolve(additionalFields: [String: String], database: String) -> Int { + if let field = additionalFields[fieldName], let index = Int(field) { return index } + return Int(database) ?? 0 + } +} diff --git a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift index 0326db0a2..fa98f92e5 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift @@ -447,13 +447,7 @@ private extension RedisPluginConnection { } func authenticateSync() throws { - guard let password, !password.isEmpty else { return } - let authArgs: [String] - if let username, !username.isEmpty { - authArgs = ["AUTH", username, password] - } else { - authArgs = ["AUTH", password] - } + guard let authArgs = RedisAuthCommand.arguments(username: username, password: password) else { return } let reply = try executeCommandSync(authArgs) if case .error(let msg) = reply { throw RedisPluginError(code: 1, message: "AUTH failed: \(msg)") diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index 38854f44b..32fd7b646 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -65,7 +65,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func connect(reportingStage report: @escaping ConnectionStageReporter) async throws { let sslConfig = config.ssl - let redisDb = Int(config.additionalFields["redisDatabase"] ?? "") ?? Int(config.database) ?? 0 + let redisDb = RedisDatabaseIndex.resolve(additionalFields: config.additionalFields, database: config.database) let conn = RedisPluginConnection( host: config.host, diff --git a/TableProMobile/TableProMobile/Drivers/DriverSSLConfiguration.swift b/TableProMobile/TableProMobile/Drivers/DriverSSLConfiguration.swift index 925901e99..1cbeaa4e2 100644 --- a/TableProMobile/TableProMobile/Drivers/DriverSSLConfiguration.swift +++ b/TableProMobile/TableProMobile/Drivers/DriverSSLConfiguration.swift @@ -75,8 +75,20 @@ struct DriverSSLConfiguration: Equatable, Sendable { } var existingCACertificatePath: String? { - guard verifiesCertificate, - let path = caCertificatePath, + guard verifiesCertificate else { return nil } + return Self.existingPath(caCertificatePath) + } + + var existingClientCertificatePath: String? { + Self.existingPath(clientCertificatePath) + } + + var existingClientKeyPath: String? { + Self.existingPath(clientKeyPath) + } + + private static func existingPath(_ path: String?) -> String? { + guard let path, !path.isEmpty, FileManager.default.fileExists(atPath: path) else { return nil } return path diff --git a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift index 478f85e73..979cf3ae6 100644 --- a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift @@ -8,6 +8,7 @@ final class RedisDriver: DatabaseDriver, @unchecked Sendable { private let actor = RedisActor() private let host: String private let port: Int + private let username: String? private let password: String? private let database: Int let ssl: DriverSSLConfiguration @@ -19,9 +20,17 @@ final class RedisDriver: DatabaseDriver, @unchecked Sendable { // Set once during connect() before the driver is shared — safe for concurrent reads nonisolated(unsafe) private(set) var serverVersion: String? - init(host: String, port: Int, password: String?, database: Int = 0, ssl: DriverSSLConfiguration = .disabled) { + init( + host: String, + port: Int, + username: String? = nil, + password: String?, + database: Int = 0, + ssl: DriverSSLConfiguration = .disabled + ) { self.host = host self.port = port + self.username = username self.password = password self.database = database self.ssl = ssl @@ -31,7 +40,14 @@ final class RedisDriver: DatabaseDriver, @unchecked Sendable { func connect() async throws { try await LocalNetworkPermission.shared.ensureAccess(for: host) - try await actor.connect(host: host, port: port, password: password, database: database, ssl: ssl) + try await actor.connect( + host: host, + port: port, + username: username, + password: password, + database: database, + ssl: ssl + ) serverVersion = try? await actor.fetchServerVersion() } @@ -356,7 +372,14 @@ private actor RedisActor { } }() - func connect(host: String, port: Int, password: String?, database: Int, ssl: DriverSSLConfiguration) throws { + func connect( + host: String, + port: Int, + username: String?, + password: String?, + database: Int, + ssl: DriverSSLConfiguration + ) throws { // Close existing connection if reconnecting close() @@ -382,21 +405,12 @@ private actor RedisActor { if ssl.isEnabled { _ = Self.initSSL - let sslCtx: OpaquePointer = try host.withCString { hostCStr in - try withOptionalCString(ssl.existingCACertificatePath) { caCStr in - var sslError = redisSSLContextError(0) - var options = redisSSLOptions() - memset(&options, 0, MemoryLayout.size) - options.server_name = hostCStr - options.cacert_filename = caCStr - options.verify_mode = ssl.verifiesCertificate ? REDIS_SSL_VERIFY_PEER : REDIS_SSL_VERIFY_NONE - - guard let created = redisCreateSSLContextWithOptions(&options, &sslError) else { - redisFree(context) - throw RedisError.connectionFailed("Failed to create SSL context (error \(sslError.rawValue))") - } - return created - } + let sslCtx: OpaquePointer + do { + sslCtx = try Self.makeSSLContext(host: host, ssl: ssl) + } catch { + redisFree(context) + throw error } let result = redisInitiateSSLWithContext(context, sslCtx) @@ -413,10 +427,16 @@ private actor RedisActor { self.ctx = context do { - if let password, !password.isEmpty { - let reply = try executeCommand(["AUTH", password]) + if let authArgs = RedisAuthCommand.arguments(username: username, password: password) { + let reply = try executeCommand(authArgs) if case .error(let msg) = reply { - throw RedisError.connectionFailed("Authentication failed: \(msg)") + throw RedisError.authenticationFailed( + serverMessage: msg, + failure: RedisAuthCommand.failure( + serverError: msg, + hadUsername: !(username ?? "").isEmpty + ) + ) } } @@ -432,6 +452,32 @@ private actor RedisActor { } } + private static func makeSSLContext(host: String, ssl: DriverSSLConfiguration) throws -> OpaquePointer { + try host.withCString { hostCStr in + try withOptionalCString(ssl.existingCACertificatePath) { caCStr in + try withOptionalCString(ssl.existingClientCertificatePath) { certCStr in + try withOptionalCString(ssl.existingClientKeyPath) { keyCStr in + var sslError = redisSSLContextError(0) + var options = redisSSLOptions() + memset(&options, 0, MemoryLayout.size) + options.server_name = hostCStr + options.cacert_filename = caCStr + options.cert_filename = certCStr + options.private_key_filename = keyCStr + options.verify_mode = ssl.verifiesCertificate ? REDIS_SSL_VERIFY_PEER : REDIS_SSL_VERIFY_NONE + + guard let created = redisCreateSSLContextWithOptions(&options, &sslError) else { + throw RedisError.connectionFailed( + "Failed to create SSL context (error \(sslError.rawValue))" + ) + } + return created + } + } + } + } + } + func close() { if let ctx { redisFree(ctx) @@ -534,6 +580,7 @@ private actor RedisActor { enum RedisError: Error, LocalizedError { case connectionFailed(String) + case authenticationFailed(serverMessage: String, failure: RedisAuthCommand.Failure) case notConnected case queryFailed(String) case unsupported(String) @@ -541,9 +588,31 @@ enum RedisError: Error, LocalizedError { var errorDescription: String? { switch self { case .connectionFailed(let msg): return "Redis connection failed: \(msg)" + case .authenticationFailed(let serverMessage, let failure): + guard let hint = Self.hint(for: failure) else { + return String(format: String(localized: "Redis authentication failed: %@"), serverMessage) + } + return String( + format: String(localized: "Redis authentication failed: %1$@ %2$@"), + serverMessage, + hint + ) case .notConnected: return "Not connected to Redis" case .queryFailed(let msg): return "Redis command failed: \(msg)" case .unsupported(let msg): return msg } } + + private static func hint(for failure: RedisAuthCommand.Failure) -> String? { + switch failure { + case .rejectedWithoutUsername: + return String(localized: "If this server uses Redis 6 or later ACL users, fill in the Username field.") + case .serverHasNoPassword: + return String(localized: "This server has no password set for the default user. Clear the Password field.") + case .usernameUnsupported: + return String(localized: "This server predates Redis 6 and takes no username. Clear the Username field.") + case .rejectedCredentials, .unrecognized: + return nil + } + } } diff --git a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift index 2fe60c2bf..13431cdb4 100644 --- a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift +++ b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift @@ -37,10 +37,14 @@ final class IOSDriverFactory: DriverFactory { ssl: DriverSSLConfiguration(sslEnabled: connection.sslEnabled, configuration: connection.sslConfiguration) ) case .redis: - let dbIndex = Int(connection.database) ?? 0 + let dbIndex = RedisDatabaseIndex.resolve( + additionalFields: connection.additionalFields, + database: connection.database + ) return RedisDriver( host: connection.host, port: connection.port, + username: connection.username, password: password, database: dbIndex, ssl: DriverSSLConfiguration(sslEnabled: connection.sslEnabled, configuration: connection.sslConfiguration) diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index acb3e8b1b..ffa0d93dc 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -313,7 +313,7 @@ struct ConnectionFormView: View { @ViewBuilder private func serverSection(viewModel: ConnectionFormViewModel) -> some View { @Bindable var viewModel = viewModel - Section("Server") { + Section { TextField("Host", text: $viewModel.host) .textInputAutocapitalization(.never) .keyboardType(.URL) @@ -321,7 +321,15 @@ struct ConnectionFormView: View { .keyboardType(.numberPad) TextField("Username", text: $viewModel.username) .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.asciiCapable) SecureField("Password", text: $viewModel.password) + } header: { + Text("Server") + } footer: { + if viewModel.type == .redis { + Text("Username is for Redis 6 and later ACL users. Leave it empty for password-only servers.") + } } Section("Database") { TextField("Database Name", text: $viewModel.database) diff --git a/TableProMobile/TableProMobileTests/Drivers/DriverSSLConfigurationTests.swift b/TableProMobile/TableProMobileTests/Drivers/DriverSSLConfigurationTests.swift index 80778b8bb..09d2513fc 100644 --- a/TableProMobile/TableProMobileTests/Drivers/DriverSSLConfigurationTests.swift +++ b/TableProMobile/TableProMobileTests/Drivers/DriverSSLConfigurationTests.swift @@ -83,4 +83,36 @@ struct DriverSSLConfigurationTests { let missing = DriverSSLConfiguration(mode: .verifyFull, caCertificatePath: "/does/not/exist.pem") #expect(missing.existingCACertificatePath == nil) } + + @Test("client certificate and key are used without requiring server verification") + func clientCertificateIndependentOfVerification() { + let certPath = NSTemporaryDirectory() + "tablepro-cert-\(UUID().uuidString).pem" + let keyPath = NSTemporaryDirectory() + "tablepro-key-\(UUID().uuidString).pem" + FileManager.default.createFile(atPath: certPath, contents: Data("cert".utf8)) + FileManager.default.createFile(atPath: keyPath, contents: Data("key".utf8)) + defer { + try? FileManager.default.removeItem(atPath: certPath) + try? FileManager.default.removeItem(atPath: keyPath) + } + + let ssl = DriverSSLConfiguration( + mode: .require, + clientCertificatePath: certPath, + clientKeyPath: keyPath + ) + #expect(ssl.existingClientCertificatePath == certPath) + #expect(ssl.existingClientKeyPath == keyPath) + } + + @Test("missing or empty client certificate paths resolve to nil") + func clientCertificateMissing() { + let absent = DriverSSLConfiguration( + mode: .verifyFull, + clientCertificatePath: "/does/not/exist.pem", + clientKeyPath: "" + ) + #expect(absent.existingClientCertificatePath == nil) + #expect(absent.existingClientKeyPath == nil) + #expect(DriverSSLConfiguration(mode: .require).existingClientCertificatePath == nil) + } } diff --git a/TableProMobile/project.yml b/TableProMobile/project.yml index c6253907f..1b5a6a03c 100644 --- a/TableProMobile/project.yml +++ b/TableProMobile/project.yml @@ -48,6 +48,9 @@ targets: # bundle on iOS, so the driver links into the app. - ../Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift - ../Plugins/MSSQLDriverPlugin/MSSQLLoginParameters.swift + # Redis credential rules the iOS driver shares with the macOS plugin. + - ../Plugins/RedisDriverPlugin/RedisAuthCommand.swift + - ../Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift configFiles: Debug: ../Configs/Version-iOS.xcconfig Release: ../Configs/Version-iOS.xcconfig diff --git a/TableProTests/Plugins/RedisAuthCommandTests.swift b/TableProTests/Plugins/RedisAuthCommandTests.swift new file mode 100644 index 000000000..8fa671b62 --- /dev/null +++ b/TableProTests/Plugins/RedisAuthCommandTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing + +@Suite("Redis AUTH command") +struct RedisAuthCommandTests { + @Test("no password sends no AUTH at all") + func noPassword() { + #expect(RedisAuthCommand.arguments(username: nil, password: nil) == nil) + #expect(RedisAuthCommand.arguments(username: nil, password: "") == nil) + #expect(RedisAuthCommand.arguments(username: "acl-user", password: nil) == nil) + #expect(RedisAuthCommand.arguments(username: "acl-user", password: "") == nil) + } + + @Test("password without a username uses the one-argument form") + func passwordOnly() { + #expect(RedisAuthCommand.arguments(username: nil, password: "s3cret") == ["AUTH", "s3cret"]) + #expect(RedisAuthCommand.arguments(username: "", password: "s3cret") == ["AUTH", "s3cret"]) + } + + @Test("a username sends the ACL form so the server does not fall back to the default user") + func usernameAndPassword() { + #expect( + RedisAuthCommand.arguments(username: "acl-user", password: "s3cret") + == ["AUTH", "acl-user", "s3cret"] + ) + } + + @Test("a 64-character hex password is passed through unchanged") + func generatedPasswordSurvivesIntact() { + let password = String(repeating: "a1b2c3d4", count: 8) + #expect(password.count == 64) + #expect( + RedisAuthCommand.arguments(username: "acl-user", password: password) + == ["AUTH", "acl-user", password] + ) + } + + @Test("WRONGPASS without a username points at the missing ACL username") + func wrongPassWithoutUsername() { + let serverError = "WRONGPASS invalid username-password pair or user is disabled." + #expect( + RedisAuthCommand.failure(serverError: serverError, hadUsername: false) + == .rejectedWithoutUsername + ) + } + + @Test("WRONGPASS with a username stays an ordinary credential rejection") + func wrongPassWithUsername() { + let serverError = "WRONGPASS invalid username-password pair or user is disabled." + #expect( + RedisAuthCommand.failure(serverError: serverError, hadUsername: true) + == .rejectedCredentials + ) + } + + @Test("a server with no password configured is reported as such") + func serverHasNoPassword() { + let modern = "ERR AUTH called without any password configured for the default user." + #expect(RedisAuthCommand.failure(serverError: modern, hadUsername: false) == .serverHasNoPassword) + + let legacy = "ERR Client sent AUTH, but no password is set" + #expect(RedisAuthCommand.failure(serverError: legacy, hadUsername: false) == .serverHasNoPassword) + } + + @Test("a pre-ACL server's wrong-password reply never suggests an ACL username") + func legacyInvalidPasswordCarriesNoUsernameHint() { + let serverError = "ERR invalid password" + #expect(RedisAuthCommand.failure(serverError: serverError, hadUsername: false) == .rejectedCredentials) + #expect(RedisAuthCommand.failure(serverError: serverError, hadUsername: true) == .rejectedCredentials) + } + + @Test("a pre-ACL server rejects the username form with an arity error") + func usernameUnsupported() { + let serverError = "ERR wrong number of arguments for 'auth' command" + #expect(RedisAuthCommand.failure(serverError: serverError, hadUsername: true) == .usernameUnsupported) + } + + @Test("an unrelated error carries no hint") + func unrecognized() { + #expect(RedisAuthCommand.failure(serverError: "LOADING dataset in memory", hadUsername: true) == .unrecognized) + } +} diff --git a/TableProTests/Plugins/RedisDatabaseIndexTests.swift b/TableProTests/Plugins/RedisDatabaseIndexTests.swift new file mode 100644 index 000000000..b9dbd7d1e --- /dev/null +++ b/TableProTests/Plugins/RedisDatabaseIndexTests.swift @@ -0,0 +1,27 @@ +import Foundation +import Testing + +@Suite("Redis database index") +struct RedisDatabaseIndexTests { + @Test("the dedicated field wins over the database name") + func fieldWins() { + let index = RedisDatabaseIndex.resolve(additionalFields: ["redisDatabase": "3"], database: "0") + #expect(index == 3) + } + + @Test("the database name is used when the field is absent") + func fallsBackToDatabase() { + #expect(RedisDatabaseIndex.resolve(additionalFields: [:], database: "7") == 7) + } + + @Test("a non-numeric field falls back to the database name") + func nonNumericField() { + #expect(RedisDatabaseIndex.resolve(additionalFields: ["redisDatabase": "db2"], database: "5") == 5) + } + + @Test("an unusable value resolves to database zero") + func defaultsToZero() { + #expect(RedisDatabaseIndex.resolve(additionalFields: [:], database: "") == 0) + #expect(RedisDatabaseIndex.resolve(additionalFields: ["redisDatabase": ""], database: "db0") == 0) + } +} diff --git a/docs/databases/redis.mdx b/docs/databases/redis.mdx index 36fd93871..3953d6e27 100644 --- a/docs/databases/redis.mdx +++ b/docs/databases/redis.mdx @@ -109,6 +109,10 @@ DBSIZE **Auth failed**: Verify password matches `requirepass` in `redis.conf`. For Redis 6.0+ ACL: `ACL SETUSER myuser on >password ~* +@all` +With the Username field empty, TablePro sends `AUTH password`, which Redis and Valkey always check against the `default` user. An ACL user's password fails that check with `WRONGPASS invalid username-password pair or user is disabled.` Fill in Username to authenticate as that user. + +Note that `>password` sets a password and `#hash` sets a SHA-256 hash. A 64-character hex string is valid for both, so `ACL SETUSER myuser on #<64-hex>` is accepted where `>` was meant, and every later login fails with `WRONGPASS`. `ACL LIST` shows stored passwords as hashes, so a value copied from there is a hash, not a password. + **Timeout**: Verify host/port, check network and firewall, whitelist IP for cloud-hosted Redis. **Slow key list**: `KEYS` blocks the server on a large keyspace. Browse by namespace instead, and use `SCAN` when you need a pattern match in the CLI. Check memory pressure with `INFO memory`. diff --git a/project.yml b/project.yml index e624027fa..98d212f9a 100644 --- a/project.yml +++ b/project.yml @@ -357,7 +357,9 @@ targets: - Plugins/PostgreSQLDriverPlugin/RedshiftExternalSchemaQueries.swift - Plugins/PostgreSQLDriverPlugin/RedshiftSchemaQueries.swift - Plugins/RedisDriverPlugin/RedisArgumentCodec.swift + - Plugins/RedisDriverPlugin/RedisAuthCommand.swift - Plugins/RedisDriverPlugin/RedisCommandParser.swift + - Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift - Plugins/RedisDriverPlugin/RedisKeySummary.swift - Plugins/RedisDriverPlugin/RedisQueryBuilder.swift - Plugins/RedisDriverPlugin/RedisStatementGenerator.swift From 20a58bc7939e3d9ccdd91617b6d31b3d28512730 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 16:52:57 +0700 Subject: [PATCH 02/22] ci: pin third-party actions to an exact commit --- .github/workflows/build-plugin.yml | 2 +- .github/workflows/build.yml | 8 ++++---- .github/workflows/ios-tests.yml | 2 +- .github/workflows/macos-tests.yml | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index f3e9722eb..82ab98e47 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -90,7 +90,7 @@ jobs: run: git lfs pull - name: Select Xcode - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: "26.4.1" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bd70b0bbd..20e4ba8f5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,7 +40,7 @@ jobs: uses: actions/checkout@v4 - name: Select Xcode - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: '26.4.1' @@ -155,7 +155,7 @@ jobs: uses: actions/checkout@v4 - name: Select Xcode - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: '26.4.1' @@ -305,7 +305,7 @@ jobs: fetch-depth: 0 - name: Select Xcode - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: '26.4.1' @@ -393,7 +393,7 @@ jobs: run: scripts/ci/extract-release-notes.sh "${GITHUB_REF#refs/tags/v}" - name: Create GitHub Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 with: files: | artifacts/*.dmg diff --git a/.github/workflows/ios-tests.yml b/.github/workflows/ios-tests.yml index 791149258..2adda6860 100644 --- a/.github/workflows/ios-tests.yml +++ b/.github/workflows/ios-tests.yml @@ -38,7 +38,7 @@ jobs: - uses: actions/checkout@v4 - name: Select Xcode - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: '26.4.1' diff --git a/.github/workflows/macos-tests.yml b/.github/workflows/macos-tests.yml index 2a8a746a2..748ff15b6 100644 --- a/.github/workflows/macos-tests.yml +++ b/.github/workflows/macos-tests.yml @@ -46,7 +46,7 @@ jobs: - uses: actions/checkout@v4 - name: Select Xcode - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: '26.4.1' @@ -61,7 +61,7 @@ jobs: - uses: actions/checkout@v4 - name: Select Xcode - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: '26.4.1' @@ -82,7 +82,7 @@ jobs: - uses: actions/checkout@v4 - name: Select Xcode - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: '26.4.1' From 085e80274cf9a456d8c85a65a4bfaea894518a82 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 16:52:58 +0700 Subject: [PATCH 03/22] build(ssh): patch libssh2 against CVE-2026-55199 --- scripts/patches/libssh2-cve-2026-55199.patch | 36 ++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 scripts/patches/libssh2-cve-2026-55199.patch diff --git a/scripts/patches/libssh2-cve-2026-55199.patch b/scripts/patches/libssh2-cve-2026-55199.patch new file mode 100644 index 000000000..a8d830a14 --- /dev/null +++ b/scripts/patches/libssh2-cve-2026-55199.patch @@ -0,0 +1,36 @@ +CVE-2026-55199: pre-authentication CPU exhaustion in the SSH_MSG_EXT_INFO handler + +libssh2 through 1.11.1 reads nr-extensions from SSH_MSG_EXT_INFO as an untrusted uint32 and +loops that many times, ignoring the return value of _libssh2_get_string() on each iteration. +A malicious server declares nr-extensions = 0xFFFFFFFF with a short body; the reads fail +immediately but the loop still runs to completion, spinning the CPU for billions of +iterations. EXT_INFO arrives during key exchange, so this is reachable before the client has +authenticated and before any credential is sent. + +The session read timeout does not apply: the loop never returns to the transport layer. + +The fix stops the loop the first time a string cannot be read, which bounds it by the bytes +actually present rather than by the server's declared count. + +1.11.1 is the newest release and no release carries the fix yet. Delete this patch and bump +LIBSSH2_VERSION once one does. + +https://nvd.nist.gov/vuln/detail/CVE-2026-55199 + +--- a/src/packet.c ++++ b/src/packet.c +@@ -868,8 +868,12 @@ + + nr_extensions -= 1; + +- _libssh2_get_string(&buf, &name, &name_len); +- _libssh2_get_string(&buf, &value, &value_len); ++ if(_libssh2_get_string(&buf, &name, &name_len) || ++ _libssh2_get_string(&buf, &value, &value_len)) { ++ rc = _libssh2_error(session, LIBSSH2_ERROR_PROTO, ++ "Invalid extension info received"); ++ break; ++ } + + if(name && value) { + _libssh2_debug((session, From ba9baf91555dc0070a2cd30681045e207d2d0720 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 16:52:58 +0700 Subject: [PATCH 04/22] fix(ssh): report a changed host key when the server offers a different key type --- TablePro/Core/SSH/HostKeyStore.swift | 9 +++-- TablePro/Core/SSH/HostKeyVerifier.swift | 9 +++-- .../Core/SSH/HostKeyStoreTests.swift | 36 +++++++++++++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/TablePro/Core/SSH/HostKeyStore.swift b/TablePro/Core/SSH/HostKeyStore.swift index 1882e5476..dd5eb084b 100644 --- a/TablePro/Core/SSH/HostKeyStore.swift +++ b/TablePro/Core/SSH/HostKeyStore.swift @@ -60,17 +60,20 @@ internal final class HostKeyStore: @unchecked Sendable { let currentFingerprint = Self.fingerprint(of: keyData) let entries = loadEntries() - guard let existing = entries.first(where: { $0.host == hostKey && $0.keyType == keyType }) else { + let hostEntries = entries.filter { $0.host == hostKey } + guard !hostEntries.isEmpty else { Self.logger.info("Unknown host key for \(hostKey)") return .unknown(fingerprint: currentFingerprint, keyType: keyType) } - let storedFingerprint = Self.fingerprint(of: existing.keyData) - if storedFingerprint == currentFingerprint { + if hostEntries.contains(where: { Self.fingerprint(of: $0.keyData) == currentFingerprint }) { Self.logger.debug("Host key trusted for \(hostKey)") return .trusted } + let sameType = hostEntries.first { $0.keyType == keyType } + let storedFingerprint = Self.fingerprint(of: (sameType ?? hostEntries[0]).keyData) + Self.logger.warning("Host key mismatch for \(hostKey)") return .mismatch(expected: storedFingerprint, actual: currentFingerprint) } diff --git a/TablePro/Core/SSH/HostKeyVerifier.swift b/TablePro/Core/SSH/HostKeyVerifier.swift index 7caf12d01..9f8c10633 100644 --- a/TablePro/Core/SSH/HostKeyVerifier.swift +++ b/TablePro/Core/SSH/HostKeyVerifier.swift @@ -107,9 +107,12 @@ internal enum HostKeyVerifier { let alert = NSAlert() alert.messageText = title alert.informativeText = message - alert.alertStyle = .informational - alert.addButton(withTitle: String(localized: "Trust")) - alert.addButton(withTitle: String(localized: "Cancel")) + alert.alertStyle = .warning + AlertHelper.addConfirmAndCancel( + to: alert, + confirmButton: String(localized: "Trust"), + cancelButton: String(localized: "Cancel") + ) if let window = AlertHelper.resolveWindow(nil) { return await withCheckedContinuation { continuation in diff --git a/TableProTests/Core/SSH/HostKeyStoreTests.swift b/TableProTests/Core/SSH/HostKeyStoreTests.swift index ee09f941e..784bb2bc7 100644 --- a/TableProTests/Core/SSH/HostKeyStoreTests.swift +++ b/TableProTests/Core/SSH/HostKeyStoreTests.swift @@ -69,6 +69,42 @@ struct HostKeyStoreTests { #expect(result == .mismatch(expected: expectedFingerprint, actual: actualFingerprint)) } + @Test("A different key type for a known host is a mismatch, not a first-use prompt") + func testDifferentKeyTypeForKnownHostIsMismatch() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let trustedKey = makeTestKey(0xEE) + let attackerKey = makeTestKey(0xEF) + + store.trust(hostname: "example.com", port: 22, key: trustedKey, keyType: "ssh-ed25519") + + let result = store.verify(keyData: attackerKey, keyType: "ssh-rsa", hostname: "example.com", port: 22) + #expect( + result == .mismatch( + expected: HostKeyStore.fingerprint(of: trustedKey), + actual: HostKeyStore.fingerprint(of: attackerKey) + ) + ) + } + + @Test("A host trusted under two key types verifies either one") + func testTrustedKeyOfEitherTypeVerifies() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let rsaKey = makeTestKey(0x11) + let edKey = makeTestKey(0x22) + + store.trust(hostname: "example.com", port: 22, key: rsaKey, keyType: "ssh-rsa") + store.trust(hostname: "example.com", port: 22, key: edKey, keyType: "ssh-ed25519") + + #expect(store.verify(keyData: rsaKey, keyType: "ssh-rsa", hostname: "example.com", port: 22) == .trusted) + #expect(store.verify(keyData: edKey, keyType: "ssh-ed25519", hostname: "example.com", port: 22) == .trusted) + } + @Test("Remove a host key then verify returns .unknown") func testRemove() { let path = makeTempFilePath() From 09e8813933bf10c0d70a1504f826ccc9ee254f04 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 16:52:59 +0700 Subject: [PATCH 05/22] fix(ios): verify the SSH host key before sending any credential --- .../TableProMobile/SSH/HostKeyStore.swift | 148 ++++++++++++++++ .../TableProMobile/SSH/HostKeyVerifier.swift | 159 ++++++++++++++++++ .../TableProMobile/SSH/SSHTunnel.swift | 14 ++ .../TableProMobile/SSH/SSHTunnelError.swift | 2 + .../TableProMobile/SSH/SSHTunnelFactory.swift | 13 ++ .../TableProMobile/TableProMobileApp.swift | 1 + .../SSH/HostKeyStoreTests.swift | 126 ++++++++++++++ 7 files changed, 463 insertions(+) create mode 100644 TableProMobile/TableProMobile/SSH/HostKeyStore.swift create mode 100644 TableProMobile/TableProMobile/SSH/HostKeyVerifier.swift create mode 100644 TableProMobile/TableProMobileTests/SSH/HostKeyStoreTests.swift diff --git a/TableProMobile/TableProMobile/SSH/HostKeyStore.swift b/TableProMobile/TableProMobile/SSH/HostKeyStore.swift new file mode 100644 index 000000000..75575eb8d --- /dev/null +++ b/TableProMobile/TableProMobile/SSH/HostKeyStore.swift @@ -0,0 +1,148 @@ +// +// HostKeyStore.swift +// TableProMobile +// +// Trusted SSH host keys, stored line-based in the app's Application Support +// directory. Mirrors the Mac store's file format and verification semantics. +// + +import CryptoKit +import Foundation +import os + +final class HostKeyStore: @unchecked Sendable { + static let shared = HostKeyStore() + + private static let logger = Logger(subsystem: "com.TablePro", category: "HostKeyStore") + + enum VerificationResult: Equatable { + case trusted + case unknown(fingerprint: String, keyType: String) + case mismatch(expected: String, actual: String) + } + + private let filePath: String + private let lock = NSLock() + + private init() { + guard let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first else { + self.filePath = NSTemporaryDirectory() + "TablePro_known_hosts" + return + } + try? FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) + self.filePath = appSupport.appendingPathComponent("known_hosts").path + } + + init(filePath: String) { + self.filePath = filePath + } + + func verify(keyData: Data, keyType: String, hostname: String, port: Int) -> VerificationResult { + lock.lock() + defer { lock.unlock() } + + let hostKey = hostIdentifier(hostname, port) + let currentFingerprint = Self.fingerprint(of: keyData) + let entries = loadEntries() + + let hostEntries = entries.filter { $0.host == hostKey } + guard !hostEntries.isEmpty else { + Self.logger.info("Unknown host key for \(hostKey)") + return .unknown(fingerprint: currentFingerprint, keyType: keyType) + } + + if hostEntries.contains(where: { Self.fingerprint(of: $0.keyData) == currentFingerprint }) { + return .trusted + } + + let sameType = hostEntries.first { $0.keyType == keyType } + let storedFingerprint = Self.fingerprint(of: (sameType ?? hostEntries[0]).keyData) + + Self.logger.warning("Host key mismatch for \(hostKey)") + return .mismatch(expected: storedFingerprint, actual: currentFingerprint) + } + + func trust(hostname: String, port: Int, key: Data, keyType: String) { + lock.lock() + defer { lock.unlock() } + + let hostKey = hostIdentifier(hostname, port) + var entries = loadEntries() + entries.removeAll { $0.host == hostKey && $0.keyType == keyType } + entries.append((host: hostKey, keyType: keyType, keyData: key)) + saveEntries(entries) + } + + func remove(hostname: String, port: Int) { + lock.lock() + defer { lock.unlock() } + + let hostKey = hostIdentifier(hostname, port) + var entries = loadEntries() + entries.removeAll { $0.host == hostKey } + saveEntries(entries) + } + + func trustedHosts() -> [String] { + lock.lock() + defer { lock.unlock() } + return Array(Set(loadEntries().map(\.host))).sorted() + } + + static func keyTypeName(_ type: Int32) -> String { + switch type { + case 1: return "ssh-rsa" + case 2: return "ssh-dss" + case 3: return "ecdsa-sha2-nistp256" + case 4: return "ecdsa-sha2-nistp384" + case 5: return "ecdsa-sha2-nistp521" + case 6: return "ssh-ed25519" + default: return "unknown" + } + } + + static func fingerprint(of key: Data) -> String { + let digest = SHA256.hash(data: key) + return "SHA256:" + Data(digest).base64EncodedString().replacingOccurrences(of: "=", with: "") + } + + private func hostIdentifier(_ hostname: String, _ port: Int) -> String { + "[\(hostname)]:\(port)" + } + + private func loadEntries() -> [(host: String, keyType: String, keyData: Data)] { + guard let content = try? String(contentsOfFile: filePath, encoding: .utf8) else { + return [] + } + + var entries: [(host: String, keyType: String, keyData: Data)] = [] + + for line in content.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { continue } + + let parts = trimmed.components(separatedBy: " ") + guard parts.count == 3, let keyData = Data(base64Encoded: parts[2]) else { + Self.logger.warning("Skipping malformed known_hosts line") + continue + } + + entries.append((host: parts[0], keyType: parts[1], keyData: keyData)) + } + + return entries + } + + private func saveEntries(_ entries: [(host: String, keyType: String, keyData: Data)]) { + let lines = entries.map { "\($0.host) \($0.keyType) \($0.keyData.base64EncodedString())" } + let content = lines.joined(separator: "\n") + (lines.isEmpty ? "" : "\n") + + do { + try content.write(toFile: filePath, atomically: true, encoding: .utf8) + } catch { + Self.logger.error("Failed to write known_hosts file: \(error.localizedDescription)") + } + } +} diff --git a/TableProMobile/TableProMobile/SSH/HostKeyVerifier.swift b/TableProMobile/TableProMobile/SSH/HostKeyVerifier.swift new file mode 100644 index 000000000..09a521f74 --- /dev/null +++ b/TableProMobile/TableProMobile/SSH/HostKeyVerifier.swift @@ -0,0 +1,159 @@ +// +// HostKeyVerifier.swift +// TableProMobile +// +// Checks a presented SSH host key against the known-hosts store before any +// credential is sent, and asks the user when the key is new or has changed. +// + +import Foundation +import Observation +import SwiftUI + +@MainActor +@Observable +final class HostKeyPromptPresenter { + static let shared = HostKeyPromptPresenter() + + struct Request: Identifiable { + let id = UUID() + let title: String + let message: String + let confirmTitle: String + let isDestructive: Bool + let respond: @MainActor (Bool) -> Void + } + + var pending: Request? + + private init() {} + + /// Only one key decision can be on screen at a time. A second connect racing the + /// first is refused rather than queued, so no attempt is left waiting on a prompt + /// the user never sees. + func ask(title: String, message: String, confirmTitle: String, isDestructive: Bool) async -> Bool { + guard pending == nil else { return false } + + return await withCheckedContinuation { continuation in + pending = Request( + title: title, + message: message, + confirmTitle: confirmTitle, + isDestructive: isDestructive + ) { accepted in + continuation.resume(returning: accepted) + } + } + } + + func resolve(_ request: Request, accepted: Bool) { + guard pending?.id == request.id else { return } + pending = nil + request.respond(accepted) + } +} + +enum HostKeyVerifier { + static func verify(keyData: Data, keyType: String, hostname: String, port: Int) async throws { + let result = HostKeyStore.shared.verify( + keyData: keyData, + keyType: keyType, + hostname: hostname, + port: port + ) + + switch result { + case .trusted: + return + + case let .unknown(fingerprint, presentedType): + let accepted = await HostKeyPromptPresenter.shared.ask( + title: String(localized: "Unknown SSH Server"), + message: String( + format: String(localized: """ + TablePro has not connected to %@ before. + + %@ key fingerprint: + %@ + + Trust this server only if the fingerprint matches the one you expect. + """), + hostDisplay(hostname, port), + presentedType, + fingerprint + ), + confirmTitle: String(localized: "Trust"), + isDestructive: false + ) + guard accepted else { + throw SSHTunnelError.hostKeyRejected(String(localized: "The server's host key was not trusted.")) + } + HostKeyStore.shared.trust(hostname: hostname, port: port, key: keyData, keyType: keyType) + + case let .mismatch(expected, actual): + let accepted = await HostKeyPromptPresenter.shared.ask( + title: String(localized: "SSH Host Key Changed"), + message: String( + format: String(localized: """ + The host key for %@ has changed. + + This can mean the server was rebuilt, or that someone is intercepting \ + the connection. + + Previous fingerprint: + %@ + + Current fingerprint: + %@ + """), + hostDisplay(hostname, port), + expected, + actual + ), + confirmTitle: String(localized: "Connect Anyway"), + isDestructive: true + ) + guard accepted else { + throw SSHTunnelError.hostKeyRejected(String(localized: "The server's host key has changed.")) + } + HostKeyStore.shared.trust(hostname: hostname, port: port, key: keyData, keyType: keyType) + } + } + + private static func hostDisplay(_ hostname: String, _ port: Int) -> String { + "[\(hostname)]:\(port)" + } +} + +struct HostKeyPromptModifier: ViewModifier { + @Bindable var presenter = HostKeyPromptPresenter.shared + + func body(content: Content) -> some View { + content.alert( + presenter.pending?.title ?? "", + isPresented: Binding( + get: { presenter.pending != nil }, + set: { presenting in + guard !presenting, let request = presenter.pending else { return } + presenter.resolve(request, accepted: false) + } + ), + presenting: presenter.pending + ) { request in + Button(request.confirmTitle, role: request.isDestructive ? .destructive : nil) { + presenter.resolve(request, accepted: true) + } + Button(String(localized: "Cancel"), role: .cancel) { + presenter.resolve(request, accepted: false) + } + } message: { request in + Text(request.message) + } + } +} + +extension View { + func hostKeyPrompt() -> some View { + modifier(HostKeyPromptModifier()) + } +} diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnel.swift b/TableProMobile/TableProMobile/SSH/SSHTunnel.swift index 4f90260fb..ec49d55ff 100644 --- a/TableProMobile/TableProMobile/SSH/SSHTunnel.swift +++ b/TableProMobile/TableProMobile/SSH/SSHTunnel.swift @@ -131,6 +131,20 @@ actor SSHTunnel { session = sess } + func hostKey() throws -> (keyData: Data, keyType: String) { + guard let session else { + throw SSHTunnelError.handshakeFailed("No active session") + } + + var keyLength = 0 + var keyType: Int32 = 0 + guard let keyPtr = libssh2_session_hostkey(session, &keyLength, &keyType) else { + throw SSHTunnelError.hostKeyRejected("The server did not present a host key.") + } + + return (Data(bytes: keyPtr, count: keyLength), HostKeyStore.keyTypeName(keyType)) + } + // MARK: - Authentication func authenticatePassword(username: String, password: String) throws { diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift b/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift index ceed8d591..f9e1d0cea 100644 --- a/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift +++ b/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift @@ -6,6 +6,7 @@ enum SSHTunnelError: Error, LocalizedError { case authenticationFailed(String) case noAvailablePort case channelOpenFailed(String) + case hostKeyRejected(String) case tunnelClosed var errorDescription: String? { @@ -15,6 +16,7 @@ enum SSHTunnelError: Error, LocalizedError { case .authenticationFailed(let msg): return "SSH authentication failed: \(msg)" case .noAvailablePort: return "No available local port for SSH tunnel" case .channelOpenFailed(let msg): return "SSH channel open failed: \(msg)" + case .hostKeyRejected(let msg): return msg case .tunnelClosed: return "SSH tunnel is closed" } } diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift b/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift index 559b08930..314f2e04c 100644 --- a/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift +++ b/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift @@ -24,6 +24,19 @@ enum SSHTunnelFactory { try await tunnel.connect(host: config.host, port: config.port) try await tunnel.handshake() + let presentedKey = try await tunnel.hostKey() + do { + try await HostKeyVerifier.verify( + keyData: presentedKey.keyData, + keyType: presentedKey.keyType, + hostname: config.host, + port: config.port + ) + } catch { + await tunnel.close() + throw error + } + switch config.authMethod { case .password: guard let password = sshPassword else { diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index eccc5d289..5f2e1e759 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -40,6 +40,7 @@ struct TableProMobileApp: App { } } .animation(.default, value: lockState.isLocked) + .hostKeyPrompt() .onOpenURL { url in if url.isFileURL, url.pathExtension.lowercased() == "tablepro" { appState.pendingImportURL = url diff --git a/TableProMobile/TableProMobileTests/SSH/HostKeyStoreTests.swift b/TableProMobile/TableProMobileTests/SSH/HostKeyStoreTests.swift new file mode 100644 index 000000000..5093b222e --- /dev/null +++ b/TableProMobile/TableProMobileTests/SSH/HostKeyStoreTests.swift @@ -0,0 +1,126 @@ +// +// HostKeyStoreTests.swift +// TableProMobileTests +// + +import Foundation +import Testing + +@testable import TableProMobile + +@Suite("HostKeyStore") +struct HostKeyStoreTests { + private func makeTempFilePath() -> String { + (NSTemporaryDirectory() as NSString).appendingPathComponent("test_known_hosts_\(UUID().uuidString)") + } + + private func makeTestKey(_ seed: UInt8) -> Data { + Data(repeating: seed, count: 32) + } + + @Test("An unseen host is unknown, not trusted") + func unknownHostIsNotTrusted() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let key = makeTestKey(0xAA) + + #expect( + store.verify(keyData: key, keyType: "ssh-ed25519", hostname: "db.example.com", port: 22) + == .unknown(fingerprint: HostKeyStore.fingerprint(of: key), keyType: "ssh-ed25519") + ) + } + + @Test("A trusted key verifies on the next connect") + func trustedKeyVerifies() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let key = makeTestKey(0xBB) + + store.trust(hostname: "db.example.com", port: 22, key: key, keyType: "ssh-ed25519") + + #expect(store.verify(keyData: key, keyType: "ssh-ed25519", hostname: "db.example.com", port: 22) == .trusted) + } + + @Test("A changed key is a mismatch") + func changedKeyIsMismatch() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let trusted = makeTestKey(0xCC) + let presented = makeTestKey(0xDD) + + store.trust(hostname: "db.example.com", port: 22, key: trusted, keyType: "ssh-ed25519") + + #expect( + store.verify(keyData: presented, keyType: "ssh-ed25519", hostname: "db.example.com", port: 22) + == .mismatch( + expected: HostKeyStore.fingerprint(of: trusted), + actual: HostKeyStore.fingerprint(of: presented) + ) + ) + } + + @Test("A different key type for a known host is a mismatch, not a first-use prompt") + func differentKeyTypeIsMismatch() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let trusted = makeTestKey(0xEE) + let attacker = makeTestKey(0xEF) + + store.trust(hostname: "db.example.com", port: 22, key: trusted, keyType: "ssh-ed25519") + + #expect( + store.verify(keyData: attacker, keyType: "ssh-rsa", hostname: "db.example.com", port: 22) + == .mismatch( + expected: HostKeyStore.fingerprint(of: trusted), + actual: HostKeyStore.fingerprint(of: attacker) + ) + ) + } + + @Test("Hosts on different ports are tracked separately") + func portsAreSeparateEntries() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let key = makeTestKey(0x11) + + store.trust(hostname: "db.example.com", port: 22, key: key, keyType: "ssh-ed25519") + + #expect(store.verify(keyData: key, keyType: "ssh-ed25519", hostname: "db.example.com", port: 22) == .trusted) + if case .trusted = store.verify( + keyData: key, keyType: "ssh-ed25519", hostname: "db.example.com", port: 2222 + ) { + Issue.record("Port 2222 should not inherit trust from port 22") + } + } + + @Test("Removing a host clears every key type stored for it") + func removeClearsAllKeyTypes() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + store.trust(hostname: "db.example.com", port: 22, key: makeTestKey(0x21), keyType: "ssh-rsa") + store.trust(hostname: "db.example.com", port: 22, key: makeTestKey(0x22), keyType: "ssh-ed25519") + + store.remove(hostname: "db.example.com", port: 22) + + #expect(store.trustedHosts().isEmpty) + } + + @Test("Fingerprints use the OpenSSH SHA256 form") + func fingerprintFormat() { + let fingerprint = HostKeyStore.fingerprint(of: Data("tablepro".utf8)) + #expect(fingerprint.hasPrefix("SHA256:")) + #expect(!fingerprint.contains("=")) + } +} From 92bf90a58f16481e66e42b440e1515fe7429917e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 16:53:07 +0700 Subject: [PATCH 06/22] fix(ios): keep the stored TLS configuration when saving a connection --- .../ViewModels/ConnectionFormViewModel.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift index 22242df85..e94a903ad 100644 --- a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift @@ -395,6 +395,17 @@ final class ConnectionFormViewModel { } } + /// The form models a mode for MSSQL and Oracle only, so the certificate paths it never + /// shows have to be carried across a save rather than dropped. + private func carryingCertificatePaths(_ configuration: SSLConfiguration) -> SSLConfiguration { + guard let stored = existingConnection?.sslConfiguration else { return configuration } + var merged = configuration + merged.caCertificatePath = stored.caCertificatePath + merged.clientCertificatePath = stored.clientCertificatePath + merged.clientKeyPath = stored.clientKeyPath + return merged + } + func buildConnection() -> DatabaseConnection { var conn = DatabaseConnection( id: existingConnection?.id ?? UUID(), @@ -409,11 +420,12 @@ final class ConnectionFormViewModel { groupId: groupId, tagIds: tagId.map { [$0] } ?? [] ) + conn.sslConfiguration = existingConnection?.sslConfiguration if type == .mssql { - conn.sslConfiguration = SSLConfiguration(mode: mssqlSSLMode) + conn.sslConfiguration = carryingCertificatePaths(SSLConfiguration(mode: mssqlSSLMode)) } if type == .oracle { - conn.sslConfiguration = SSLConfiguration(mode: oracleSSLMode) + conn.sslConfiguration = carryingCertificatePaths(SSLConfiguration(mode: oracleSSLMode)) conn.additionalFields[OracleConnectionOptions.AdditionalFieldKey.connectionType] = oracleConnectionType.rawValue conn.additionalFields[OracleConnectionOptions.AdditionalFieldKey.serviceName] = oracleServiceName From ee767fb30d3a36717013dccd8078c80eb5490921 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 16:53:07 +0700 Subject: [PATCH 07/22] fix(ios): honor the password sync preference before writing to the keychain --- .../TableProMobile/Platform/AppPreferences.swift | 5 +++++ .../Platform/KeychainSecureStore.swift | 16 +++++++++++++--- .../TableProMobile/Views/SettingsView.swift | 9 ++++++++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/TableProMobile/TableProMobile/Platform/AppPreferences.swift b/TableProMobile/TableProMobile/Platform/AppPreferences.swift index d5716afca..9b13b5d3a 100644 --- a/TableProMobile/TableProMobile/Platform/AppPreferences.swift +++ b/TableProMobile/TableProMobile/Platform/AppPreferences.swift @@ -3,6 +3,7 @@ import TableProModels enum AppPreferences { static let cloudSyncEnabledKey = "com.TablePro.settings.cloudSyncEnabled" + static let syncPasswordsKey = "com.TablePro.settings.syncPasswords" static let defaultPageSizeKey = "com.TablePro.settings.defaultPageSize" static let defaultSafeModeKey = "com.TablePro.settings.defaultSafeMode" static let hideQueryPreviewInActivityKey = "com.TablePro.settings.hideQueryPreviewInActivity" @@ -13,6 +14,10 @@ enum AppPreferences { UserDefaults.standard.object(forKey: cloudSyncEnabledKey) as? Bool ?? true } + static var syncsPasswords: Bool { + isCloudSyncEnabled && UserDefaults.standard.bool(forKey: syncPasswordsKey) + } + static var defaultPageSize: Int { guard let stored = UserDefaults.standard.object(forKey: defaultPageSizeKey) as? Int, pageSizeOptions.contains(stored) else { return 100 } diff --git a/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift b/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift index 7126e37b7..6bf0a0ad2 100644 --- a/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift +++ b/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift @@ -37,9 +37,17 @@ final class KeychainSecureStore: SecureStore { return query } + static func accessibility(forSync synchronizable: Bool) -> CFString { + synchronizable + ? kSecAttrAccessibleAfterFirstUnlock + : kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + } + func store(_ value: String, forKey key: String) throws { guard let data = value.data(using: .utf8) else { return } + let synchronizable = AppPreferences.syncsPasswords + let deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, @@ -49,15 +57,17 @@ final class KeychainSecureStore: SecureStore { ] SecItemDelete(applyingAccessGroup(deleteQuery) as CFDictionary) - let addQuery: [String: Any] = [ + var addQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, kSecAttrAccount as String: key, kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, - kSecAttrSynchronizable as String: true, + kSecAttrAccessible as String: Self.accessibility(forSync: synchronizable), kSecUseDataProtectionKeychain as String: true, ] + if synchronizable { + addQuery[kSecAttrSynchronizable as String] = true + } let status = SecItemAdd(applyingAccessGroup(addQuery) as CFDictionary, nil) if status != errSecSuccess { throw KeychainError.storeFailed(status) diff --git a/TableProMobile/TableProMobile/Views/SettingsView.swift b/TableProMobile/TableProMobile/Views/SettingsView.swift index c85b7eade..27d2d1b84 100644 --- a/TableProMobile/TableProMobile/Views/SettingsView.swift +++ b/TableProMobile/TableProMobile/Views/SettingsView.swift @@ -9,6 +9,7 @@ struct SettingsView: View { @AppStorage(AppLockState.lockEnabledKey) private var lockEnabled = false @AppStorage(AppLockState.lockTimeoutKey) private var lockTimeoutSeconds = AppLockState.AutoLockTimeout.fiveMinutes.rawValue @AppStorage(AppPreferences.cloudSyncEnabledKey) private var cloudSyncEnabled = true + @AppStorage(AppPreferences.syncPasswordsKey) private var syncPasswords = false @AppStorage(AppPreferences.defaultPageSizeKey) private var defaultPageSize = 100 @AppStorage(AppPreferences.defaultSafeModeKey) private var defaultSafeModeRaw = SafeModeLevel.off.rawValue @AppStorage(AppPreferences.hideQueryPreviewInActivityKey) private var hideQueryPreviewInActivity = false @@ -93,11 +94,17 @@ struct SettingsView: View { } } .disabled(isSyncing) + + Toggle(String(localized: "Sync Passwords"), isOn: $syncPasswords) + + Text("Passwords sync through iCloud Keychain, which is end-to-end encrypted. Only affects new saves. Re-save a password to update its sync.") + .font(.caption) + .foregroundStyle(.secondary) } } header: { Text("Sync") } footer: { - Text("When off, connections, groups, and tags stay on this device only. Existing iCloud data is not deleted.") + Text("When off, connections, groups, and tags stay on this device only. Existing iCloud data is not deleted. Passwords stay on this device unless you turn on Sync Passwords.") } } From c255124517cd0778f4a3d09b0d32486637b8f621 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 16:53:07 +0700 Subject: [PATCH 08/22] fix(connections): drop credential-resolution fields from an imported connection --- .../ConnectionExportEnvelope.swift | 32 ++++++- .../Database/AWS/RDSSigningEndpoint.swift | 12 ++- .../TeamLibrary/TeamLibraryStore.swift | 7 +- .../TeamLibrary/TeamLibraryModels.swift | 19 ++++ .../Connection/DeeplinkImportSheet.swift | 42 +++++++++ TableProTests/AWS/AWSIAMAuthTests.swift | 32 +++++++ .../Services/ConnectionSharingTests.swift | 87 +++++++++++++++++++ 7 files changed, 223 insertions(+), 8 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProImport/ConnectionExportEnvelope.swift b/Packages/TableProCore/Sources/TableProImport/ConnectionExportEnvelope.swift index 468a68148..2eead45c5 100644 --- a/Packages/TableProCore/Sources/TableProImport/ConnectionExportEnvelope.swift +++ b/Packages/TableProCore/Sources/TableProImport/ConnectionExportEnvelope.swift @@ -150,11 +150,39 @@ public struct ExportableConnection: Codable { } public extension ExportableConnection { - static let importBlockedAdditionalFieldKeys: Set = ["preConnectScript"] + static let importBlockedAdditionalFieldKeys: Set = [ + "preconnectscript", + "pretunnelhost", + "pretunnelport", + "promptforpassword", + "sslclientkeypassphrase", + "usepgpass", + ] + + static let importBlockedAdditionalFieldPrefixes: Set = ["aws"] + + static func isImportBlockedAdditionalFieldKey(_ key: String) -> Bool { + let normalized = key.lowercased() + if importBlockedAdditionalFieldKeys.contains(normalized) { return true } + return importBlockedAdditionalFieldPrefixes.contains { normalized.hasPrefix($0) } + } + + func withoutStartupCommands() -> ExportableConnection { + guard startupCommands != nil else { return self } + return ExportableConnection( + name: name, host: host, port: port, database: database, + username: username, type: type, sshConfig: sshConfig, + sslConfig: sslConfig, color: color, tagName: tagName, tagNames: tagNames, + groupName: groupName, sshProfileId: sshProfileId, + safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, + additionalFields: additionalFields, redisDatabase: redisDatabase, + startupCommands: nil, localOnly: localOnly + ) + } func sanitizedForImport() -> ExportableConnection { guard let additionalFields else { return self } - let allowed = additionalFields.filter { !Self.importBlockedAdditionalFieldKeys.contains($0.key) } + let allowed = additionalFields.filter { !Self.isImportBlockedAdditionalFieldKey($0.key) } guard allowed.count != additionalFields.count else { return self } return ExportableConnection( name: name, host: host, port: port, database: database, diff --git a/TablePro/Core/Database/AWS/RDSSigningEndpoint.swift b/TablePro/Core/Database/AWS/RDSSigningEndpoint.swift index 9c416db1f..2e37c74ca 100644 --- a/TablePro/Core/Database/AWS/RDSSigningEndpoint.swift +++ b/TablePro/Core/Database/AWS/RDSSigningEndpoint.swift @@ -28,12 +28,18 @@ enum RDSSigningEndpointResolver { override: String?, defaultPort: Int ) throws -> RDSSigningEndpoint { + let dialsLoopback = isLoopback(configuredHost) + if let override, !override.trimmingCharacters(in: .whitespaces).isEmpty { - return try parse(override, defaultPort: defaultPort) + let endpoint = try parse(override, defaultPort: defaultPort) + guard dialsLoopback || endpoint.host.caseInsensitiveCompare(configuredHost) == .orderedSame else { + throw AWSAuthError.rdsEndpointUnresolved(host: configuredHost) + } + return endpoint } - let host = preTunnelHost ?? configuredHost - let port = preTunnelPort ?? configuredPort + let host = dialsLoopback ? (preTunnelHost ?? configuredHost) : configuredHost + let port = dialsLoopback ? (preTunnelPort ?? configuredPort) : configuredPort guard !isLoopback(host) else { throw AWSAuthError.rdsEndpointUnresolved(host: host) } diff --git a/TablePro/Core/Services/TeamLibrary/TeamLibraryStore.swift b/TablePro/Core/Services/TeamLibrary/TeamLibraryStore.swift index c4aedcc31..0bd2a6132 100644 --- a/TablePro/Core/Services/TeamLibrary/TeamLibraryStore.swift +++ b/TablePro/Core/Services/TeamLibrary/TeamLibraryStore.swift @@ -36,14 +36,15 @@ actor TeamLibraryStore { guard let data = try? Data(contentsOf: fileURL) else { return nil } - cached = try? JSONDecoder().decode(TeamLibraryPullResponse.self, from: data) + cached = (try? JSONDecoder().decode(TeamLibraryPullResponse.self, from: data))?.sanitized() return cached } func replace(_ response: TeamLibraryPullResponse) { - cached = response + let sanitized = response.sanitized() + cached = sanitized do { - try JSONEncoder().encode(response).write(to: fileURL, options: .atomic) + try JSONEncoder().encode(sanitized).write(to: fileURL, options: .atomic) } catch { Self.logger.error("Failed to cache team library: \(error.localizedDescription)") } diff --git a/TablePro/Models/TeamLibrary/TeamLibraryModels.swift b/TablePro/Models/TeamLibrary/TeamLibraryModels.swift index c57f0bc9c..b8687784d 100644 --- a/TablePro/Models/TeamLibrary/TeamLibraryModels.swift +++ b/TablePro/Models/TeamLibrary/TeamLibraryModels.swift @@ -106,6 +106,25 @@ struct TeamLibraryPullResponse: Codable { static let empty = TeamLibraryPullResponse(connections: [], queryFolders: [], queries: [], fetchedAt: "") + /// A pulled connection is authored by another account and lands on this device with no + /// confirmation step, so anything the app would act on by itself is dropped before caching. + func sanitized() -> TeamLibraryPullResponse { + TeamLibraryPullResponse( + connections: connections.map { + Connection( + id: $0.id, + sourceConnectionId: $0.sourceConnectionId, + payload: $0.payload.sanitizedForImport().withoutStartupCommands(), + publishedBy: $0.publishedBy, + publishedAt: $0.publishedAt + ) + }, + queryFolders: queryFolders, + queries: queries, + fetchedAt: fetchedAt + ) + } + struct Connection: Codable, Identifiable { let id: String let sourceConnectionId: String? diff --git a/TablePro/Views/Connection/DeeplinkImportSheet.swift b/TablePro/Views/Connection/DeeplinkImportSheet.swift index 7a50acdf0..ad9416fca 100644 --- a/TablePro/Views/Connection/DeeplinkImportSheet.swift +++ b/TablePro/Views/Connection/DeeplinkImportSheet.swift @@ -67,6 +67,10 @@ struct DeeplinkImportSheet: View { metadataSection } + startupCommandsSection + + optionsSection + if isDuplicate { Section { Label( @@ -95,6 +99,7 @@ struct DeeplinkImportSheet: View { .padding() } .frame(width: 420) + .frame(maxHeight: 560) .onAppear { checkDuplicate() } } @@ -139,6 +144,43 @@ struct DeeplinkImportSheet: View { } } + @ViewBuilder + private var startupCommandsSection: some View { + if let startupCommands = connection.startupCommands, + !startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Section { + Label( + String(localized: "This connection runs SQL every time it connects, using your credentials."), + systemImage: "exclamationmark.triangle.fill" + ) + .foregroundStyle(.orange) + .font(.callout) + + Text(startupCommands) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } header: { + Text("Startup SQL") + } + } + } + + @ViewBuilder + private var optionsSection: some View { + if let fields = connection.additionalFields, !fields.isEmpty { + Section(String(localized: "Driver Options")) { + ForEach(fields.keys.sorted(), id: \.self) { key in + LabeledContent(key) { + Text(fields[key] ?? "") + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } + } + } + private var hasMetadata: Bool { connection.color != nil || connection.tagName != nil || connection.groupName != nil } diff --git a/TableProTests/AWS/AWSIAMAuthTests.swift b/TableProTests/AWS/AWSIAMAuthTests.swift index fc872bc2d..7d01779ee 100644 --- a/TableProTests/AWS/AWSIAMAuthTests.swift +++ b/TableProTests/AWS/AWSIAMAuthTests.swift @@ -211,6 +211,38 @@ struct RDSSigningEndpointResolverTests { } } + @Test("An override may not redirect the token away from the host being dialled") + func overrideCannotRedirectToAnotherHost() { + #expect(throws: AWSAuthError.rdsEndpointUnresolved(host: "evil.example.com")) { + _ = try resolve( + host: "evil.example.com", + port: 5_432, + override: "prod.abc123.us-east-1.rds.amazonaws.com:5432" + ) + } + } + + @Test("An override that restates the dialled host is accepted") + func overrideMatchingDialledHostIsAccepted() throws { + let endpoint = try resolve( + host: "mydb.abc123.us-east-1.rds.amazonaws.com", + port: 5_432, + override: "MyDB.abc123.us-east-1.RDS.amazonaws.com:5433" + ) + #expect(endpoint.port == 5_433) + } + + @Test("A pre-tunnel host is ignored when the socket is not a local forward") + func preTunnelHostIgnoredForRemoteDial() throws { + let endpoint = try resolve( + host: "evil.example.com", + port: 5_432, + preTunnelHost: "prod.abc123.us-east-1.rds.amazonaws.com", + preTunnelPort: 5_432 + ) + #expect(endpoint == RDSSigningEndpoint(host: "evil.example.com", port: 5_432)) + } + @Test("The region comes from the signing host, not the local forward") func regionFollowsSigningHost() throws { let endpoint = try resolve( diff --git a/TableProTests/Core/Services/ConnectionSharingTests.swift b/TableProTests/Core/Services/ConnectionSharingTests.swift index c277cf431..16a75142b 100644 --- a/TableProTests/Core/Services/ConnectionSharingTests.swift +++ b/TableProTests/Core/Services/ConnectionSharingTests.swift @@ -578,5 +578,92 @@ struct ConnectionSharingTests { ) #expect(connection.preConnectScript == nil) } + + private static func parseImportLink(_ items: [URLQueryItem]) -> ExportableConnection? { + var components = URLComponents() + components.scheme = "tablepro" + components.host = "import" + components.queryItems = items + guard let url = components.url, + case .success(.importConnection(let parsed)) = DeeplinkParser.parse(url) else { + return nil + } + return parsed + } + + @Test("Deeplink import drops every AWS credential-resolution field") + @MainActor + func testDeeplinkImportDropsAWSFields() throws { + let parsed = try #require(Self.parseImportLink([ + URLQueryItem(name: "name", value: "Analytics Replica"), + URLQueryItem(name: "host", value: "evil.example.com"), + URLQueryItem(name: "port", value: "5432"), + URLQueryItem(name: "type", value: "PostgreSQL"), + URLQueryItem(name: "af_awsAuth", value: "profile"), + URLQueryItem(name: "af_awsRDSEndpoint", value: "prod.abc.us-east-1.rds.amazonaws.com:5432"), + URLQueryItem(name: "af_awsRegion", value: "us-east-1"), + URLQueryItem(name: "af_awsProfileName", value: "default"), + URLQueryItem(name: "af_mongoAuthSource", value: "admin") + ])) + + #expect(parsed.additionalFields?["awsAuth"] == nil) + #expect(parsed.additionalFields?["awsRDSEndpoint"] == nil) + #expect(parsed.additionalFields?["awsRegion"] == nil) + #expect(parsed.additionalFields?["awsProfileName"] == nil) + #expect(parsed.additionalFields?["mongoAuthSource"] == "admin") + + let connection = ConnectionExportService.buildDatabaseConnection( + id: UUID(), from: parsed, name: parsed.name, + tagIdsByName: [:], groupIdsByName: [:] + ) + #expect(!connection.usesAWSIAM) + } + + @Test("Deeplink import drops pgpass and pre-tunnel redirection fields") + @MainActor + func testDeeplinkImportDropsCredentialRedirectionFields() throws { + let parsed = try #require(Self.parseImportLink([ + URLQueryItem(name: "name", value: "Replica"), + URLQueryItem(name: "host", value: "evil.example.com"), + URLQueryItem(name: "port", value: "5432"), + URLQueryItem(name: "type", value: "PostgreSQL"), + URLQueryItem(name: "af_usePgpass", value: "true"), + URLQueryItem(name: "af_preTunnelHost", value: "prod.internal"), + URLQueryItem(name: "af_preTunnelPort", value: "5432"), + URLQueryItem(name: "af_promptForPassword", value: "false"), + URLQueryItem(name: "af_sslClientKeyPassphrase", value: "secret"), + URLQueryItem(name: "af_mssqlSchema", value: "dbo") + ])) + + #expect(parsed.additionalFields?["usePgpass"] == nil) + #expect(parsed.additionalFields?["preTunnelHost"] == nil) + #expect(parsed.additionalFields?["preTunnelPort"] == nil) + #expect(parsed.additionalFields?["promptForPassword"] == nil) + #expect(parsed.additionalFields?["sslClientKeyPassphrase"] == nil) + #expect(parsed.additionalFields?["mssqlSchema"] == "dbo") + } + + @Test("Blocked field keys are matched without regard to case") + func testBlockedKeysAreCaseInsensitive() { + #expect(ExportableConnection.isImportBlockedAdditionalFieldKey("AWSAuth")) + #expect(ExportableConnection.isImportBlockedAdditionalFieldKey("PreConnectScript")) + #expect(ExportableConnection.isImportBlockedAdditionalFieldKey("UsePgpass")) + #expect(!ExportableConnection.isImportBlockedAdditionalFieldKey("mongoAuthSource")) + #expect(!ExportableConnection.isImportBlockedAdditionalFieldKey("awareness")) + } + + @Test("Imported startup commands survive so the sheet can disclose them") + @MainActor + func testDeeplinkImportKeepsStartupCommandsForDisclosure() throws { + let parsed = try #require(Self.parseImportLink([ + URLQueryItem(name: "name", value: "Staging"), + URLQueryItem(name: "host", value: "db.example.com"), + URLQueryItem(name: "port", value: "5432"), + URLQueryItem(name: "type", value: "PostgreSQL"), + URLQueryItem(name: "startupCommands", value: "GRANT ALL ON *.* TO 'attacker'@'%'") + ])) + + #expect(parsed.startupCommands == "GRANT ALL ON *.* TO 'attacker'@'%'") + } } } From 52092b08d13cd00d5b736c49eb0e70fd1177f80b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 16:53:07 +0700 Subject: [PATCH 09/22] fix(plugins): verify a plugin signature immediately before loading it --- TablePro/Core/Plugins/PluginInstaller.swift | 4 ++++ TablePro/Core/Plugins/PluginManager.swift | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/TablePro/Core/Plugins/PluginInstaller.swift b/TablePro/Core/Plugins/PluginInstaller.swift index c06bcf454..418e60618 100644 --- a/TablePro/Core/Plugins/PluginInstaller.swift +++ b/TablePro/Core/Plugins/PluginInstaller.swift @@ -70,6 +70,10 @@ actor PluginInstaller { guard let stagedURL = stagedUpdates[pluginId] else { throw PluginError.notFound } + guard let stagedBundle = Bundle(url: stagedURL) else { + throw PluginError.invalidBundle("Cannot create bundle from \(stagedURL.lastPathComponent)") + } + try PluginCodeSignatureVerifier.verify(bundle: stagedBundle) let bundleName = stagedURL.deletingPathExtension().lastPathComponent let destURL = userPluginsDir.appendingPathComponent("\(bundleName).tableplugin", isDirectory: true) let finalURL = try Self.atomicReplace(stagedBundleURL: stagedURL, destURL: destURL) diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index ee93413a1..db5e21d2b 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -549,6 +549,10 @@ final class PluginManager { try validateBundleVersions(bundle) + if source != .builtIn { + try PluginCodeSignatureVerifier.verify(bundle: bundle) + } + try PluginBundleLoader.load(bundle) return bundle From 657230fa5f2a53d598622b8e26b0009304c3d8cc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 16:53:15 +0700 Subject: [PATCH 10/22] fix(plugin-mysql): refuse a server request to read a local file --- Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift | 6 ++++++ TableProMobile/TableProMobile/Drivers/MySQLDriver.swift | 3 +++ 2 files changed, 9 insertions(+) diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index a632c4452..38a477dfe 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -273,6 +273,9 @@ final class MariaDBPluginConnection: @unchecked Sendable { var protocol_tcp = UInt32(MYSQL_PROTOCOL_TCP.rawValue) mysql_options(mysql, MYSQL_OPT_PROTOCOL, &protocol_tcp) + var allowLocalInfile: UInt32 = 0 + mysql_options(mysql, MYSQL_OPT_LOCAL_INFILE, &allowLocalInfile) + var sslEnforce: my_bool = enforceSSL ? 1 : 0 mysql_options(mysql, MYSQL_OPT_SSL_ENFORCE, &sslEnforce) @@ -396,6 +399,9 @@ final class MariaDBPluginConnection: @unchecked Sendable { mysql_options(killConn, MYSQL_OPT_READ_TIMEOUT, &killTimeout) mysql_options(killConn, MYSQL_OPT_WRITE_TIMEOUT, &killTimeout) + var killAllowLocalInfile: UInt32 = 0 + mysql_options(killConn, MYSQL_OPT_LOCAL_INFILE, &killAllowLocalInfile) + let killResult = host.withCString { hostPtr in user.withCString { userPtr in if let pass = password { diff --git a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index 76929a464..30bb93572 100644 --- a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -276,6 +276,9 @@ private actor MySQLActor { var reconnect: my_bool = 0 mysql_options(handle, MYSQL_OPT_RECONNECT, &reconnect) + var allowLocalInfile: UInt32 = 0 + mysql_options(handle, MYSQL_OPT_LOCAL_INFILE, &allowLocalInfile) + var sslEnforce: my_bool = ssl.isEnabled ? 1 : 0 mysql_options(handle, MYSQL_OPT_SSL_ENFORCE, &sslEnforce) var sslVerify: my_bool = ssl.verifiesCertificate ? 1 : 0 From 1d8d93112dcc0cbf9a0776cadd7f869024704bec Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 16:53:15 +0700 Subject: [PATCH 11/22] fix(plugin-clickhouse): fail closed when the CA file cannot be read --- .../ClickHousePlugin.swift | 16 +++- .../PEMCertificateDecoder.swift | 51 +++++++++++++ .../Services/PEMCertificateDecoderTests.swift | 74 +++++++++++++++++++ 3 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 Plugins/TableProPluginKit/PEMCertificateDecoder.swift create mode 100644 TableProTests/Core/Services/PEMCertificateDecoderTests.swift diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index ff8975073..203fa8d1f 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -722,6 +722,7 @@ private final class ClickHouseTLSDelegate: NSObject, URLSessionDelegate, @unchec private enum Strategy { case skipVerify case verifyChain(anchor: SecCertificate?) + case anchorUnavailable } private let strategy: Strategy @@ -739,15 +740,24 @@ private final class ClickHouseTLSDelegate: NSObject, URLSessionDelegate, @unchec case .preferred, .required: return ClickHouseTLSDelegate(strategy: .skipVerify) case .verifyCa: - return ClickHouseTLSDelegate(strategy: .verifyChain(anchor: loadAnchor(at: ssl.caCertificatePath))) + guard !ssl.caCertificatePath.isEmpty else { + return ClickHouseTLSDelegate(strategy: .verifyChain(anchor: nil)) + } + guard let anchor = loadAnchor(at: ssl.caCertificatePath) else { + return ClickHouseTLSDelegate(strategy: .anchorUnavailable) + } + return ClickHouseTLSDelegate(strategy: .verifyChain(anchor: anchor)) } } + /// A verification mode whose anchor cannot be read must fail, never quietly widen to the + /// system roots. `SecCertificateCreateWithData` takes DER only, so PEM is decoded first. private static func loadAnchor(at path: String) -> SecCertificate? { guard !path.isEmpty, let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil } - return SecCertificateCreateWithData(nil, data as CFData) + guard let der = PEMCertificateDecoder.certificateDER(from: data) else { return nil } + return SecCertificateCreateWithData(nil, der as CFData) } func urlSession( @@ -764,6 +774,8 @@ private final class ClickHouseTLSDelegate: NSObject, URLSessionDelegate, @unchec switch strategy { case .skipVerify: completionHandler(.useCredential, URLCredential(trust: serverTrust)) + case .anchorUnavailable: + completionHandler(.cancelAuthenticationChallenge, nil) case .verifyChain(let anchor): if let anchor { SecTrustSetAnchorCertificates(serverTrust, [anchor] as CFArray) diff --git a/Plugins/TableProPluginKit/PEMCertificateDecoder.swift b/Plugins/TableProPluginKit/PEMCertificateDecoder.swift new file mode 100644 index 000000000..33a6183e2 --- /dev/null +++ b/Plugins/TableProPluginKit/PEMCertificateDecoder.swift @@ -0,0 +1,51 @@ +// +// PEMCertificateDecoder.swift +// TableProPluginKit +// +// Security framework certificate APIs take DER only. A user pointing a "Verify CA" +// setting at a PEM file is the common case, so the DER has to be recovered first. +// + +import Foundation + +public enum PEMCertificateDecoder { + private static let beginMarker = "-----BEGIN CERTIFICATE" + private static let endMarker = "-----END CERTIFICATE" + + /// The DER bytes of the first certificate in a PEM document, or nil when the text + /// carries no complete certificate block. + public static func firstCertificateDER(inPEM text: String) -> Data? { + var base64 = "" + var insideCertificate = false + var sawEndMarker = false + + for line in text.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix(beginMarker) { + insideCertificate = true + continue + } + if trimmed.hasPrefix(endMarker) { + sawEndMarker = true + break + } + if insideCertificate { + base64 += trimmed + } + } + + guard insideCertificate, sawEndMarker, !base64.isEmpty else { return nil } + return Data(base64Encoded: base64) + } + + /// The DER bytes for a certificate file that may be either DER or PEM encoded. A file that + /// announces a certificate block it cannot decode yields nil rather than its own raw bytes, + /// so a caller cannot mistake undecodable text for a usable anchor. + public static func certificateDER(from data: Data) -> Data? { + guard let text = String(data: data, encoding: .utf8), text.contains(beginMarker) else { + return data + } + return firstCertificateDER(inPEM: text) + } +} diff --git a/TableProTests/Core/Services/PEMCertificateDecoderTests.swift b/TableProTests/Core/Services/PEMCertificateDecoderTests.swift new file mode 100644 index 000000000..5589e4436 --- /dev/null +++ b/TableProTests/Core/Services/PEMCertificateDecoderTests.swift @@ -0,0 +1,74 @@ +// +// PEMCertificateDecoderTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("PEM certificate decoder") +struct PEMCertificateDecoderTests { + private static let derBytes = Data([0x30, 0x82, 0x01, 0x0A, 0x02, 0x01, 0x00]) + + private static func pemDocument(body: String) -> String { + """ + -----BEGIN CERTIFICATE----- + \(body) + -----END CERTIFICATE----- + """ + } + + @Test("Decodes a PEM block back to its DER bytes") + func decodesPEM() { + let pem = Self.pemDocument(body: Self.derBytes.base64EncodedString()) + #expect(PEMCertificateDecoder.firstCertificateDER(inPEM: pem) == Self.derBytes) + } + + @Test("Joins a base64 body wrapped across several lines") + func decodesWrappedBody() { + let base64 = Data(repeating: 0xAB, count: 120).base64EncodedString() + let wrapped = stride(from: 0, to: base64.count, by: 64).map { offset -> String in + let start = base64.index(base64.startIndex, offsetBy: offset) + let end = base64.index(start, offsetBy: min(64, base64.count - offset)) + return String(base64[start.. Date: Tue, 11 Aug 2026 16:53:16 +0700 Subject: [PATCH 12/22] fix(mcp): reject a request from a disallowed browser origin --- TablePro/Core/MCP/MCPConnectionBridge.swift | 2 ++ .../MCP/Transport/MCPHttpRequestRouter.swift | 20 ++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/MCP/MCPConnectionBridge.swift b/TablePro/Core/MCP/MCPConnectionBridge.swift index b90953d9d..a43cc55b6 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge.swift @@ -9,8 +9,10 @@ public actor MCPConnectionBridge { func listConnections() async -> JsonValue { let (connections, activeSessions) = await MainActor.run { + let defaultPolicy = AppSettingsManager.shared.ai.defaultConnectionPolicy let conns = ConnectionStorage.shared.loadConnections() .filter { $0.externalAccess != .blocked } + .filter { ($0.aiPolicy ?? defaultPolicy) != .never } let sessions = DatabaseManager.shared.activeSessions return (conns, sessions) } diff --git a/TablePro/Core/MCP/Transport/MCPHttpRequestRouter.swift b/TablePro/Core/MCP/Transport/MCPHttpRequestRouter.swift index 5b6f09be4..1d76191c4 100644 --- a/TablePro/Core/MCP/Transport/MCPHttpRequestRouter.swift +++ b/TablePro/Core/MCP/Transport/MCPHttpRequestRouter.swift @@ -24,7 +24,16 @@ struct MCPHttpRequestRouter: Sendable { let clientAddress: MCPClientAddress = await context.clientAddress() let now = await clock.now() - await context.setOrigin(head.headers.value(for: "Origin")) + let origin = head.headers.value(for: "Origin") + await context.setOrigin(origin) + + // A browser attacking the loopback server through DNS rebinding always sends an Origin. + // A native client sends none, so an absent header stays allowed. This runs before every + // route, including the ones that authenticate themselves. + if let origin, !origin.isEmpty, !MCPCorsHeaders.isAllowed(origin: origin) { + await respondHttpForbiddenOrigin(context: context) + return + } if head.method == .post, stripQueryString(head.path) == "/v1/integrations/exchange" { await handleIntegrationsExchange(body: body, context: context) @@ -428,6 +437,15 @@ struct MCPHttpRequestRouter: Sendable { await context.cancel() } + private func respondHttpForbiddenOrigin(context: HttpConnectionContext) async { + await context.writePlainJsonError( + status: .forbidden, + error: "forbidden_origin", + errorDescription: "This origin is not allowed to reach TablePro's MCP server." + ) + await context.cancel() + } + private func respondHttpMethodNotAllowed(context: HttpConnectionContext) async { await context.writePlainJsonError( status: .methodNotAllowed, From 1d8f803b8ab63c3ec0dfc71419d690f175e9208d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 16:53:16 +0700 Subject: [PATCH 13/22] fix(connections): keep copied credentials out of clipboard history and logs --- .../Core/Database/PostgresDumpService.swift | 2 +- .../Infrastructure/ClipboardService.swift | 19 +++++++++++++++++++ .../Connection/WelcomeContextMenus.swift | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Database/PostgresDumpService.swift b/TablePro/Core/Database/PostgresDumpService.swift index dacf723f0..e7dfa06d1 100644 --- a/TablePro/Core/Database/PostgresDumpService.swift +++ b/TablePro/Core/Database/PostgresDumpService.swift @@ -330,7 +330,7 @@ final class PostgresDumpService { ? String(format: String(localized: "Process exited with code %d"), Int(result.exitCode)) : result.stderr setState(.failed(message: summary)) - Self.logger.error("\(self.kind == .backup ? "pg_dump" : "pg_restore", privacy: .public) failed code=\(result.exitCode) db=\(database, privacy: .public) stderr=\(result.stderr, privacy: .public)") + Self.logger.error("\(self.kind == .backup ? "pg_dump" : "pg_restore", privacy: .public) failed code=\(result.exitCode) db=\(database, privacy: .public) stderr=\(result.stderr)") } private func startByteSizePolling(url: URL, database: String, totalBytes: Int64?) { diff --git a/TablePro/Core/Services/Infrastructure/ClipboardService.swift b/TablePro/Core/Services/Infrastructure/ClipboardService.swift index 1c9e3cf96..f9e3b4384 100644 --- a/TablePro/Core/Services/Infrastructure/ClipboardService.swift +++ b/TablePro/Core/Services/Infrastructure/ClipboardService.swift @@ -22,11 +22,22 @@ protocol ClipboardProvider { var hasGridRows: Bool { get } } +extension ClipboardProvider { + /// Text a clipboard-history app should not retain. Providers that cannot express that + /// fall back to a plain write rather than refusing to copy. + func writeSecretText(_ text: String) { + writeText(text) + } +} + struct NSPasteboardClipboardProvider: ClipboardProvider { private static let tsvType = NSPasteboard.PasteboardType("public.utf8-tab-separated-values-text") private static let csvType = NSPasteboard.PasteboardType("public.comma-separated-values-text") private static let gridRowsType = NSPasteboard.PasteboardType("com.TablePro.gridRows") + /// The convention clipboard managers watch for to keep an item out of their history. + private static let concealedType = NSPasteboard.PasteboardType("org.nspasteboard.ConcealedType") + func readText() -> String? { NSPasteboard.general.string(forType: .string) } @@ -43,6 +54,14 @@ struct NSPasteboardClipboardProvider: ClipboardProvider { pb.setString(text, forType: NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)) } + func writeSecretText(_ text: String) { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(text, forType: .string) + pb.setString(text, forType: NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)) + pb.setString(text, forType: Self.concealedType) + } + func writeCsv(_ csv: String) { let pb = NSPasteboard.general pb.clearContents() diff --git a/TablePro/Views/Connection/WelcomeContextMenus.swift b/TablePro/Views/Connection/WelcomeContextMenus.swift index 99e241588..4dbd29ede 100644 --- a/TablePro/Views/Connection/WelcomeContextMenus.swift +++ b/TablePro/Views/Connection/WelcomeContextMenus.swift @@ -187,7 +187,7 @@ extension WelcomeWindowView { sshPassword: sshPw, sshProfile: sshProfile ) - ClipboardService.shared.writeText(url) + ClipboardService.shared.writeSecretText(url) } label: { Label(String(localized: "Copy Connection String"), systemImage: "link") } From c5d9521c416661898e333556c9572b49299ff819 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 16:53:16 +0700 Subject: [PATCH 14/22] docs(changelog): record the security fixes --- CHANGELOG.md | 20 ++++++++++++++++++++ docs/databases/clickhouse.mdx | 2 ++ docs/features/connection-sharing.mdx | 13 +++++++++++++ docs/features/icloud-sync.mdx | 2 ++ 4 files changed, 37 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9be0b5699..36db99b11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Mobile keeps remote connections open when you switch apps. +- Mobile no longer copies database passwords to iCloud Keychain unless you turn on Sync Passwords. Mac already worked this way. +- An imported connection link now shows the startup SQL and driver options it carries, before you add it. + +### Security + +- Mobile now checks the SSH server's host key before sending any credential, and asks you the first time it sees a server. It never checked at all, so anyone intercepting the connection received the SSH password. +- Mobile no longer drops a connection's TLS verification settings when you save an unrelated edit. +- libssh2 is patched against CVE-2026-55199, where a malicious SSH server could pin a CPU core before authentication. +- An import link can no longer make TablePro fetch an AWS credential and send it to the link author's server. +- An import link can no longer preset the fields that decide where a connection looks for its password. +- A changed SSH host key is now reported as changed even when the server offers a different key type. Presenting a new key type used to get the milder first-use prompt. +- Trusting an unknown SSH host key now takes a click. Return picks Cancel. +- MySQL and MariaDB connections refuse a server's request to read a local file. +- A plugin's signature is rechecked immediately before it is loaded, and again before a staged update replaces the installed copy. +- Connections pulled from a team library no longer carry startup SQL or credential-resolution options. +- ClickHouse Verify CA now reads a PEM certificate authority file, and refuses to connect when the file cannot be read. It used to fall back to the public root store without saying so. +- Copy Connection String marks the clipboard item so clipboard-history apps leave it out of their history. +- The MCP server refuses a request whose browser Origin is not on its allow list, which closes a DNS-rebinding route to the local port. +- The MCP connection listing no longer names connections you set to AI Never. +- Release and test workflows pin their third-party actions to an exact commit. ### Fixed diff --git a/docs/databases/clickhouse.mdx b/docs/databases/clickhouse.mdx index eb6bcd217..8684cfb14 100644 --- a/docs/databases/clickhouse.mdx +++ b/docs/databases/clickhouse.mdx @@ -20,6 +20,8 @@ Click **New Connection**, select **ClickHouse**, enter host, port, credentials, **SSL/TLS**: Setting any non-Disabled SSL Mode switches the URL scheme to `https`. **Preferred** and **Required** skip certificate verification, **Verify CA** validates against the supplied CA file, **Verify Identity** uses system default HTTPS trust (certificate chain plus hostname). Use **Verify Identity** for ClickHouse Cloud (port 8443); its certificates chain to a public CA. Use **Required** only for a self-signed certificate you have no CA file for. See [SSL/TLS](/features/ssl). +The CA file may be PEM or DER. If **Verify CA** cannot read the file you point it at, the connection fails rather than falling back to the public root store. + For unencrypted HTTP to a remote server, use [SSH tunneling](/databases/ssh-tunneling). ## Connection URL diff --git a/docs/features/connection-sharing.mdx b/docs/features/connection-sharing.mdx index 3754ce2c0..9ee64f65f 100644 --- a/docs/features/connection-sharing.mdx +++ b/docs/features/connection-sharing.mdx @@ -77,6 +77,19 @@ tablepro://import?name=Staging&host=db.example.com&port=5432&type=PostgreSQL&use TablePro also opens `tablepro://connect/` URLs that launch a saved connection. There is no menu item for this form; build the URL from the connection's UUID. See [URL Scheme](/external-api/url-scheme#open-a-connection). +### What a link cannot set + +A link comes from outside, so TablePro drops the settings that decide where a connection finds or sends a credential. These are ignored on import, whether they arrive from a link or a `.tablepro` file: + +- Any `aws*` option, including IAM authentication, the region, the profile name, and the RDS endpoint used to sign a token +- `usePgpass` +- `preTunnelHost` and `preTunnelPort` +- `preConnectScript` +- `promptForPassword` +- `sslClientKeyPassphrase` + +Startup SQL and the remaining driver options do carry across, and the confirmation sheet lists them in full before you add the connection. Read the startup SQL: it runs on every connect, with your credentials. + ## Encrypted Export Starter Include passwords in the export, protected by a passphrase. diff --git a/docs/features/icloud-sync.mdx b/docs/features/icloud-sync.mdx index 32ab0a0be..110cca65a 100644 --- a/docs/features/icloud-sync.mdx +++ b/docs/features/icloud-sync.mdx @@ -21,6 +21,8 @@ TablePro syncs your connections, groups, table favorites, saved queries, setting Passwords are not synced by default. Enable the **Passwords** toggle under Connections to sync them via Apple's iCloud Keychain. Enabling it only affects new saves: re-save a password to update its sync. With password sync off, enter the password once on each new Mac. + +On iPhone and iPad the same opt-in lives in **Settings** > **Sync** > **Sync Passwords**, under the iCloud Sync toggle. It is off by default and, like the Mac, only affects new saves. ## Enabling iCloud Sync From ebeadc141caf06eb78fb51a191558716c082afda Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 16:59:54 +0700 Subject: [PATCH 15/22] build: update static library checksums --- Libs/checksums.sha256 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Libs/checksums.sha256 b/Libs/checksums.sha256 index af32116db..527721bc0 100644 --- a/Libs/checksums.sha256 +++ b/Libs/checksums.sha256 @@ -42,10 +42,10 @@ efba529b1ad767de988a58ca2c3fdcc26c38ce79df044a988f41fddbf9fde118 Libs/libpgport b86ecf68d2b0dd8aa7712d13607c9584df2297aca4cd651428e8ee974c6bdf80 Libs/libpq_universal.a 1ce2b45af228915fad05e07f54e96621af7143e199e002e5100777261a7f4a13 Libs/libpq_x86_64.a b86ecf68d2b0dd8aa7712d13607c9584df2297aca4cd651428e8ee974c6bdf80 Libs/libpq.a -6d737d744b5a2494bca0eee9091166d7e15912a3eca5aa0f74644393cc2ce087 Libs/libssh2_arm64.a -c6e3dbcb3d79d740a8bcab355ae10389a39d99e0c8594528202d0eb8c78a5e18 Libs/libssh2_universal.a -bd4dab1e2b24fa695bad8c951d5a2c3271c4c4436f0651f276c09899126fe088 Libs/libssh2_x86_64.a -c6e3dbcb3d79d740a8bcab355ae10389a39d99e0c8594528202d0eb8c78a5e18 Libs/libssh2.a +ba616a85e4de9800ba73095a888602c5202d320fd8d6cca80cf489ca7d923372 Libs/libssh2_arm64.a +c1249063c458a7db695d866c379ba21173fab24b41c4f94bf50370ebbc1202a1 Libs/libssh2_universal.a +c7d0d6e1caa7b44f56712f8f9f01b6f232dbb2c9888843c44c11b64c6f3af7fb Libs/libssh2_x86_64.a +c1249063c458a7db695d866c379ba21173fab24b41c4f94bf50370ebbc1202a1 Libs/libssh2.a b3861975896ebf35255d8c3efccdc59ad39874c9b70fdd710ebd15f0a58c4e10 Libs/libssl_arm64.a 3ca208dedf57dbae4f5cb0a22bfbedeba80dc6740d626484d9d815811d64a2aa Libs/libssl_universal.a 34de647ccd0951095f987591562a5236348bac2d4b3e217877559a7b170cf4e4 Libs/libssl_x86_64.a From 1e4369b766b6bcebf309eb6ab56f9a0b2b9321d0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 16:59:54 +0700 Subject: [PATCH 16/22] fix(connections): refuse to run a password source when the store was edited outside TablePro --- TablePro/Core/Database/DatabaseDriver.swift | 3 + TablePro/Core/Storage/ConnectionStorage.swift | 20 +++ .../Storage/ConnectionStoreIntegrity.swift | 130 ++++++++++++++++++ .../Connection/PasswordSourceResolver.swift | 7 + .../ConnectionStoreIntegrityTests.swift | 85 ++++++++++++ 5 files changed, 245 insertions(+) create mode 100644 TablePro/Core/Storage/ConnectionStoreIntegrity.swift create mode 100644 TableProTests/Core/Storage/ConnectionStoreIntegrityTests.swift diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 88975cd36..14b3e4431 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -580,6 +580,9 @@ enum DatabaseDriverFactory { } if let override { return override } if let passwordSource = connection.passwordSource { + guard await ConnectionStorage.shared.storeIsTrusted else { + throw PasswordSourceResolver.ResolutionError.storeNotTrusted + } return try await PasswordSourceResolver.resolve(passwordSource) } if connection.usePgpass { diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 998c5e2f0..e7b0bc42c 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -27,6 +27,10 @@ final class ConnectionStorage { /// In-memory cache to avoid re-decoding JSON from file on every access private var cachedConnections: [DatabaseConnection]? + /// Whether the file on disk is the one TablePro last wrote. False once it has been edited by + /// something else, which is the signal to refuse to run a connection's password source. + private(set) var storeIsTrusted = true + private let fileURL: URL private let keychain: any KeychainStoring @@ -78,9 +82,23 @@ final class ConnectionStorage { if let cached = cachedConnections { return cached } guard let data = try? Data(contentsOf: fileURL) else { + storeIsTrusted = true return [] } + switch ConnectionStoreIntegrity.verify(data, fileURL: fileURL) { + case .trusted: + storeIsTrusted = true + case .unstamped: + // An install that predates the tag. Adopt the file as it stands, which is the only + // option without a prior baseline, and stamp it so later edits are detectable. + ConnectionStoreIntegrity.stamp(data, fileURL: fileURL) + storeIsTrusted = true + case .modified: + Self.logger.warning("connections.json changed outside TablePro; password sources will not run") + storeIsTrusted = false + } + do { let storedConnections = try decoder.decode([StoredConnection].self, from: data) @@ -125,6 +143,8 @@ final class ConnectionStorage { do { let data = try encoder.encode(storedConnections) try data.write(to: fileURL, options: .atomic) + ConnectionStoreIntegrity.stamp(data, fileURL: fileURL) + storeIsTrusted = true cachedConnections = nil return true } catch { diff --git a/TablePro/Core/Storage/ConnectionStoreIntegrity.swift b/TablePro/Core/Storage/ConnectionStoreIntegrity.swift new file mode 100644 index 000000000..f11165a0b --- /dev/null +++ b/TablePro/Core/Storage/ConnectionStoreIntegrity.swift @@ -0,0 +1,130 @@ +// +// ConnectionStoreIntegrity.swift +// TablePro +// +// connections.json is ordinary user-writable storage, and a connection in it can declare a +// password source that TablePro executes at connect time. This binds the file to a key only +// TablePro can read, so a record written by another process is not acted on. +// +// The tag lives beside the file; forging it needs the key, which the keychain holds under the +// app's own access control. +// + +import CryptoKit +import Foundation +import os +import Security + +enum ConnectionStoreIntegrity { + enum Verdict: Equatable { + /// The tag matches, so the file is the one TablePro last wrote. + case trusted + /// No tag yet. An install that predates this check, or a fresh store. + case unstamped + /// A tag exists and does not match. The file changed outside TablePro. + case modified + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionStoreIntegrity") + + private static let keychainService = "com.TablePro" + private static let keychainAccount = "com.TablePro.connectionStoreIntegrityKey" + private static let keyByteCount = 32 + + private static let lock = NSLock() + private nonisolated(unsafe) static var cachedKey: SymmetricKey? + + static func tagURL(for fileURL: URL) -> URL { + fileURL.appendingPathExtension("hmac") + } + + static func verify(_ data: Data, fileURL: URL) -> Verdict { + guard let key = resolveKey() else { return .unstamped } + guard let storedTag = try? Data(contentsOf: tagURL(for: fileURL)), !storedTag.isEmpty else { + return .unstamped + } + + let expected = Data(HMAC.authenticationCode(for: data, using: key)) + return constantTimeEquals(expected, storedTag) ? .trusted : .modified + } + + @discardableResult + static func stamp(_ data: Data, fileURL: URL) -> Bool { + guard let key = resolveKey() else { return false } + let tag = Data(HMAC.authenticationCode(for: data, using: key)) + do { + try tag.write(to: tagURL(for: fileURL), options: .atomic) + return true + } catch { + logger.error("Could not write the connection store tag: \(error.localizedDescription)") + return false + } + } + + static func constantTimeEquals(_ lhs: Data, _ rhs: Data) -> Bool { + guard lhs.count == rhs.count else { return false } + var difference: UInt8 = 0 + for (left, right) in zip(lhs, rhs) { + difference |= left ^ right + } + return difference == 0 + } + + // MARK: - Key material + + private static func resolveKey() -> SymmetricKey? { + lock.lock() + defer { lock.unlock() } + + if let cachedKey { return cachedKey } + if let existing = readKey() { + cachedKey = existing + return existing + } + guard let created = createKey() else { return nil } + cachedKey = created + return created + } + + private static func baseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + kSecUseDataProtectionKeychain as String: true, + ] + } + + private static func readKey() -> SymmetricKey? { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data, + data.count == keyByteCount else { + return nil + } + return SymmetricKey(data: data) + } + + private static func createKey() -> SymmetricKey? { + var bytes = [UInt8](repeating: 0, count: keyByteCount) + guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { + logger.error("Could not generate a connection store integrity key") + return nil + } + + var addQuery = baseQuery() + addQuery[kSecValueData as String] = Data(bytes) + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + + let status = SecItemAdd(addQuery as CFDictionary, nil) + guard status == errSecSuccess else { + logger.error("Could not store the connection store integrity key (OSStatus \(status))") + return nil + } + return SymmetricKey(data: Data(bytes)) + } +} diff --git a/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift b/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift index 4173cde27..9697337d5 100644 --- a/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift +++ b/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift @@ -25,6 +25,7 @@ enum PasswordSourceResolver { case emptyPassword case invalidSecretJson case jsonKeyNotFound(key: String) + case storeNotTrusted var errorDescription: String? { switch self { @@ -57,6 +58,12 @@ enum PasswordSourceResolver { return String(localized: "The secret manager did not return valid JSON.") case let .jsonKeyNotFound(key): return String(format: String(localized: "Key %@ was not found in the secret JSON."), key) + case .storeNotTrusted: + return String(localized: """ + Your connections file was changed outside TablePro, so this connection's \ + password source was not run. Open the connection and save it again to \ + confirm the change. + """) } } } diff --git a/TableProTests/Core/Storage/ConnectionStoreIntegrityTests.swift b/TableProTests/Core/Storage/ConnectionStoreIntegrityTests.swift new file mode 100644 index 000000000..503e6380f --- /dev/null +++ b/TableProTests/Core/Storage/ConnectionStoreIntegrityTests.swift @@ -0,0 +1,85 @@ +// +// ConnectionStoreIntegrityTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("Connection store integrity") +struct ConnectionStoreIntegrityTests { + private func makeTempFileURL() -> URL { + URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("connections_\(UUID().uuidString).json") + } + + private func cleanUp(_ fileURL: URL) { + try? FileManager.default.removeItem(at: fileURL) + try? FileManager.default.removeItem(at: ConnectionStoreIntegrity.tagURL(for: fileURL)) + } + + @Test("A file with no tag reads as unstamped") + func unstampedWithoutTag() { + let fileURL = makeTempFileURL() + defer { cleanUp(fileURL) } + + #expect(ConnectionStoreIntegrity.verify(Data("[]".utf8), fileURL: fileURL) == .unstamped) + } + + @Test("A stamped file verifies against its own bytes") + func stampedFileIsTrusted() throws { + let fileURL = makeTempFileURL() + defer { cleanUp(fileURL) } + + let data = Data(#"[{"name":"Prod"}]"#.utf8) + try #require(ConnectionStoreIntegrity.stamp(data, fileURL: fileURL)) + + #expect(ConnectionStoreIntegrity.verify(data, fileURL: fileURL) == .trusted) + } + + @Test("Changing a single byte is detected") + func modifiedFileIsDetected() throws { + let fileURL = makeTempFileURL() + defer { cleanUp(fileURL) } + + let original = Data(#"[{"name":"Prod"}]"#.utf8) + try #require(ConnectionStoreIntegrity.stamp(original, fileURL: fileURL)) + + let tampered = Data(#"[{"name":"Prod!"}]"#.utf8) + #expect(ConnectionStoreIntegrity.verify(tampered, fileURL: fileURL) == .modified) + } + + @Test("A planted password source cannot be stamped without the key") + func forgedTagIsRejected() throws { + let fileURL = makeTempFileURL() + defer { cleanUp(fileURL) } + + let original = Data(#"[{"name":"Prod"}]"#.utf8) + try #require(ConnectionStoreIntegrity.stamp(original, fileURL: fileURL)) + + let planted = Data(#"[{"name":"Prod","passwordSource":{"kind":"command","shell":"id"}}]"#.utf8) + try Data(repeating: 0xAB, count: 32).write(to: ConnectionStoreIntegrity.tagURL(for: fileURL)) + + #expect(ConnectionStoreIntegrity.verify(planted, fileURL: fileURL) == .modified) + } + + @Test("An empty tag file is treated as no tag, not as a match") + func emptyTagIsUnstamped() throws { + let fileURL = makeTempFileURL() + defer { cleanUp(fileURL) } + + try Data().write(to: ConnectionStoreIntegrity.tagURL(for: fileURL)) + + #expect(ConnectionStoreIntegrity.verify(Data("[]".utf8), fileURL: fileURL) == .unstamped) + } + + @Test("Constant-time comparison agrees with equality") + func constantTimeComparison() { + let a = Data([1, 2, 3, 4]) + #expect(ConnectionStoreIntegrity.constantTimeEquals(a, Data([1, 2, 3, 4]))) + #expect(!ConnectionStoreIntegrity.constantTimeEquals(a, Data([1, 2, 3, 5]))) + #expect(!ConnectionStoreIntegrity.constantTimeEquals(a, Data([1, 2, 3]))) + } +} From 0c8cb7ae4942b8481828331ca2dcd73a5762de58 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 17:00:19 +0700 Subject: [PATCH 17/22] docs(changelog): record the connection store integrity check --- CHANGELOG.md | 1 + docs/features/connection-sharing.mdx | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36db99b11..e2061b06c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- A connection's password source no longer runs if the connections file was edited outside TablePro. Save the connection again from the app to confirm the change. - Mobile now checks the SSH server's host key before sending any credential, and asks you the first time it sees a server. It never checked at all, so anyone intercepting the connection received the SSH password. - Mobile no longer drops a connection's TLS verification settings when you save an unrelated edit. - libssh2 is patched against CVE-2026-55199, where a malicious SSH server could pin a CPU core before authentication. diff --git a/docs/features/connection-sharing.mdx b/docs/features/connection-sharing.mdx index 9ee64f65f..9de4ef87a 100644 --- a/docs/features/connection-sharing.mdx +++ b/docs/features/connection-sharing.mdx @@ -136,6 +136,8 @@ Use `$VAR` and `${VAR}` in `.tablepro` files. Resolved from TablePro's process e Connections in `~/Library/Application Support/TablePro/connections.json` can declare where their password comes from instead of storing it in the Keychain. Useful when a script provisions connections, for example one Docker database per git worktree. The password resolves at connect time and is not synced to iCloud, since the path, variable, or command is specific to one Mac. +TablePro keeps a signature for `connections.json`, so it can tell whether the file it is reading is the one it last wrote. If the file changed outside the app, password sources do not run and the connection reports why. Open the connection and save it from TablePro to accept the change. This is what stops a program that can write to your home folder from adding a `command` source and getting it executed the next time TablePro reopens your session. + Add a `passwordSource` object to the connection: ```json From a412da8ba28c0e1c01ac5eb00d2827d5cdfc2215 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 17:05:11 +0700 Subject: [PATCH 18/22] fix(plugins): require https for a plugin download and pin TLS for TablePro's own hosts --- TablePro/Core/Plugins/PluginInstaller.swift | 6 +++- TablePro/Info.plist | 33 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/Plugins/PluginInstaller.swift b/TablePro/Core/Plugins/PluginInstaller.swift index 418e60618..82761f65c 100644 --- a/TablePro/Core/Plugins/PluginInstaller.swift +++ b/TablePro/Core/Plugins/PluginInstaller.swift @@ -185,7 +185,11 @@ actor PluginInstaller { ) } - guard let downloadURL = URL(string: binary.downloadURL) else { + // The registry manifest is fetched over the network, so the URL it names is untrusted + // input. The code-signature check is the real gate, but nothing should be fetched over + // cleartext on the way to it. + guard let downloadURL = URL(string: binary.downloadURL), + downloadURL.scheme?.lowercased() == "https" else { throw PluginError.downloadFailed("Invalid download URL") } diff --git a/TablePro/Info.plist b/TablePro/Info.plist index 17d7c86e4..26bb8f3a9 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -313,6 +313,39 @@ NSExceptionRequiresForwardSecrecy + github.com + + NSIncludesSubdomains + + NSExceptionAllowsInsecureHTTPLoads + + NSExceptionMinimumTLSVersion + TLSv1.2 + NSExceptionRequiresForwardSecrecy + + + githubusercontent.com + + NSIncludesSubdomains + + NSExceptionAllowsInsecureHTTPLoads + + NSExceptionMinimumTLSVersion + TLSv1.2 + NSExceptionRequiresForwardSecrecy + + + jsdelivr.net + + NSIncludesSubdomains + + NSExceptionAllowsInsecureHTTPLoads + + NSExceptionMinimumTLSVersion + TLSv1.2 + NSExceptionRequiresForwardSecrecy + + NSUserActivityTypes From f5a8e10b5b48d1143ffd643fe78ccbbbfb94e317 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 17:05:11 +0700 Subject: [PATCH 19/22] style(connections): order nonisolated before private on the integrity key cache --- TablePro/Core/Storage/ConnectionStoreIntegrity.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Core/Storage/ConnectionStoreIntegrity.swift b/TablePro/Core/Storage/ConnectionStoreIntegrity.swift index f11165a0b..3fc8bb5c6 100644 --- a/TablePro/Core/Storage/ConnectionStoreIntegrity.swift +++ b/TablePro/Core/Storage/ConnectionStoreIntegrity.swift @@ -32,7 +32,7 @@ enum ConnectionStoreIntegrity { private static let keyByteCount = 32 private static let lock = NSLock() - private nonisolated(unsafe) static var cachedKey: SymmetricKey? + nonisolated(unsafe) private static var cachedKey: SymmetricKey? static func tagURL(for fileURL: URL) -> URL { fileURL.appendingPathExtension("hmac") From 3b3c98c5b643c898d4347b278bbfd4e551dcb446 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 17:31:44 +0700 Subject: [PATCH 20/22] fix(plugin-mssql): make Verify CA and Verify Identity check the certificate --- .../MSSQLConnectionOptions.swift | 5 ++ .../MSSQLFreeTDSConfig.swift | 64 +++++++++++++++ .../MSSQLDriverPlugin/FreeTDSConnection.swift | 49 ++++++++++- Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 4 +- .../MSSQLDriverPlugin/MSSQLSSLMapping.swift | 18 ++-- .../Plugins/MSSQLFreeTDSConfigTests.swift | 82 +++++++++++++++++++ project.yml | 2 + 7 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfig.swift create mode 100644 TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift index 54a8e1ba2..2a3c5ee09 100644 --- a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift @@ -14,6 +14,11 @@ public struct MSSQLConnectionOptions: Sendable, Equatable { public var kerberosCachePath: String? public var kerberosServicePrincipal: String? + /// How far to check the server certificate. Set after init by the plugin from the + /// connection's SSL mode, so the existing initializer keeps its signature. + public var certificateVerification: MSSQLCertificateVerification = .none + public var caCertificatePath: String? + public static let defaultPort = 1433 public static let defaultSchema = "dbo" public static let defaultApplicationName = "TablePro" diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfig.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfig.swift new file mode 100644 index 000000000..642a1a305 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLFreeTDSConfig.swift @@ -0,0 +1,64 @@ +// +// 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. +// + +import Foundation + +public enum MSSQLCertificateVerification: Equatable, Sendable { + /// Encrypt without checking who is on the other end. What "Required" has always meant. + case none + /// Check that the server certificate chains to the given authority. + case chain + /// Check the chain and that the certificate names the host being dialled. + case chainAndHostname + + public var checksHostname: Bool { self == .chainAndHostname } + 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" + + /// 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" + + public static func authorityPath(userSupplied: String?) -> String { + guard let userSupplied, !userSupplied.trimmingCharacters(in: .whitespaces).isEmpty else { + return systemTrustStorePath + } + return userSupplied + } + + public static func configuration( + host: String, + port: Int, + encryptionFlag: String, + verification: MSSQLCertificateVerification, + caCertificatePath: String? + ) -> String { + var lines = [ + "[\(serverEntryName)]", + "\thost = \(host)", + "\tport = \(port)", + "\ttds version = 7.4", + "\tencryption = \(encryptionFlag)", + ] + + if verification.needsAuthority { + lines.append("\tca file = \(authorityPath(userSupplied: caCertificatePath))") + } + if verification.checksHostname { + lines.append("\tcheck certificate hostname = yes") + } + + return lines.joined(separator: "\n") + "\n" + } +} diff --git a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift index bae84dca8..0ec3e0df7 100644 --- a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift +++ b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift @@ -126,6 +126,7 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { private var _isCancelled = false private static let kerberosEnvLock = NSLock() + private static let freetdsConfEnvLock = NSLock() private static let deadlineQueue = DispatchQueue(label: "com.TablePro.freetds.connect-deadline", qos: .userInitiated) private static let connectDeadlineMarginSeconds = 5 @@ -200,8 +201,11 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { #endif freetdsClearError(for: nil) - let serverName = "\(options.host):\(options.port)" - guard let proc = withKerberosEnvironmentIfNeeded({ dbopen(login, serverName) }) else { + 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) { @@ -215,6 +219,47 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { return proc } + /// 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() + } + + 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) + } + return body() + } + private func withKerberosEnvironmentIfNeeded( _ body: () -> UnsafeMutablePointer? ) -> UnsafeMutablePointer? { diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 9079ae7ee..ec9f028a8 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -278,7 +278,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { do { let kerberosCachePath = try await acquireKerberosTicketIfNeeded(authMethod: authMethod) let kerberosServicePrincipal = try await resolveKerberosServicePrincipal(authMethod: authMethod) - let options = MSSQLConnectionOptions( + var options = MSSQLConnectionOptions( host: config.host, port: config.port, user: config.username, @@ -290,6 +290,8 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { kerberosCachePath: kerberosCachePath, kerberosServicePrincipal: kerberosServicePrincipal ) + options.certificateVerification = MSSQLSSLMapping.certificateVerification(for: config.ssl.mode) + options.caCertificatePath = config.ssl.caCertificatePath conn = FreeTDSConnection(options: options) try await conn.connect() } catch let error as MSSQLCoreError { diff --git a/Plugins/MSSQLDriverPlugin/MSSQLSSLMapping.swift b/Plugins/MSSQLDriverPlugin/MSSQLSSLMapping.swift index 83880de04..0eab283db 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLSSLMapping.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLSSLMapping.swift @@ -1,12 +1,10 @@ import Foundation +import TableProMSSQLCore import TableProPluginKit -/// FreeTDS dblib reads this value via DBSETENCRYPT. Accepted values come from -/// libtds: "off", "request", "require", "strict". Cert verification beyond what -/// the system trust store provides is configured in freetds.conf, not per -/// connection through dblib, so .verifyCa and .verifyIdentity both map to -/// "require"; the actual verification depends on the trust store and any -/// freetds.conf overrides on the machine. +/// 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. enum MSSQLSSLMapping { static func freetdsEncryptionFlag(for mode: SSLMode) -> String { switch mode { @@ -15,4 +13,12 @@ enum MSSQLSSLMapping { case .required, .verifyCa, .verifyIdentity: return "require" } } + + static func certificateVerification(for mode: SSLMode) -> MSSQLCertificateVerification { + switch mode { + case .disabled, .preferred, .required: return .none + case .verifyCa: return .chain + case .verifyIdentity: return .chainAndHostname + } + } } diff --git a/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift b/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift new file mode 100644 index 000000000..c90a1b680 --- /dev/null +++ b/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift @@ -0,0 +1,82 @@ +// +// MSSQLFreeTDSConfigTests.swift +// TableProTests +// + +import Foundation +import TableProMSSQLCore +import TableProPluginKit +import Testing + +@Suite("MSSQL FreeTDS config") +struct MSSQLFreeTDSConfigTests { + private func configuration( + verification: MSSQLCertificateVerification, + caCertificatePath: String? = nil + ) -> String { + MSSQLFreeTDSConfig.configuration( + host: "db.example.com", + port: 1_433, + encryptionFlag: "require", + verification: verification, + caCertificatePath: caCertificatePath + ) + } + + @Test("Every SSL mode maps to the verification it advertises") + func modeMapping() { + #expect(MSSQLSSLMapping.certificateVerification(for: .disabled) == .none) + #expect(MSSQLSSLMapping.certificateVerification(for: .preferred) == .none) + #expect(MSSQLSSLMapping.certificateVerification(for: .required) == .none) + #expect(MSSQLSSLMapping.certificateVerification(for: .verifyCa) == .chain) + #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) + + #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")) + } + + @Test("Verify Identity also checks the hostname") + func verifyIdentityConfiguration() { + let text = configuration(verification: .chainAndHostname) + + #expect(text.contains("ca file = ")) + #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") + + #expect(text.contains("ca file = /Users/me/corp-ca.pem")) + #expect(!text.contains(MSSQLFreeTDSConfig.systemTrustStorePath)) + } + + @Test("A blank authority path falls back to the system trust store") + func blankAuthorityFallsBack() { + #expect(MSSQLFreeTDSConfig.authorityPath(userSupplied: nil) == MSSQLFreeTDSConfig.systemTrustStorePath) + #expect(MSSQLFreeTDSConfig.authorityPath(userSupplied: " ") == MSSQLFreeTDSConfig.systemTrustStorePath) + #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) + + #expect(!text.contains("ca file")) + #expect(!text.contains("check certificate hostname")) + } + + @Test("The system trust store is present on this machine") + func systemTrustStoreExists() { + #expect(FileManager.default.fileExists(atPath: MSSQLFreeTDSConfig.systemTrustStorePath)) + } +} diff --git a/project.yml b/project.yml index 98d212f9a..447baef64 100644 --- a/project.yml +++ b/project.yml @@ -388,6 +388,8 @@ targets: - Plugins/SurrealDBDriverPlugin/SurrealValue.swift dependencies: - target: TablePro + - package: TableProCore + products: [TableProMSSQLCore] settings: base: CURRENT_PROJECT_VERSION: 1 From 038e52bf91b7427dfd270d9f92c266d3eefd42ba Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 17:31:44 +0700 Subject: [PATCH 21/22] docs(changelog): record real certificate verification for SQL Server --- CHANGELOG.md | 1 + docs/databases/mssql.mdx | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2061b06c..1846c4b6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- SQL Server Verify CA and Verify Identity now check the certificate for real. Both used to encrypt without checking anything, while the picker said otherwise. - A connection's password source no longer runs if the connections file was edited outside TablePro. Save the connection again from the app to confirm the change. - Mobile now checks the SSH server's host key before sending any credential, and asks you the first time it sees a server. It never checked at all, so anyone intercepting the connection received the SSH password. - Mobile no longer drops a connection's TLS verification settings when you save an unrelated edit. diff --git a/docs/databases/mssql.mdx b/docs/databases/mssql.mdx index 082da319c..8155fbdd6 100644 --- a/docs/databases/mssql.mdx +++ b/docs/databases/mssql.mdx @@ -103,10 +103,12 @@ TablePro maps the SSL mode to FreeTDS `DBSETENCRYPT`. New connections default to | **Disabled** | `off` | Plain TCP | | **Preferred** | `request` | Try TLS, fall back to plain if the server cannot | | **Required** | `require` | TLS required | -| **Verify CA** | `require` | Same as Required; dblib does not accept a CA path per connection | -| **Verify Identity** | `require` | Same as Required | +| **Verify CA** | `require` | TLS required, and the certificate must chain to a trusted authority | +| **Verify Identity** | `require` | TLS required, chain checked, and the certificate must name the host you dialled | -Certificate verification beyond the system trust store is configured machine-wide in `freetds.conf`, not per connection. See [SSL/TLS](/features/ssl) for concepts. +FreeTDS reads certificate settings from a config file rather than from the connection, so the two verifying modes make TablePro write a one-entry config for that connection and point FreeTDS at it. Nothing on your machine changes: your own `freetds.conf` is used for every other connection. + +Verification uses the macOS system roots at `/etc/ssl/cert.pem`, which covers Azure SQL and any server with a publicly trusted certificate. For a private authority, set the CA certificate on the SSL tab and that file is used instead. See [SSL/TLS](/features/ssl) for concepts. ## Google Cloud SQL From 5275b4b4582bc6bc52742ef6d3115e8d3e041f20 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 11 Aug 2026 19:01:33 +0700 Subject: [PATCH 22/22] fix(connections): inject the integrity key source so a locked keychain cannot pass as trusted --- TablePro/Core/Storage/ConnectionStorage.swift | 12 ++- .../Storage/ConnectionStoreIntegrity.swift | 84 ++++++++++++------- .../ConnectionStoreIntegrityTests.swift | 58 +++++++++++-- 3 files changed, 110 insertions(+), 44 deletions(-) diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index e7b0bc42c..4414e568a 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -86,17 +86,20 @@ final class ConnectionStorage { return [] } - switch ConnectionStoreIntegrity.verify(data, fileURL: fileURL) { + switch ConnectionStoreIntegrity.shared.verify(data, fileURL: fileURL) { case .trusted: storeIsTrusted = true case .unstamped: // An install that predates the tag. Adopt the file as it stands, which is the only // option without a prior baseline, and stamp it so later edits are detectable. - ConnectionStoreIntegrity.stamp(data, fileURL: fileURL) + ConnectionStoreIntegrity.shared.stamp(data, fileURL: fileURL) storeIsTrusted = true case .modified: Self.logger.warning("connections.json changed outside TablePro; password sources will not run") storeIsTrusted = false + case .unavailable: + Self.logger.warning("No connection store integrity key; password sources will not run") + storeIsTrusted = false } do { @@ -143,8 +146,9 @@ final class ConnectionStorage { do { let data = try encoder.encode(storedConnections) try data.write(to: fileURL, options: .atomic) - ConnectionStoreIntegrity.stamp(data, fileURL: fileURL) - storeIsTrusted = true + // Trust follows the tag. If no tag could be written, later edits are undetectable, + // so the store is not treated as trusted. + storeIsTrusted = ConnectionStoreIntegrity.shared.stamp(data, fileURL: fileURL) cachedConnections = nil return true } catch { diff --git a/TablePro/Core/Storage/ConnectionStoreIntegrity.swift b/TablePro/Core/Storage/ConnectionStoreIntegrity.swift index 3fc8bb5c6..e62997d79 100644 --- a/TablePro/Core/Storage/ConnectionStoreIntegrity.swift +++ b/TablePro/Core/Storage/ConnectionStoreIntegrity.swift @@ -15,7 +15,12 @@ import Foundation import os import Security -enum ConnectionStoreIntegrity { +protocol IntegrityKeySource: Sendable { + /// The key this device signs its connection store with, or nil when no key can be obtained. + func key() -> SymmetricKey? +} + +struct ConnectionStoreIntegrity: Sendable { enum Verdict: Equatable { /// The tag matches, so the file is the one TablePro last wrote. case trusted @@ -23,40 +28,42 @@ enum ConnectionStoreIntegrity { case unstamped /// A tag exists and does not match. The file changed outside TablePro. case modified + /// No key, so nothing can be said either way. Treated as untrusted rather than assumed + /// good, for the same reason an unreadable certificate authority fails a connection. + case unavailable } - private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionStoreIntegrity") + static let shared = ConnectionStoreIntegrity() - private static let keychainService = "com.TablePro" - private static let keychainAccount = "com.TablePro.connectionStoreIntegrityKey" - private static let keyByteCount = 32 + private let keySource: any IntegrityKeySource - private static let lock = NSLock() - nonisolated(unsafe) private static var cachedKey: SymmetricKey? + init(keySource: any IntegrityKeySource = KeychainIntegrityKeySource()) { + self.keySource = keySource + } static func tagURL(for fileURL: URL) -> URL { fileURL.appendingPathExtension("hmac") } - static func verify(_ data: Data, fileURL: URL) -> Verdict { - guard let key = resolveKey() else { return .unstamped } - guard let storedTag = try? Data(contentsOf: tagURL(for: fileURL)), !storedTag.isEmpty else { + func verify(_ data: Data, fileURL: URL) -> Verdict { + guard let key = keySource.key() else { return .unavailable } + guard let storedTag = try? Data(contentsOf: Self.tagURL(for: fileURL)), !storedTag.isEmpty else { return .unstamped } let expected = Data(HMAC.authenticationCode(for: data, using: key)) - return constantTimeEquals(expected, storedTag) ? .trusted : .modified + return Self.constantTimeEquals(expected, storedTag) ? .trusted : .modified } @discardableResult - static func stamp(_ data: Data, fileURL: URL) -> Bool { - guard let key = resolveKey() else { return false } + func stamp(_ data: Data, fileURL: URL) -> Bool { + guard let key = keySource.key() else { return false } let tag = Data(HMAC.authenticationCode(for: data, using: key)) do { - try tag.write(to: tagURL(for: fileURL), options: .atomic) + try tag.write(to: Self.tagURL(for: fileURL), options: .atomic) return true } catch { - logger.error("Could not write the connection store tag: \(error.localizedDescription)") + Self.logger.error("Could not write the connection store tag: \(error.localizedDescription)") return false } } @@ -70,32 +77,43 @@ enum ConnectionStoreIntegrity { return difference == 0 } - // MARK: - Key material + fileprivate static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionStoreIntegrity") +} + +/// Holds the key in the keychain under the app's own access control, device local so a tag +/// written on one Mac is never compared against a file on another. +struct KeychainIntegrityKeySource: IntegrityKeySource { + private static let service = "com.TablePro" + private static let account = "com.TablePro.connectionStoreIntegrityKey" + private static let byteCount = 32 + + private static let lock = NSLock() + nonisolated(unsafe) private static var cached: SymmetricKey? - private static func resolveKey() -> SymmetricKey? { - lock.lock() - defer { lock.unlock() } + func key() -> SymmetricKey? { + Self.lock.lock() + defer { Self.lock.unlock() } - if let cachedKey { return cachedKey } - if let existing = readKey() { - cachedKey = existing + if let cached = Self.cached { return cached } + if let existing = Self.read() { + Self.cached = existing return existing } - guard let created = createKey() else { return nil } - cachedKey = created + guard let created = Self.create() else { return nil } + Self.cached = created return created } private static func baseQuery() -> [String: Any] { [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: keychainAccount, + kSecAttrService as String: service, + kSecAttrAccount as String: account, kSecUseDataProtectionKeychain as String: true, ] } - private static func readKey() -> SymmetricKey? { + private static func read() -> SymmetricKey? { var query = baseQuery() query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne @@ -103,16 +121,16 @@ enum ConnectionStoreIntegrity { var result: AnyObject? guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data, - data.count == keyByteCount else { + data.count == byteCount else { return nil } return SymmetricKey(data: data) } - private static func createKey() -> SymmetricKey? { - var bytes = [UInt8](repeating: 0, count: keyByteCount) + private static func create() -> SymmetricKey? { + var bytes = [UInt8](repeating: 0, count: byteCount) guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { - logger.error("Could not generate a connection store integrity key") + ConnectionStoreIntegrity.logger.error("Could not generate a connection store integrity key") return nil } @@ -122,7 +140,9 @@ enum ConnectionStoreIntegrity { let status = SecItemAdd(addQuery as CFDictionary, nil) guard status == errSecSuccess else { - logger.error("Could not store the connection store integrity key (OSStatus \(status))") + ConnectionStoreIntegrity.logger.error( + "Could not store the connection store integrity key (OSStatus \(status))" + ) return nil } return SymmetricKey(data: Data(bytes)) diff --git a/TableProTests/Core/Storage/ConnectionStoreIntegrityTests.swift b/TableProTests/Core/Storage/ConnectionStoreIntegrityTests.swift index 503e6380f..977bf66d8 100644 --- a/TableProTests/Core/Storage/ConnectionStoreIntegrityTests.swift +++ b/TableProTests/Core/Storage/ConnectionStoreIntegrityTests.swift @@ -3,13 +3,30 @@ // TableProTests // +import CryptoKit import Foundation import Testing @testable import TablePro +/// The production source keeps its key in the keychain, which a headless CI runner has no +/// unlocked access to. The property under test is the HMAC, not where the key is stored. +private struct FixedIntegrityKeySource: IntegrityKeySource { + let material: Data + + func key() -> SymmetricKey? { SymmetricKey(data: material) } +} + +private struct MissingIntegrityKeySource: IntegrityKeySource { + func key() -> SymmetricKey? { nil } +} + @Suite("Connection store integrity") struct ConnectionStoreIntegrityTests { + private let integrity = ConnectionStoreIntegrity( + keySource: FixedIntegrityKeySource(material: Data(repeating: 0x5A, count: 32)) + ) + private func makeTempFileURL() -> URL { URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("connections_\(UUID().uuidString).json") @@ -25,7 +42,7 @@ struct ConnectionStoreIntegrityTests { let fileURL = makeTempFileURL() defer { cleanUp(fileURL) } - #expect(ConnectionStoreIntegrity.verify(Data("[]".utf8), fileURL: fileURL) == .unstamped) + #expect(integrity.verify(Data("[]".utf8), fileURL: fileURL) == .unstamped) } @Test("A stamped file verifies against its own bytes") @@ -34,9 +51,9 @@ struct ConnectionStoreIntegrityTests { defer { cleanUp(fileURL) } let data = Data(#"[{"name":"Prod"}]"#.utf8) - try #require(ConnectionStoreIntegrity.stamp(data, fileURL: fileURL)) + try #require(integrity.stamp(data, fileURL: fileURL)) - #expect(ConnectionStoreIntegrity.verify(data, fileURL: fileURL) == .trusted) + #expect(integrity.verify(data, fileURL: fileURL) == .trusted) } @Test("Changing a single byte is detected") @@ -45,10 +62,10 @@ struct ConnectionStoreIntegrityTests { defer { cleanUp(fileURL) } let original = Data(#"[{"name":"Prod"}]"#.utf8) - try #require(ConnectionStoreIntegrity.stamp(original, fileURL: fileURL)) + try #require(integrity.stamp(original, fileURL: fileURL)) let tampered = Data(#"[{"name":"Prod!"}]"#.utf8) - #expect(ConnectionStoreIntegrity.verify(tampered, fileURL: fileURL) == .modified) + #expect(integrity.verify(tampered, fileURL: fileURL) == .modified) } @Test("A planted password source cannot be stamped without the key") @@ -57,12 +74,26 @@ struct ConnectionStoreIntegrityTests { defer { cleanUp(fileURL) } let original = Data(#"[{"name":"Prod"}]"#.utf8) - try #require(ConnectionStoreIntegrity.stamp(original, fileURL: fileURL)) + try #require(integrity.stamp(original, fileURL: fileURL)) let planted = Data(#"[{"name":"Prod","passwordSource":{"kind":"command","shell":"id"}}]"#.utf8) try Data(repeating: 0xAB, count: 32).write(to: ConnectionStoreIntegrity.tagURL(for: fileURL)) - #expect(ConnectionStoreIntegrity.verify(planted, fileURL: fileURL) == .modified) + #expect(integrity.verify(planted, fileURL: fileURL) == .modified) + } + + @Test("A tag written under a different key does not verify") + func otherKeyDoesNotVerify() throws { + let fileURL = makeTempFileURL() + defer { cleanUp(fileURL) } + + let data = Data(#"[{"name":"Prod"}]"#.utf8) + let other = ConnectionStoreIntegrity( + keySource: FixedIntegrityKeySource(material: Data(repeating: 0x11, count: 32)) + ) + try #require(other.stamp(data, fileURL: fileURL)) + + #expect(integrity.verify(data, fileURL: fileURL) == .modified) } @Test("An empty tag file is treated as no tag, not as a match") @@ -72,7 +103,18 @@ struct ConnectionStoreIntegrityTests { try Data().write(to: ConnectionStoreIntegrity.tagURL(for: fileURL)) - #expect(ConnectionStoreIntegrity.verify(Data("[]".utf8), fileURL: fileURL) == .unstamped) + #expect(integrity.verify(Data("[]".utf8), fileURL: fileURL) == .unstamped) + } + + @Test("No key means unavailable, never a silent pass") + func missingKeyIsUnavailable() { + let fileURL = makeTempFileURL() + defer { cleanUp(fileURL) } + + let withoutKey = ConnectionStoreIntegrity(keySource: MissingIntegrityKeySource()) + + #expect(withoutKey.verify(Data("[]".utf8), fileURL: fileURL) == .unavailable) + #expect(!withoutKey.stamp(Data("[]".utf8), fileURL: fileURL)) } @Test("Constant-time comparison agrees with equality")