From 377fbee0d495d16eca2ec25c2ccc2d5a2a267d70 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 14 Mar 2026 10:04:56 +0700 Subject: [PATCH 1/4] feat: add SSH TOTP/two-factor authentication support (#312) --- CHANGELOG.md | 3 + Libs/libssh2.a | 1 + Libs/libssh2_arm64.a | 3 + Libs/libssh2_universal.a | 3 + Libs/libssh2_x86_64.a | 3 + TablePro.xcodeproj/project.pbxproj | 26 +- TablePro/Core/Database/DatabaseManager.swift | 12 +- .../Core/SSH/Auth/AgentAuthenticator.swift | 86 + .../SSH/Auth/CompositeAuthenticator.swift | 35 + .../KeyboardInteractiveAuthenticator.swift | 155 ++ .../Core/SSH/Auth/PasswordAuthenticator.swift | 23 + .../Core/SSH/Auth/PromptTOTPProvider.swift | 47 + .../SSH/Auth/PublicKeyAuthenticator.swift | 68 + TablePro/Core/SSH/Auth/SSHAuthenticator.swift | 17 + TablePro/Core/SSH/Auth/TOTPProvider.swift | 30 + TablePro/Core/SSH/CLibSSH2/CLibSSH2.h | 39 + TablePro/Core/SSH/CLibSSH2/include/.gitkeep | 0 TablePro/Core/SSH/CLibSSH2/include/libssh2.h | 1516 +++++++++++++++++ .../SSH/CLibSSH2/include/libssh2_publickey.h | 128 ++ .../Core/SSH/CLibSSH2/include/libssh2_sftp.h | 382 +++++ TablePro/Core/SSH/CLibSSH2/module.modulemap | 4 + TablePro/Core/SSH/HostKeyStore.swift | 195 +++ TablePro/Core/SSH/HostKeyVerifier.swift | 167 ++ TablePro/Core/SSH/LibSSH2Tunnel.swift | 336 ++++ TablePro/Core/SSH/LibSSH2TunnelFactory.swift | 521 ++++++ TablePro/Core/SSH/SSHTunnelManager.swift | 605 +------ TablePro/Core/SSH/TOTP/Base32.swift | 63 + TablePro/Core/SSH/TOTP/TOTPGenerator.swift | 90 + TablePro/Core/Storage/ConnectionStorage.swift | 82 +- .../Connection/DatabaseConnection.swift | 14 + .../Models/Connection/TOTPConfiguration.swift | 32 + .../Views/Connection/ConnectionFormView.swift | 90 +- .../Core/SSH/HostKeyStoreTests.swift | 198 +++ TableProTests/Core/SSH/TOTP/Base32Tests.swift | 127 ++ .../Core/SSH/TOTP/TOTPGeneratorTests.swift | 205 +++ docs/databases/ssh-tunneling.mdx | 42 + docs/vi/databases/ssh-tunneling.mdx | 42 + scripts/build-libssh2.sh | 268 +++ 38 files changed, 5130 insertions(+), 528 deletions(-) create mode 120000 Libs/libssh2.a create mode 100644 Libs/libssh2_arm64.a create mode 100644 Libs/libssh2_universal.a create mode 100644 Libs/libssh2_x86_64.a create mode 100644 TablePro/Core/SSH/Auth/AgentAuthenticator.swift create mode 100644 TablePro/Core/SSH/Auth/CompositeAuthenticator.swift create mode 100644 TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift create mode 100644 TablePro/Core/SSH/Auth/PasswordAuthenticator.swift create mode 100644 TablePro/Core/SSH/Auth/PromptTOTPProvider.swift create mode 100644 TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift create mode 100644 TablePro/Core/SSH/Auth/SSHAuthenticator.swift create mode 100644 TablePro/Core/SSH/Auth/TOTPProvider.swift create mode 100644 TablePro/Core/SSH/CLibSSH2/CLibSSH2.h create mode 100644 TablePro/Core/SSH/CLibSSH2/include/.gitkeep create mode 100644 TablePro/Core/SSH/CLibSSH2/include/libssh2.h create mode 100644 TablePro/Core/SSH/CLibSSH2/include/libssh2_publickey.h create mode 100644 TablePro/Core/SSH/CLibSSH2/include/libssh2_sftp.h create mode 100644 TablePro/Core/SSH/CLibSSH2/module.modulemap create mode 100644 TablePro/Core/SSH/HostKeyStore.swift create mode 100644 TablePro/Core/SSH/HostKeyVerifier.swift create mode 100644 TablePro/Core/SSH/LibSSH2Tunnel.swift create mode 100644 TablePro/Core/SSH/LibSSH2TunnelFactory.swift create mode 100644 TablePro/Core/SSH/TOTP/Base32.swift create mode 100644 TablePro/Core/SSH/TOTP/TOTPGenerator.swift create mode 100644 TablePro/Models/Connection/TOTPConfiguration.swift create mode 100644 TableProTests/Core/SSH/HostKeyStoreTests.swift create mode 100644 TableProTests/Core/SSH/TOTP/Base32Tests.swift create mode 100644 TableProTests/Core/SSH/TOTP/TOTPGeneratorTests.swift create mode 100755 scripts/build-libssh2.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f1d95b65..58149582f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- SSH TOTP/two-factor authentication support (auto-generate and prompt modes) +- SSH host key verification with fingerprint confirmation +- Keyboard Interactive SSH authentication method - Column visibility: toggle individual columns on/off via "Columns" button in the status bar or right-click header context menu "Hide Column", with per-tab and per-table persistence - `SQLDialectDescriptor` in TableProPluginKit: plugins can now self-describe their SQL dialect (keywords, functions, data types, identifier quoting), with `SQLDialectFactory` preferring plugin-provided dialect info over built-in structs - DDL schema generation protocol in TableProPluginKit: plugins can now optionally provide database-specific ALTER TABLE syntax (ADD/MODIFY/DROP COLUMN, ADD/DROP INDEX, ADD/DROP FK, MODIFY PK) via `PluginDatabaseDriver`, with `SchemaStatementGenerator` trying plugin methods first before falling back to built-in logic diff --git a/Libs/libssh2.a b/Libs/libssh2.a new file mode 120000 index 000000000..584c3ceba --- /dev/null +++ b/Libs/libssh2.a @@ -0,0 +1 @@ +libssh2_universal.a \ No newline at end of file diff --git a/Libs/libssh2_arm64.a b/Libs/libssh2_arm64.a new file mode 100644 index 000000000..65ab0b8e7 --- /dev/null +++ b/Libs/libssh2_arm64.a @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:166e0e23ce60fd2edcae38b6005de106394f7e2bc922a4944317d6aa576f284c +size 367728 diff --git a/Libs/libssh2_universal.a b/Libs/libssh2_universal.a new file mode 100644 index 000000000..0adb842bb --- /dev/null +++ b/Libs/libssh2_universal.a @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:445b51e6fdaa0a0eceb8090e6d552a551ec15d91e4370a4cc356c8f561e8b469 +size 729032 diff --git a/Libs/libssh2_x86_64.a b/Libs/libssh2_x86_64.a new file mode 100644 index 000000000..14759e880 --- /dev/null +++ b/Libs/libssh2_x86_64.a @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:76681299c4305273cea62e59cfa366ceb5cc320831b87fd6a06143d342f8b7db +size 361256 diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 73d93cd80..d82f0524f 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -1715,9 +1715,19 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + HEADER_SEARCH_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2/include"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 0.17.0; - OTHER_LDFLAGS = "-Wl,-w"; + OTHER_LDFLAGS = ( + "-Wl,-w", + "-force_load", + "$(PROJECT_DIR)/Libs/libssh2.a", + "-force_load", + "$(PROJECT_DIR)/Libs/libssl.a", + "-force_load", + "$(PROJECT_DIR)/Libs/libcrypto.a", + "-lz", + ); PRODUCT_BUNDLE_IDENTIFIER = com.TablePro; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1734,6 +1744,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.9; XROS_DEPLOYMENT_TARGET = 26.2; @@ -1757,6 +1768,7 @@ DEAD_CODE_STRIPPING = YES; DEPLOYMENT_POSTPROCESSING = YES; DEVELOPMENT_TEAM = D7HJ5TFYCU; + HEADER_SEARCH_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2/include"; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -1779,7 +1791,16 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 0.17.0; - OTHER_LDFLAGS = "-Wl,-w"; + OTHER_LDFLAGS = ( + "-Wl,-w", + "-force_load", + "$(PROJECT_DIR)/Libs/libssh2.a", + "-force_load", + "$(PROJECT_DIR)/Libs/libssl.a", + "-force_load", + "$(PROJECT_DIR)/Libs/libcrypto.a", + "-lz", + ); PRODUCT_BUNDLE_IDENTIFIER = com.TablePro; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1796,6 +1817,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.9; XROS_DEPLOYMENT_TARGET = 26.2; diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 1ec1a86b9..84c0b23ee 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -353,10 +353,11 @@ final class DatabaseManager { // Load Keychain credentials off the main thread to avoid blocking UI let connectionId = connection.id - let (storedSshPassword, keyPassphrase) = await Task.detached { + let (storedSshPassword, keyPassphrase, totpSecret) = await Task.detached { let pwd = ConnectionStorage.shared.loadSSHPassword(for: connectionId) let phrase = ConnectionStorage.shared.loadKeyPassphrase(for: connectionId) - return (pwd, phrase) + let totp = ConnectionStorage.shared.loadTOTPSecret(for: connectionId) + return (pwd, phrase, totp) }.value let sshPassword = sshPasswordOverride ?? storedSshPassword @@ -373,7 +374,12 @@ final class DatabaseManager { agentSocketPath: connection.sshConfig.agentSocketPath, remoteHost: connection.host, remotePort: connection.port, - jumpHosts: connection.sshConfig.jumpHosts + jumpHosts: connection.sshConfig.jumpHosts, + totpMode: connection.sshConfig.totpMode, + totpSecret: totpSecret, + totpAlgorithm: connection.sshConfig.totpAlgorithm, + totpDigits: connection.sshConfig.totpDigits, + totpPeriod: connection.sshConfig.totpPeriod ) // Adapt SSL config for tunnel: SSH already authenticates the server, diff --git a/TablePro/Core/SSH/Auth/AgentAuthenticator.swift b/TablePro/Core/SSH/Auth/AgentAuthenticator.swift new file mode 100644 index 000000000..f212d433c --- /dev/null +++ b/TablePro/Core/SSH/Auth/AgentAuthenticator.swift @@ -0,0 +1,86 @@ +// +// AgentAuthenticator.swift +// TablePro +// + +import CLibSSH2 +import Foundation +import os + +struct AgentAuthenticator: SSHAuthenticator { + private static let logger = Logger(subsystem: "com.TablePro", category: "AgentAuthenticator") + + let socketPath: String? + + func authenticate(session: OpaquePointer, username: String) throws { + // Save original SSH_AUTH_SOCK so we can restore it + let originalSocketPath = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] + + if let socketPath { + Self.logger.debug("Using custom SSH agent socket: \(socketPath, privacy: .private)") + setenv("SSH_AUTH_SOCK", socketPath, 1) + } + + defer { + // Restore original SSH_AUTH_SOCK + if let originalSocketPath { + setenv("SSH_AUTH_SOCK", originalSocketPath, 1) + } else if socketPath != nil { + unsetenv("SSH_AUTH_SOCK") + } + } + + guard let agent = libssh2_agent_init(session) else { + throw SSHTunnelError.tunnelCreationFailed("Failed to initialize SSH agent") + } + + defer { + libssh2_agent_disconnect(agent) + libssh2_agent_free(agent) + } + + var rc = libssh2_agent_connect(agent) + guard rc == 0 else { + Self.logger.error("Failed to connect to SSH agent (rc=\(rc))") + throw SSHTunnelError.tunnelCreationFailed("Failed to connect to SSH agent") + } + + rc = libssh2_agent_list_identities(agent) + guard rc == 0 else { + Self.logger.error("Failed to list SSH agent identities (rc=\(rc))") + throw SSHTunnelError.tunnelCreationFailed("Failed to list SSH agent identities") + } + + // Iterate through available identities and try each + var previousIdentity: UnsafeMutablePointer? + var currentIdentity: UnsafeMutablePointer? + + while true { + rc = libssh2_agent_get_identity(agent, ¤tIdentity, previousIdentity) + + if rc == 1 { + // End of identity list, none worked + break + } + if rc < 0 { + Self.logger.error("Failed to get SSH agent identity (rc=\(rc))") + throw SSHTunnelError.tunnelCreationFailed("Failed to get SSH agent identity") + } + + guard let identity = currentIdentity else { + break + } + + let authRc = libssh2_agent_userauth(agent, username, identity) + if authRc == 0 { + Self.logger.info("SSH agent authentication succeeded") + return + } + + previousIdentity = identity + } + + Self.logger.error("SSH agent authentication failed: no identity accepted") + throw SSHTunnelError.authenticationFailed + } +} diff --git a/TablePro/Core/SSH/Auth/CompositeAuthenticator.swift b/TablePro/Core/SSH/Auth/CompositeAuthenticator.swift new file mode 100644 index 000000000..08406f7a8 --- /dev/null +++ b/TablePro/Core/SSH/Auth/CompositeAuthenticator.swift @@ -0,0 +1,35 @@ +// +// CompositeAuthenticator.swift +// TablePro +// + +import CLibSSH2 +import Foundation +import os + +/// Authenticator that tries multiple auth methods in sequence. +/// Used for servers requiring e.g. password + keyboard-interactive (TOTP). +struct CompositeAuthenticator: SSHAuthenticator { + private static let logger = Logger(subsystem: "com.TablePro", category: "CompositeAuthenticator") + + let authenticators: [any SSHAuthenticator] + + func authenticate(session: OpaquePointer, username: String) throws { + for (index, authenticator) in authenticators.enumerated() { + Self.logger.debug( + "Trying authenticator \(index + 1)/\(authenticators.count): \(String(describing: type(of: authenticator)))" + ) + + try authenticator.authenticate(session: session, username: username) + + if libssh2_userauth_authenticated(session) != 0 { + Self.logger.info("Authentication succeeded after \(index + 1) step(s)") + return + } + } + + if libssh2_userauth_authenticated(session) == 0 { + throw SSHTunnelError.authenticationFailed + } + } +} diff --git a/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift b/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift new file mode 100644 index 000000000..d5388b496 --- /dev/null +++ b/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift @@ -0,0 +1,155 @@ +// +// KeyboardInteractiveAuthenticator.swift +// TablePro +// + +import CLibSSH2 +import Foundation +import os + +/// Prompt type classification for keyboard-interactive authentication +enum KBDINTPromptType { + case password + case totp + case unknown +} + +/// Context passed through the libssh2 session abstract pointer to the C callback +final class KeyboardInteractiveContext { + var password: String? + var totpCode: String? + + init(password: String?, totpCode: String?) { + self.password = password + self.totpCode = totpCode + } +} + +/// C-compatible callback for libssh2 keyboard-interactive authentication. +/// +/// libssh2 calls this for each authentication challenge. The context (password/TOTP code) +/// is retrieved from the session abstract pointer. Responses are allocated with `strdup` +/// because libssh2 will `free` them. +private let kbdintCallback: @convention(c) ( + UnsafePointer?, Int32, + UnsafePointer?, Int32, + Int32, + UnsafePointer?, + UnsafeMutablePointer?, + UnsafeMutablePointer? +) -> Void = { _, _, _, _, numPrompts, prompts, responses, abstract in + guard numPrompts > 0, + let prompts, + let responses, + let abstract, + let contextPtr = abstract.pointee else { + return + } + + let context = Unmanaged.fromOpaque(contextPtr) + .takeUnretainedValue() + + for i in 0.. 0 { + promptText = String( + bytesNoCopy: UnsafeMutableRawPointer(mutating: textPtr), + length: Int(prompt.length), + encoding: .utf8, + freeWhenDone: false + ) ?? "" + } else { + promptText = "" + } + + let promptType = KeyboardInteractiveAuthenticator.classifyPrompt(promptText) + + let responseText: String + switch promptType { + case .password: + responseText = context.password ?? "" + case .totp: + responseText = context.totpCode ?? "" + case .unknown: + // Fall back to password for unrecognized prompts + responseText = context.password ?? "" + } + + let duplicated = strdup(responseText) + responses[i].text = duplicated + responses[i].length = UInt32(strlen(duplicated!)) + } +} + +struct KeyboardInteractiveAuthenticator: SSHAuthenticator { + private static let logger = Logger( + subsystem: "com.TablePro", + category: "KeyboardInteractiveAuthenticator" + ) + + let password: String? + let totpProvider: (any TOTPProvider)? + + func authenticate(session: OpaquePointer, username: String) throws { + // Generate TOTP code if a provider is available + let totpCode: String? + if let totpProvider { + totpCode = try totpProvider.provideCode() + } else { + totpCode = nil + } + + // Create context and store in session abstract pointer + let context = KeyboardInteractiveContext(password: password, totpCode: totpCode) + let contextPtr = Unmanaged.passRetained(context).toOpaque() + + defer { + // Balance the passRetained call + Unmanaged.fromOpaque(contextPtr).release() + } + + // Store context pointer in the session's abstract field + let abstractPtr = libssh2_session_abstract(session) + let previousAbstract = abstractPtr?.pointee + abstractPtr?.pointee = contextPtr + + defer { + // Restore previous abstract value + abstractPtr?.pointee = previousAbstract + } + + Self.logger.debug("Attempting keyboard-interactive authentication for \(username, privacy: .private)") + + let rc = libssh2_userauth_keyboard_interactive_ex( + session, + username, UInt32(username.utf8.count), + kbdintCallback + ) + + guard rc == 0 else { + Self.logger.error("Keyboard-interactive authentication failed (rc=\(rc))") + throw SSHTunnelError.authenticationFailed + } + + Self.logger.info("Keyboard-interactive authentication succeeded") + } + + /// Classify a keyboard-interactive prompt to determine which credential to supply + static func classifyPrompt(_ promptText: String) -> KBDINTPromptType { + let lower = promptText.lowercased() + + if lower.contains("password") { + return .password + } + + if lower.contains("verification") || lower.contains("code") || + lower.contains("otp") || lower.contains("token") || + lower.contains("totp") || lower.contains("2fa") || + lower.contains("one-time") || lower.contains("factor") { + return .totp + } + + return .unknown + } +} diff --git a/TablePro/Core/SSH/Auth/PasswordAuthenticator.swift b/TablePro/Core/SSH/Auth/PasswordAuthenticator.swift new file mode 100644 index 000000000..476575bb2 --- /dev/null +++ b/TablePro/Core/SSH/Auth/PasswordAuthenticator.swift @@ -0,0 +1,23 @@ +// +// PasswordAuthenticator.swift +// TablePro +// + +import CLibSSH2 +import Foundation + +struct PasswordAuthenticator: SSHAuthenticator { + let password: String + + func authenticate(session: OpaquePointer, username: String) throws { + let rc = libssh2_userauth_password_ex( + session, + username, UInt32(username.utf8.count), + password, UInt32(password.utf8.count), + nil + ) + guard rc == 0 else { + throw SSHTunnelError.authenticationFailed + } + } +} diff --git a/TablePro/Core/SSH/Auth/PromptTOTPProvider.swift b/TablePro/Core/SSH/Auth/PromptTOTPProvider.swift new file mode 100644 index 000000000..c3a2d5fc2 --- /dev/null +++ b/TablePro/Core/SSH/Auth/PromptTOTPProvider.swift @@ -0,0 +1,47 @@ +// +// PromptTOTPProvider.swift +// TablePro +// + +import AppKit +import Foundation + +/// Prompts the user for a TOTP code via a modal NSAlert dialog. +/// +/// This provider blocks the calling thread while the alert is displayed on the main thread. +/// It is intended for interactive SSH sessions where no TOTP secret is configured. +final class PromptTOTPProvider: TOTPProvider, @unchecked Sendable { + func provideCode() throws -> String { + let semaphore = DispatchSemaphore(value: 0) + var code: String? + + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = String(localized: "Verification Code Required") + alert.informativeText = String( + localized: "Enter the TOTP verification code for SSH authentication." + ) + alert.alertStyle = .informational + alert.addButton(withTitle: String(localized: "Connect")) + alert.addButton(withTitle: String(localized: "Cancel")) + + let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 24)) + textField.placeholderString = "000000" + alert.accessoryView = textField + alert.window.initialFirstResponder = textField + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + code = textField.stringValue + } + semaphore.signal() + } + + semaphore.wait() + + guard let totpCode = code, !totpCode.isEmpty else { + throw SSHTunnelError.authenticationFailed + } + return totpCode + } +} diff --git a/TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift b/TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift new file mode 100644 index 000000000..4a4d6d2ba --- /dev/null +++ b/TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift @@ -0,0 +1,68 @@ +// +// PublicKeyAuthenticator.swift +// TablePro +// + +import CLibSSH2 +import Foundation + +struct PublicKeyAuthenticator: SSHAuthenticator { + let privateKeyPath: String + let passphrase: String? + + func authenticate(session: OpaquePointer, username: String) throws { + let expandedPath = expandPath(privateKeyPath) + + guard FileManager.default.fileExists(atPath: expandedPath) else { + throw SSHTunnelError.tunnelCreationFailed( + "Private key file not found at: \(expandedPath)" + ) + } + guard FileManager.default.isReadableFile(atPath: expandedPath) else { + throw SSHTunnelError.tunnelCreationFailed( + "Private key file not readable. Check permissions (should be 600): \(expandedPath)" + ) + } + + let pubKeyPath = expandedPath + ".pub" + let hasPubKey = FileManager.default.fileExists(atPath: pubKeyPath) + + let rc: Int32 + if hasPubKey { + rc = pubKeyPath.withCString { pubKeyCStr in + libssh2_userauth_publickey_fromfile_ex( + session, + username, UInt32(username.utf8.count), + pubKeyCStr, + expandedPath, + passphrase + ) + } + } else { + rc = libssh2_userauth_publickey_fromfile_ex( + session, + username, UInt32(username.utf8.count), + nil, + expandedPath, + passphrase + ) + } + + guard rc == 0 else { + throw SSHTunnelError.authenticationFailed + } + } + + private func expandPath(_ path: String) -> String { + if path.hasPrefix("~/") { + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(String(path.dropFirst(2))) + .path(percentEncoded: false) + } + if path == "~" { + return FileManager.default.homeDirectoryForCurrentUser + .path(percentEncoded: false) + } + return path + } +} diff --git a/TablePro/Core/SSH/Auth/SSHAuthenticator.swift b/TablePro/Core/SSH/Auth/SSHAuthenticator.swift new file mode 100644 index 000000000..a8b8f2036 --- /dev/null +++ b/TablePro/Core/SSH/Auth/SSHAuthenticator.swift @@ -0,0 +1,17 @@ +// +// SSHAuthenticator.swift +// TablePro +// + +import CLibSSH2 +import Foundation + +/// Protocol for SSH authentication methods +protocol SSHAuthenticator: Sendable { + /// Authenticate the SSH session + /// - Parameters: + /// - session: libssh2 session pointer + /// - username: SSH username + /// - Throws: SSHTunnelError on failure + func authenticate(session: OpaquePointer, username: String) throws +} diff --git a/TablePro/Core/SSH/Auth/TOTPProvider.swift b/TablePro/Core/SSH/Auth/TOTPProvider.swift new file mode 100644 index 000000000..8304ddcf3 --- /dev/null +++ b/TablePro/Core/SSH/Auth/TOTPProvider.swift @@ -0,0 +1,30 @@ +// +// TOTPProvider.swift +// TablePro +// + +import Foundation + +/// Protocol for providing TOTP verification codes +protocol TOTPProvider: Sendable { + /// Generate or obtain a TOTP code + /// - Returns: The TOTP code string + /// - Throws: SSHTunnelError if the code cannot be obtained + func provideCode() throws -> String +} + +/// Automatically generates TOTP codes from a stored secret. +/// +/// If the current code expires in less than 5 seconds, waits for the next +/// period to avoid submitting a code that expires during the authentication handshake. +struct AutoTOTPProvider: TOTPProvider { + let generator: TOTPGenerator + + func provideCode() throws -> String { + let remaining = generator.secondsRemaining() + if remaining < 5 { + Thread.sleep(forTimeInterval: TimeInterval(remaining + 1)) + } + return generator.generate() + } +} diff --git a/TablePro/Core/SSH/CLibSSH2/CLibSSH2.h b/TablePro/Core/SSH/CLibSSH2/CLibSSH2.h new file mode 100644 index 000000000..c5d3fb156 --- /dev/null +++ b/TablePro/Core/SSH/CLibSSH2/CLibSSH2.h @@ -0,0 +1,39 @@ +// +// CLibSSH2.h +// TablePro +// +// C bridging header for libssh2 (SSH protocol library). +// Headers are bundled in the include/ subdirectory. +// + +#ifndef CLibSSH2_h +#define CLibSSH2_h + +#include "include/libssh2.h" +#include "include/libssh2_sftp.h" +#include "include/libssh2_publickey.h" + +// Wrapper functions for libssh2 macros (Swift cannot call C macros directly) + +static inline LIBSSH2_SESSION *tablepro_libssh2_session_init(void) { + return libssh2_session_init(); +} + +static inline int tablepro_libssh2_session_disconnect(LIBSSH2_SESSION *session, + const char *description) { + return libssh2_session_disconnect(session, description); +} + +static inline ssize_t tablepro_libssh2_channel_read(LIBSSH2_CHANNEL *channel, + char *buf, + size_t buflen) { + return libssh2_channel_read(channel, buf, buflen); +} + +static inline ssize_t tablepro_libssh2_channel_write(LIBSSH2_CHANNEL *channel, + const char *buf, + size_t buflen) { + return libssh2_channel_write(channel, buf, buflen); +} + +#endif /* CLibSSH2_h */ diff --git a/TablePro/Core/SSH/CLibSSH2/include/.gitkeep b/TablePro/Core/SSH/CLibSSH2/include/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/TablePro/Core/SSH/CLibSSH2/include/libssh2.h b/TablePro/Core/SSH/CLibSSH2/include/libssh2.h new file mode 100644 index 000000000..f47858aed --- /dev/null +++ b/TablePro/Core/SSH/CLibSSH2/include/libssh2.h @@ -0,0 +1,1516 @@ +/* Copyright (C) Sara Golemon + * Copyright (C) Daniel Stenberg + * Copyright (C) Simon Josefsson + * All rights reserved. + * + * Redistribution and use in source and binary forms, + * with or without modification, are permitted provided + * that the following conditions are met: + * + * Redistributions of source code must retain the above + * copyright notice, this list of conditions and the + * following disclaimer. + * + * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * Neither the name of the copyright holder nor the names + * of any other contributors may be used to endorse or + * promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY + * OF SUCH DAMAGE. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +#ifndef LIBSSH2_H +#define LIBSSH2_H 1 + +#define LIBSSH2_COPYRIGHT "The libssh2 project and its contributors." + +/* We use underscore instead of dash when appending DEV in dev versions just + to make the BANNER define (used by src/session.c) be a valid SSH + banner. Release versions have no appended strings and may of course not + have dashes either. */ +#define LIBSSH2_VERSION "1.11.1" + +/* The numeric version number is also available "in parts" by using these + defines: */ +#define LIBSSH2_VERSION_MAJOR 1 +#define LIBSSH2_VERSION_MINOR 11 +#define LIBSSH2_VERSION_PATCH 1 + +/* This is the numeric version of the libssh2 version number, meant for easier + parsing and comparisons by programs. The LIBSSH2_VERSION_NUM define will + always follow this syntax: + + 0xXXYYZZ + + Where XX, YY and ZZ are the main version, release and patch numbers in + hexadecimal (using 8 bits each). All three numbers are always represented + using two digits. 1.2 would appear as "0x010200" while version 9.11.7 + appears as "0x090b07". + + This 6-digit (24 bits) hexadecimal number does not show pre-release number, + and it is always a greater number in a more recent release. It makes + comparisons with greater than and less than work. +*/ +#define LIBSSH2_VERSION_NUM 0x010b01 + +/* + * This is the date and time when the full source package was created. The + * timestamp is not stored in the source code repo, as the timestamp is + * properly set in the tarballs by the maketgz script. + * + * The format of the date should follow this template: + * + * "Mon Feb 12 11:35:33 UTC 2007" + */ +#define LIBSSH2_TIMESTAMP "Wed Oct 16 08:03:21 UTC 2024" + +#ifndef RC_INVOKED + +#ifdef __cplusplus +extern "C" { +#endif + +#ifdef _WIN32 +# include +# include +#endif + +#include +#include +#include +#include + +/* Allow alternate API prefix from CFLAGS or calling app */ +#ifndef LIBSSH2_API +# ifdef _WIN32 +# if defined(LIBSSH2_EXPORTS) || defined(_WINDLL) +# ifdef LIBSSH2_LIBRARY +# define LIBSSH2_API __declspec(dllexport) +# else +# define LIBSSH2_API __declspec(dllimport) +# endif /* LIBSSH2_LIBRARY */ +# else +# define LIBSSH2_API +# endif +# else /* !_WIN32 */ +# define LIBSSH2_API +# endif /* _WIN32 */ +#endif /* LIBSSH2_API */ + +#ifdef HAVE_SYS_UIO_H +# include +#endif + +#ifdef _MSC_VER +typedef unsigned char uint8_t; +typedef unsigned short int uint16_t; +typedef unsigned int uint32_t; +typedef __int32 int32_t; +typedef __int64 int64_t; +typedef unsigned __int64 uint64_t; +typedef unsigned __int64 libssh2_uint64_t; +typedef __int64 libssh2_int64_t; +#if (!defined(HAVE_SSIZE_T) && !defined(ssize_t)) +typedef SSIZE_T ssize_t; +#define HAVE_SSIZE_T +#endif +#else +#include +typedef unsigned long long libssh2_uint64_t; +typedef long long libssh2_int64_t; +#endif + +#ifdef _WIN32 +typedef SOCKET libssh2_socket_t; +#define LIBSSH2_INVALID_SOCKET INVALID_SOCKET +#define LIBSSH2_SOCKET_CLOSE(s) closesocket(s) +#else /* !_WIN32 */ +typedef int libssh2_socket_t; +#define LIBSSH2_INVALID_SOCKET -1 +#define LIBSSH2_SOCKET_CLOSE(s) close(s) +#endif /* _WIN32 */ + +/* Compile-time deprecation macros */ +#if !defined(LIBSSH2_DISABLE_DEPRECATION) && !defined(LIBSSH2_LIBRARY) +# if defined(_MSC_VER) +# if _MSC_VER >= 1400 +# define LIBSSH2_DEPRECATED(version, message) \ + __declspec(deprecated("since libssh2 " # version ". " message)) +# elif _MSC_VER >= 1310 +# define LIBSSH2_DEPRECATED(version, message) \ + __declspec(deprecated) +# endif +# elif defined(__GNUC__) && !defined(__INTEL_COMPILER) +# if (defined(__clang__) && __clang_major__ >= 3) || \ + (__GNUC__ > 4 || (__GNUC__ == 4 && __GNUC_MINOR__ >= 5)) +# define LIBSSH2_DEPRECATED(version, message) \ + __attribute__((deprecated("since libssh2 " # version ". " message))) +# elif __GNUC__ > 3 || (__GNUC__ == 3 && __GNUC_MINOR__ > 0) +# define LIBSSH2_DEPRECATED(version, message) \ + __attribute__((deprecated)) +# endif +# elif defined(__SUNPRO_C) && __SUNPRO_C >= 0x5130 +# define LIBSSH2_DEPRECATED(version, message) \ + __attribute__((deprecated)) +# endif +#endif + +#ifndef LIBSSH2_DEPRECATED +#define LIBSSH2_DEPRECATED(version, message) +#endif + +/* + * Determine whether there is small or large file support on windows. + */ + +#if defined(_MSC_VER) && !defined(_WIN32_WCE) +# if (_MSC_VER >= 900) && (_INTEGRAL_MAX_BITS >= 64) +# define LIBSSH2_USE_WIN32_LARGE_FILES +# else +# define LIBSSH2_USE_WIN32_SMALL_FILES +# endif +#endif + +#if defined(__MINGW32__) && !defined(LIBSSH2_USE_WIN32_LARGE_FILES) +# define LIBSSH2_USE_WIN32_LARGE_FILES +#endif + +#if defined(__WATCOMC__) && !defined(LIBSSH2_USE_WIN32_LARGE_FILES) +# define LIBSSH2_USE_WIN32_LARGE_FILES +#endif + +#if defined(__POCC__) +# undef LIBSSH2_USE_WIN32_LARGE_FILES +#endif + +#if defined(_WIN32) && !defined(LIBSSH2_USE_WIN32_LARGE_FILES) && \ + !defined(LIBSSH2_USE_WIN32_SMALL_FILES) +# define LIBSSH2_USE_WIN32_SMALL_FILES +#endif + +/* + * Large file (>2Gb) support using WIN32 functions. + */ + +#ifdef LIBSSH2_USE_WIN32_LARGE_FILES +# include +# define LIBSSH2_STRUCT_STAT_SIZE_FORMAT "%I64d" +typedef struct _stati64 libssh2_struct_stat; +typedef __int64 libssh2_struct_stat_size; +#endif + +/* + * Small file (<2Gb) support using WIN32 functions. + */ + +#ifdef LIBSSH2_USE_WIN32_SMALL_FILES +# ifndef _WIN32_WCE +# define LIBSSH2_STRUCT_STAT_SIZE_FORMAT "%d" +typedef struct _stat libssh2_struct_stat; +typedef off_t libssh2_struct_stat_size; +# endif +#endif + +#ifndef LIBSSH2_STRUCT_STAT_SIZE_FORMAT +# ifdef __VMS +/* We have to roll our own format here because %z is a C99-ism we don't + have. */ +# if __USE_OFF64_T || __USING_STD_STAT +# define LIBSSH2_STRUCT_STAT_SIZE_FORMAT "%Ld" +# else +# define LIBSSH2_STRUCT_STAT_SIZE_FORMAT "%d" +# endif +# else +# define LIBSSH2_STRUCT_STAT_SIZE_FORMAT "%zd" +# endif +typedef struct stat libssh2_struct_stat; +typedef off_t libssh2_struct_stat_size; +#endif + +/* Part of every banner, user specified or not */ +#define LIBSSH2_SSH_BANNER "SSH-2.0-libssh2_" LIBSSH2_VERSION + +#define LIBSSH2_SSH_DEFAULT_BANNER LIBSSH2_SSH_BANNER +#define LIBSSH2_SSH_DEFAULT_BANNER_WITH_CRLF LIBSSH2_SSH_DEFAULT_BANNER "\r\n" + +/* Defaults for pty requests */ +#define LIBSSH2_TERM_WIDTH 80 +#define LIBSSH2_TERM_HEIGHT 24 +#define LIBSSH2_TERM_WIDTH_PX 0 +#define LIBSSH2_TERM_HEIGHT_PX 0 + +/* 1/4 second */ +#define LIBSSH2_SOCKET_POLL_UDELAY 250000 +/* 0.25 * 120 == 30 seconds */ +#define LIBSSH2_SOCKET_POLL_MAXLOOPS 120 + +/* Maximum size to allow a payload to compress to, plays it safe by falling + short of spec limits */ +#define LIBSSH2_PACKET_MAXCOMP 32000 + +/* Maximum size to allow a payload to deccompress to, plays it safe by + allowing more than spec requires */ +#define LIBSSH2_PACKET_MAXDECOMP 40000 + +/* Maximum size for an inbound compressed payload, plays it safe by + overshooting spec limits */ +#define LIBSSH2_PACKET_MAXPAYLOAD 40000 + +/* Malloc callbacks */ +#define LIBSSH2_ALLOC_FUNC(name) void *name(size_t count, void **abstract) +#define LIBSSH2_REALLOC_FUNC(name) void *name(void *ptr, size_t count, \ + void **abstract) +#define LIBSSH2_FREE_FUNC(name) void name(void *ptr, void **abstract) + +typedef struct _LIBSSH2_USERAUTH_KBDINT_PROMPT +{ + unsigned char *text; + size_t length; + unsigned char echo; +} LIBSSH2_USERAUTH_KBDINT_PROMPT; + +typedef struct _LIBSSH2_USERAUTH_KBDINT_RESPONSE +{ + char *text; + unsigned int length; /* FIXME: change type to size_t */ +} LIBSSH2_USERAUTH_KBDINT_RESPONSE; + +typedef struct _LIBSSH2_SK_SIG_INFO { + uint8_t flags; + uint32_t counter; + unsigned char *sig_r; + size_t sig_r_len; + unsigned char *sig_s; + size_t sig_s_len; +} LIBSSH2_SK_SIG_INFO; + +/* 'publickey' authentication callback */ +#define LIBSSH2_USERAUTH_PUBLICKEY_SIGN_FUNC(name) \ + int name(LIBSSH2_SESSION *session, unsigned char **sig, size_t *sig_len, \ + const unsigned char *data, size_t data_len, void **abstract) + +/* 'keyboard-interactive' authentication callback */ +/* FIXME: name_len, instruction_len -> size_t, num_prompts -> unsigned int? */ +#define LIBSSH2_USERAUTH_KBDINT_RESPONSE_FUNC(name_) \ + void name_(const char *name, int name_len, const char *instruction, \ + int instruction_len, int num_prompts, \ + const LIBSSH2_USERAUTH_KBDINT_PROMPT *prompts, \ + LIBSSH2_USERAUTH_KBDINT_RESPONSE *responses, void **abstract) + +/* SK authentication callback */ +#define LIBSSH2_USERAUTH_SK_SIGN_FUNC(name) \ + int name(LIBSSH2_SESSION *session, LIBSSH2_SK_SIG_INFO *sig_info, \ + const unsigned char *data, size_t data_len, \ + int algorithm, uint8_t flags, \ + const char *application, const unsigned char *key_handle, \ + size_t handle_len, \ + void **abstract) + +/* Flags for SK authentication */ +#define LIBSSH2_SK_PRESENCE_REQUIRED 0x01 +#define LIBSSH2_SK_VERIFICATION_REQUIRED 0x04 + +/* FIXME: update lengths to size_t (or ssize_t): */ + +/* Callbacks for special SSH packets */ +#define LIBSSH2_IGNORE_FUNC(name) \ + void name(LIBSSH2_SESSION *session, const char *message, int message_len, \ + void **abstract) + +#define LIBSSH2_DEBUG_FUNC(name) \ + void name(LIBSSH2_SESSION *session, int always_display, \ + const char *message, int message_len, \ + const char *language, int language_len, \ + void **abstract) + +#define LIBSSH2_DISCONNECT_FUNC(name) \ + void name(LIBSSH2_SESSION *session, int reason, \ + const char *message, int message_len, \ + const char *language, int language_len, \ + void **abstract) + +#define LIBSSH2_PASSWD_CHANGEREQ_FUNC(name) \ + void name(LIBSSH2_SESSION *session, char **newpw, int *newpw_len, \ + void **abstract) + +#define LIBSSH2_MACERROR_FUNC(name) \ + int name(LIBSSH2_SESSION *session, const char *packet, int packet_len, \ + void **abstract) + +#define LIBSSH2_X11_OPEN_FUNC(name) \ + void name(LIBSSH2_SESSION *session, LIBSSH2_CHANNEL *channel, \ + const char *shost, int sport, void **abstract) + +#define LIBSSH2_AUTHAGENT_FUNC(name) \ + void name(LIBSSH2_SESSION *session, LIBSSH2_CHANNEL *channel, \ + void **abstract) + +#define LIBSSH2_ADD_IDENTITIES_FUNC(name) \ + void name(LIBSSH2_SESSION *session, void *buffer, \ + const char *agent_path, void **abstract) + +#define LIBSSH2_AUTHAGENT_SIGN_FUNC(name) \ + int name(LIBSSH2_SESSION* session, \ + unsigned char *blob, unsigned int blen, \ + const unsigned char *data, unsigned int dlen, \ + unsigned char **signature, unsigned int *sigLen, \ + const char *agentPath, \ + void **abstract) + +#define LIBSSH2_CHANNEL_CLOSE_FUNC(name) \ + void name(LIBSSH2_SESSION *session, void **session_abstract, \ + LIBSSH2_CHANNEL *channel, void **channel_abstract) + +/* I/O callbacks */ +#define LIBSSH2_RECV_FUNC(name) \ + ssize_t name(libssh2_socket_t socket, \ + void *buffer, size_t length, \ + int flags, void **abstract) +#define LIBSSH2_SEND_FUNC(name) \ + ssize_t name(libssh2_socket_t socket, \ + const void *buffer, size_t length, \ + int flags, void **abstract) + +/* libssh2_session_callback_set() constants */ +#define LIBSSH2_CALLBACK_IGNORE 0 +#define LIBSSH2_CALLBACK_DEBUG 1 +#define LIBSSH2_CALLBACK_DISCONNECT 2 +#define LIBSSH2_CALLBACK_MACERROR 3 +#define LIBSSH2_CALLBACK_X11 4 +#define LIBSSH2_CALLBACK_SEND 5 +#define LIBSSH2_CALLBACK_RECV 6 +#define LIBSSH2_CALLBACK_AUTHAGENT 7 +#define LIBSSH2_CALLBACK_AUTHAGENT_IDENTITIES 8 +#define LIBSSH2_CALLBACK_AUTHAGENT_SIGN 9 + +/* libssh2_session_method_pref() constants */ +#define LIBSSH2_METHOD_KEX 0 +#define LIBSSH2_METHOD_HOSTKEY 1 +#define LIBSSH2_METHOD_CRYPT_CS 2 +#define LIBSSH2_METHOD_CRYPT_SC 3 +#define LIBSSH2_METHOD_MAC_CS 4 +#define LIBSSH2_METHOD_MAC_SC 5 +#define LIBSSH2_METHOD_COMP_CS 6 +#define LIBSSH2_METHOD_COMP_SC 7 +#define LIBSSH2_METHOD_LANG_CS 8 +#define LIBSSH2_METHOD_LANG_SC 9 +#define LIBSSH2_METHOD_SIGN_ALGO 10 + +/* flags */ +#define LIBSSH2_FLAG_SIGPIPE 1 +#define LIBSSH2_FLAG_COMPRESS 2 +#define LIBSSH2_FLAG_QUOTE_PATHS 3 + +typedef struct _LIBSSH2_SESSION LIBSSH2_SESSION; +typedef struct _LIBSSH2_CHANNEL LIBSSH2_CHANNEL; +typedef struct _LIBSSH2_LISTENER LIBSSH2_LISTENER; +typedef struct _LIBSSH2_KNOWNHOSTS LIBSSH2_KNOWNHOSTS; +typedef struct _LIBSSH2_AGENT LIBSSH2_AGENT; + +/* SK signature callback */ +typedef struct _LIBSSH2_PRIVKEY_SK { + int algorithm; + uint8_t flags; + const char *application; + const unsigned char *key_handle; + size_t handle_len; + LIBSSH2_USERAUTH_SK_SIGN_FUNC((*sign_callback)); + void **orig_abstract; +} LIBSSH2_PRIVKEY_SK; + +int +libssh2_sign_sk(LIBSSH2_SESSION *session, + unsigned char **sig, + size_t *sig_len, + const unsigned char *data, + size_t data_len, + void **abstract); + +typedef struct _LIBSSH2_POLLFD { + unsigned char type; /* LIBSSH2_POLLFD_* below */ + + union { + libssh2_socket_t socket; /* File descriptors -- examined with + system select() call */ + LIBSSH2_CHANNEL *channel; /* Examined by checking internal state */ + LIBSSH2_LISTENER *listener; /* Read polls only -- are inbound + connections waiting to be accepted? */ + } fd; + + unsigned long events; /* Requested Events */ + unsigned long revents; /* Returned Events */ +} LIBSSH2_POLLFD; + +/* Poll FD Descriptor Types */ +#define LIBSSH2_POLLFD_SOCKET 1 +#define LIBSSH2_POLLFD_CHANNEL 2 +#define LIBSSH2_POLLFD_LISTENER 3 + +/* Note: Win32 Doesn't actually have a poll() implementation, so some of these + values are faked with select() data */ +/* Poll FD events/revents -- Match sys/poll.h where possible */ +#define LIBSSH2_POLLFD_POLLIN 0x0001 /* Data available to be read or + connection available -- + All */ +#define LIBSSH2_POLLFD_POLLPRI 0x0002 /* Priority data available to + be read -- Socket only */ +#define LIBSSH2_POLLFD_POLLEXT 0x0002 /* Extended data available to + be read -- Channel only */ +#define LIBSSH2_POLLFD_POLLOUT 0x0004 /* Can may be written -- + Socket/Channel */ +/* revents only */ +#define LIBSSH2_POLLFD_POLLERR 0x0008 /* Error Condition -- Socket */ +#define LIBSSH2_POLLFD_POLLHUP 0x0010 /* HangUp/EOF -- Socket */ +#define LIBSSH2_POLLFD_SESSION_CLOSED 0x0010 /* Session Disconnect */ +#define LIBSSH2_POLLFD_POLLNVAL 0x0020 /* Invalid request -- Socket + Only */ +#define LIBSSH2_POLLFD_POLLEX 0x0040 /* Exception Condition -- + Socket/Win32 */ +#define LIBSSH2_POLLFD_CHANNEL_CLOSED 0x0080 /* Channel Disconnect */ +#define LIBSSH2_POLLFD_LISTENER_CLOSED 0x0080 /* Listener Disconnect */ + +#define HAVE_LIBSSH2_SESSION_BLOCK_DIRECTION +/* Block Direction Types */ +#define LIBSSH2_SESSION_BLOCK_INBOUND 0x0001 +#define LIBSSH2_SESSION_BLOCK_OUTBOUND 0x0002 + +/* Hash Types */ +#define LIBSSH2_HOSTKEY_HASH_MD5 1 +#define LIBSSH2_HOSTKEY_HASH_SHA1 2 +#define LIBSSH2_HOSTKEY_HASH_SHA256 3 + +/* Hostkey Types */ +#define LIBSSH2_HOSTKEY_TYPE_UNKNOWN 0 +#define LIBSSH2_HOSTKEY_TYPE_RSA 1 +#define LIBSSH2_HOSTKEY_TYPE_DSS 2 /* deprecated */ +#define LIBSSH2_HOSTKEY_TYPE_ECDSA_256 3 +#define LIBSSH2_HOSTKEY_TYPE_ECDSA_384 4 +#define LIBSSH2_HOSTKEY_TYPE_ECDSA_521 5 +#define LIBSSH2_HOSTKEY_TYPE_ED25519 6 + +/* Disconnect Codes (defined by SSH protocol) */ +#define SSH_DISCONNECT_HOST_NOT_ALLOWED_TO_CONNECT 1 +#define SSH_DISCONNECT_PROTOCOL_ERROR 2 +#define SSH_DISCONNECT_KEY_EXCHANGE_FAILED 3 +#define SSH_DISCONNECT_RESERVED 4 +#define SSH_DISCONNECT_MAC_ERROR 5 +#define SSH_DISCONNECT_COMPRESSION_ERROR 6 +#define SSH_DISCONNECT_SERVICE_NOT_AVAILABLE 7 +#define SSH_DISCONNECT_PROTOCOL_VERSION_NOT_SUPPORTED 8 +#define SSH_DISCONNECT_HOST_KEY_NOT_VERIFIABLE 9 +#define SSH_DISCONNECT_CONNECTION_LOST 10 +#define SSH_DISCONNECT_BY_APPLICATION 11 +#define SSH_DISCONNECT_TOO_MANY_CONNECTIONS 12 +#define SSH_DISCONNECT_AUTH_CANCELLED_BY_USER 13 +#define SSH_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE 14 +#define SSH_DISCONNECT_ILLEGAL_USER_NAME 15 + +/* Error Codes (defined by libssh2) */ +#define LIBSSH2_ERROR_NONE 0 + +/* The library once used -1 as a generic error return value on numerous places + through the code, which subsequently was converted to + LIBSSH2_ERROR_SOCKET_NONE uses over time. As this is a generic error code, + the goal is to never ever return this code but instead make sure that a + more accurate and descriptive error code is used. */ +#define LIBSSH2_ERROR_SOCKET_NONE -1 + +#define LIBSSH2_ERROR_BANNER_RECV -2 +#define LIBSSH2_ERROR_BANNER_SEND -3 +#define LIBSSH2_ERROR_INVALID_MAC -4 +#define LIBSSH2_ERROR_KEX_FAILURE -5 +#define LIBSSH2_ERROR_ALLOC -6 +#define LIBSSH2_ERROR_SOCKET_SEND -7 +#define LIBSSH2_ERROR_KEY_EXCHANGE_FAILURE -8 +#define LIBSSH2_ERROR_TIMEOUT -9 +#define LIBSSH2_ERROR_HOSTKEY_INIT -10 +#define LIBSSH2_ERROR_HOSTKEY_SIGN -11 +#define LIBSSH2_ERROR_DECRYPT -12 +#define LIBSSH2_ERROR_SOCKET_DISCONNECT -13 +#define LIBSSH2_ERROR_PROTO -14 +#define LIBSSH2_ERROR_PASSWORD_EXPIRED -15 +#define LIBSSH2_ERROR_FILE -16 +#define LIBSSH2_ERROR_METHOD_NONE -17 +#define LIBSSH2_ERROR_AUTHENTICATION_FAILED -18 +#define LIBSSH2_ERROR_PUBLICKEY_UNRECOGNIZED \ + LIBSSH2_ERROR_AUTHENTICATION_FAILED +#define LIBSSH2_ERROR_PUBLICKEY_UNVERIFIED -19 +#define LIBSSH2_ERROR_CHANNEL_OUTOFORDER -20 +#define LIBSSH2_ERROR_CHANNEL_FAILURE -21 +#define LIBSSH2_ERROR_CHANNEL_REQUEST_DENIED -22 +#define LIBSSH2_ERROR_CHANNEL_UNKNOWN -23 +#define LIBSSH2_ERROR_CHANNEL_WINDOW_EXCEEDED -24 +#define LIBSSH2_ERROR_CHANNEL_PACKET_EXCEEDED -25 +#define LIBSSH2_ERROR_CHANNEL_CLOSED -26 +#define LIBSSH2_ERROR_CHANNEL_EOF_SENT -27 +#define LIBSSH2_ERROR_SCP_PROTOCOL -28 +#define LIBSSH2_ERROR_ZLIB -29 +#define LIBSSH2_ERROR_SOCKET_TIMEOUT -30 +#define LIBSSH2_ERROR_SFTP_PROTOCOL -31 +#define LIBSSH2_ERROR_REQUEST_DENIED -32 +#define LIBSSH2_ERROR_METHOD_NOT_SUPPORTED -33 +#define LIBSSH2_ERROR_INVAL -34 +#define LIBSSH2_ERROR_INVALID_POLL_TYPE -35 +#define LIBSSH2_ERROR_PUBLICKEY_PROTOCOL -36 +#define LIBSSH2_ERROR_EAGAIN -37 +#define LIBSSH2_ERROR_BUFFER_TOO_SMALL -38 +#define LIBSSH2_ERROR_BAD_USE -39 +#define LIBSSH2_ERROR_COMPRESS -40 +#define LIBSSH2_ERROR_OUT_OF_BOUNDARY -41 +#define LIBSSH2_ERROR_AGENT_PROTOCOL -42 +#define LIBSSH2_ERROR_SOCKET_RECV -43 +#define LIBSSH2_ERROR_ENCRYPT -44 +#define LIBSSH2_ERROR_BAD_SOCKET -45 +#define LIBSSH2_ERROR_KNOWN_HOSTS -46 +#define LIBSSH2_ERROR_CHANNEL_WINDOW_FULL -47 +#define LIBSSH2_ERROR_KEYFILE_AUTH_FAILED -48 +#define LIBSSH2_ERROR_RANDGEN -49 +#define LIBSSH2_ERROR_MISSING_USERAUTH_BANNER -50 +#define LIBSSH2_ERROR_ALGO_UNSUPPORTED -51 +#define LIBSSH2_ERROR_MAC_FAILURE -52 +#define LIBSSH2_ERROR_HASH_INIT -53 +#define LIBSSH2_ERROR_HASH_CALC -54 + +/* this is a define to provide the old (<= 1.2.7) name */ +#define LIBSSH2_ERROR_BANNER_NONE LIBSSH2_ERROR_BANNER_RECV + +/* Global API */ +#define LIBSSH2_INIT_NO_CRYPTO 0x0001 + +/* + * libssh2_init() + * + * Initialize the libssh2 functions. This typically initialize the + * crypto library. It uses a global state, and is not thread safe -- + * you must make sure this function is not called concurrently. + * + * Flags can be: + * 0: Normal initialize + * LIBSSH2_INIT_NO_CRYPTO: Do not initialize the crypto library (ie. + * OPENSSL_add_cipher_algoritms() for OpenSSL + * + * Returns 0 if succeeded, or a negative value for error. + */ +LIBSSH2_API int libssh2_init(int flags); + +/* + * libssh2_exit() + * + * Exit the libssh2 functions and free's all memory used internal. + */ +LIBSSH2_API void libssh2_exit(void); + +/* + * libssh2_free() + * + * Deallocate memory allocated by earlier call to libssh2 functions. + */ +LIBSSH2_API void libssh2_free(LIBSSH2_SESSION *session, void *ptr); + +/* + * libssh2_session_supported_algs() + * + * Fills algs with a list of supported acryptographic algorithms. Returns a + * non-negative number (number of supported algorithms) on success or a + * negative number (an error code) on failure. + * + * NOTE: on success, algs must be deallocated (by calling libssh2_free) when + * not needed anymore + */ +LIBSSH2_API int libssh2_session_supported_algs(LIBSSH2_SESSION* session, + int method_type, + const char ***algs); + +/* Session API */ +LIBSSH2_API LIBSSH2_SESSION * +libssh2_session_init_ex(LIBSSH2_ALLOC_FUNC((*my_alloc)), + LIBSSH2_FREE_FUNC((*my_free)), + LIBSSH2_REALLOC_FUNC((*my_realloc)), void *abstract); +#define libssh2_session_init() libssh2_session_init_ex(NULL, NULL, NULL, NULL) + +LIBSSH2_API void **libssh2_session_abstract(LIBSSH2_SESSION *session); + +typedef void (libssh2_cb_generic)(void); + +LIBSSH2_API libssh2_cb_generic * +libssh2_session_callback_set2(LIBSSH2_SESSION *session, int cbtype, + libssh2_cb_generic *callback); + +LIBSSH2_DEPRECATED(1.11.1, "Use libssh2_session_callback_set2()") +LIBSSH2_API void *libssh2_session_callback_set(LIBSSH2_SESSION *session, + int cbtype, void *callback); +LIBSSH2_API int libssh2_session_banner_set(LIBSSH2_SESSION *session, + const char *banner); +#ifndef LIBSSH2_NO_DEPRECATED +LIBSSH2_DEPRECATED(1.4.0, "Use libssh2_session_banner_set()") +LIBSSH2_API int libssh2_banner_set(LIBSSH2_SESSION *session, + const char *banner); + +LIBSSH2_DEPRECATED(1.2.8, "Use libssh2_session_handshake()") +LIBSSH2_API int libssh2_session_startup(LIBSSH2_SESSION *session, int sock); +#endif +LIBSSH2_API int libssh2_session_handshake(LIBSSH2_SESSION *session, + libssh2_socket_t sock); +LIBSSH2_API int libssh2_session_disconnect_ex(LIBSSH2_SESSION *session, + int reason, + const char *description, + const char *lang); +#define libssh2_session_disconnect(session, description) \ + libssh2_session_disconnect_ex((session), SSH_DISCONNECT_BY_APPLICATION, \ + (description), "") + +LIBSSH2_API int libssh2_session_free(LIBSSH2_SESSION *session); + +LIBSSH2_API const char *libssh2_hostkey_hash(LIBSSH2_SESSION *session, + int hash_type); + +LIBSSH2_API const char *libssh2_session_hostkey(LIBSSH2_SESSION *session, + size_t *len, int *type); + +LIBSSH2_API int libssh2_session_method_pref(LIBSSH2_SESSION *session, + int method_type, + const char *prefs); +LIBSSH2_API const char *libssh2_session_methods(LIBSSH2_SESSION *session, + int method_type); +LIBSSH2_API int libssh2_session_last_error(LIBSSH2_SESSION *session, + char **errmsg, + int *errmsg_len, int want_buf); +LIBSSH2_API int libssh2_session_last_errno(LIBSSH2_SESSION *session); +LIBSSH2_API int libssh2_session_set_last_error(LIBSSH2_SESSION* session, + int errcode, + const char *errmsg); +LIBSSH2_API int libssh2_session_block_directions(LIBSSH2_SESSION *session); + +LIBSSH2_API int libssh2_session_flag(LIBSSH2_SESSION *session, int flag, + int value); +LIBSSH2_API const char *libssh2_session_banner_get(LIBSSH2_SESSION *session); + +/* Userauth API */ +LIBSSH2_API char *libssh2_userauth_list(LIBSSH2_SESSION *session, + const char *username, + unsigned int username_len); +LIBSSH2_API int libssh2_userauth_banner(LIBSSH2_SESSION *session, + char **banner); +LIBSSH2_API int libssh2_userauth_authenticated(LIBSSH2_SESSION *session); + +LIBSSH2_API int +libssh2_userauth_password_ex(LIBSSH2_SESSION *session, + const char *username, + unsigned int username_len, + const char *password, + unsigned int password_len, + LIBSSH2_PASSWD_CHANGEREQ_FUNC + ((*passwd_change_cb))); + +#define libssh2_userauth_password(session, username, password) \ + libssh2_userauth_password_ex((session), (username), \ + (unsigned int)strlen(username), \ + (password), (unsigned int)strlen(password), \ + NULL) + +LIBSSH2_API int +libssh2_userauth_publickey_fromfile_ex(LIBSSH2_SESSION *session, + const char *username, + unsigned int username_len, + const char *publickey, + const char *privatekey, + const char *passphrase); + +#define libssh2_userauth_publickey_fromfile(session, username, publickey, \ + privatekey, passphrase) \ + libssh2_userauth_publickey_fromfile_ex((session), (username), \ + (unsigned int)strlen(username), \ + (publickey), \ + (privatekey), (passphrase)) + +LIBSSH2_API int +libssh2_userauth_publickey(LIBSSH2_SESSION *session, + const char *username, + const unsigned char *pubkeydata, + size_t pubkeydata_len, + LIBSSH2_USERAUTH_PUBLICKEY_SIGN_FUNC + ((*sign_callback)), + void **abstract); + +LIBSSH2_API int +libssh2_userauth_hostbased_fromfile_ex(LIBSSH2_SESSION *session, + const char *username, + unsigned int username_len, + const char *publickey, + const char *privatekey, + const char *passphrase, + const char *hostname, + unsigned int hostname_len, + const char *local_username, + unsigned int local_username_len); + +#define libssh2_userauth_hostbased_fromfile(session, username, publickey, \ + privatekey, passphrase, hostname) \ + libssh2_userauth_hostbased_fromfile_ex((session), (username), \ + (unsigned int)strlen(username), \ + (publickey), \ + (privatekey), (passphrase), \ + (hostname), \ + (unsigned int)strlen(hostname), \ + (username), \ + (unsigned int)strlen(username)) + +LIBSSH2_API int +libssh2_userauth_publickey_frommemory(LIBSSH2_SESSION *session, + const char *username, + size_t username_len, + const char *publickeyfiledata, + size_t publickeyfiledata_len, + const char *privatekeyfiledata, + size_t privatekeyfiledata_len, + const char *passphrase); + +/* + * response_callback is provided with filled by library prompts array, + * but client must allocate and fill individual responses. Responses + * array is already allocated. Responses data will be freed by libssh2 + * after callback return, but before subsequent callback invocation. + */ +LIBSSH2_API int +libssh2_userauth_keyboard_interactive_ex(LIBSSH2_SESSION* session, + const char *username, + unsigned int username_len, + LIBSSH2_USERAUTH_KBDINT_RESPONSE_FUNC + ((*response_callback))); + +#define libssh2_userauth_keyboard_interactive(session, username, \ + response_callback) \ + libssh2_userauth_keyboard_interactive_ex((session), (username), \ + (unsigned int)strlen(username), \ + (response_callback)) + +LIBSSH2_API int +libssh2_userauth_publickey_sk(LIBSSH2_SESSION *session, + const char *username, + size_t username_len, + const unsigned char *pubkeydata, + size_t pubkeydata_len, + const char *privatekeydata, + size_t privatekeydata_len, + const char *passphrase, + LIBSSH2_USERAUTH_SK_SIGN_FUNC + ((*sign_callback)), + void **abstract); + +LIBSSH2_API int libssh2_poll(LIBSSH2_POLLFD *fds, unsigned int nfds, + long timeout); + +/* Channel API */ +#define LIBSSH2_CHANNEL_WINDOW_DEFAULT (2*1024*1024) +#define LIBSSH2_CHANNEL_PACKET_DEFAULT 32768 +#define LIBSSH2_CHANNEL_MINADJUST 1024 + +/* Extended Data Handling */ +#define LIBSSH2_CHANNEL_EXTENDED_DATA_NORMAL 0 +#define LIBSSH2_CHANNEL_EXTENDED_DATA_IGNORE 1 +#define LIBSSH2_CHANNEL_EXTENDED_DATA_MERGE 2 + +#define SSH_EXTENDED_DATA_STDERR 1 + +/* Returned by any function that would block during a read/write operation */ +#define LIBSSH2CHANNEL_EAGAIN LIBSSH2_ERROR_EAGAIN + +LIBSSH2_API LIBSSH2_CHANNEL * +libssh2_channel_open_ex(LIBSSH2_SESSION *session, const char *channel_type, + unsigned int channel_type_len, + unsigned int window_size, unsigned int packet_size, + const char *message, unsigned int message_len); + +#define libssh2_channel_open_session(session) \ + libssh2_channel_open_ex((session), "session", sizeof("session") - 1, \ + LIBSSH2_CHANNEL_WINDOW_DEFAULT, \ + LIBSSH2_CHANNEL_PACKET_DEFAULT, NULL, 0) + +LIBSSH2_API LIBSSH2_CHANNEL * +libssh2_channel_direct_tcpip_ex(LIBSSH2_SESSION *session, const char *host, + int port, const char *shost, int sport); +#define libssh2_channel_direct_tcpip(session, host, port) \ + libssh2_channel_direct_tcpip_ex((session), (host), (port), "127.0.0.1", 22) + +LIBSSH2_API LIBSSH2_CHANNEL * +libssh2_channel_direct_streamlocal_ex(LIBSSH2_SESSION * session, + const char *socket_path, + const char *shost, int sport); + +LIBSSH2_API LIBSSH2_LISTENER * +libssh2_channel_forward_listen_ex(LIBSSH2_SESSION *session, const char *host, + int port, int *bound_port, + int queue_maxsize); +#define libssh2_channel_forward_listen(session, port) \ + libssh2_channel_forward_listen_ex((session), NULL, (port), NULL, 16) + +LIBSSH2_API int libssh2_channel_forward_cancel(LIBSSH2_LISTENER *listener); + +LIBSSH2_API LIBSSH2_CHANNEL * +libssh2_channel_forward_accept(LIBSSH2_LISTENER *listener); + +LIBSSH2_API int libssh2_channel_setenv_ex(LIBSSH2_CHANNEL *channel, + const char *varname, + unsigned int varname_len, + const char *value, + unsigned int value_len); + +#define libssh2_channel_setenv(channel, varname, value) \ + libssh2_channel_setenv_ex((channel), (varname), \ + (unsigned int)strlen(varname), (value), \ + (unsigned int)strlen(value)) + +LIBSSH2_API int libssh2_channel_request_auth_agent(LIBSSH2_CHANNEL *channel); + +LIBSSH2_API int libssh2_channel_request_pty_ex(LIBSSH2_CHANNEL *channel, + const char *term, + unsigned int term_len, + const char *modes, + unsigned int modes_len, + int width, int height, + int width_px, int height_px); +#define libssh2_channel_request_pty(channel, term) \ + libssh2_channel_request_pty_ex((channel), (term), \ + (unsigned int)strlen(term), \ + NULL, 0, \ + LIBSSH2_TERM_WIDTH, \ + LIBSSH2_TERM_HEIGHT, \ + LIBSSH2_TERM_WIDTH_PX, \ + LIBSSH2_TERM_HEIGHT_PX) + +LIBSSH2_API int libssh2_channel_request_pty_size_ex(LIBSSH2_CHANNEL *channel, + int width, int height, + int width_px, + int height_px); +#define libssh2_channel_request_pty_size(channel, width, height) \ + libssh2_channel_request_pty_size_ex((channel), (width), (height), 0, 0) + +LIBSSH2_API int libssh2_channel_x11_req_ex(LIBSSH2_CHANNEL *channel, + int single_connection, + const char *auth_proto, + const char *auth_cookie, + int screen_number); +#define libssh2_channel_x11_req(channel, screen_number) \ + libssh2_channel_x11_req_ex((channel), 0, NULL, NULL, (screen_number)) + +LIBSSH2_API int libssh2_channel_signal_ex(LIBSSH2_CHANNEL *channel, + const char *signame, + size_t signame_len); +#define libssh2_channel_signal(channel, signame) \ + libssh2_channel_signal_ex((channel), signame, strlen(signame)) + +LIBSSH2_API int libssh2_channel_process_startup(LIBSSH2_CHANNEL *channel, + const char *request, + unsigned int request_len, + const char *message, + unsigned int message_len); +#define libssh2_channel_shell(channel) \ + libssh2_channel_process_startup((channel), "shell", sizeof("shell") - 1, \ + NULL, 0) +#define libssh2_channel_exec(channel, command) \ + libssh2_channel_process_startup((channel), "exec", sizeof("exec") - 1, \ + (command), (unsigned int)strlen(command)) +#define libssh2_channel_subsystem(channel, subsystem) \ + libssh2_channel_process_startup((channel), "subsystem", \ + sizeof("subsystem") - 1, (subsystem), \ + (unsigned int)strlen(subsystem)) + +LIBSSH2_API ssize_t libssh2_channel_read_ex(LIBSSH2_CHANNEL *channel, + int stream_id, char *buf, + size_t buflen); +#define libssh2_channel_read(channel, buf, buflen) \ + libssh2_channel_read_ex((channel), 0, \ + (buf), (buflen)) +#define libssh2_channel_read_stderr(channel, buf, buflen) \ + libssh2_channel_read_ex((channel), SSH_EXTENDED_DATA_STDERR, \ + (buf), (buflen)) + +LIBSSH2_API int libssh2_poll_channel_read(LIBSSH2_CHANNEL *channel, + int extended); + +LIBSSH2_API unsigned long +libssh2_channel_window_read_ex(LIBSSH2_CHANNEL *channel, + unsigned long *read_avail, + unsigned long *window_size_initial); +#define libssh2_channel_window_read(channel) \ + libssh2_channel_window_read_ex((channel), NULL, NULL) + +#ifndef LIBSSH2_NO_DEPRECATED +LIBSSH2_DEPRECATED(1.1.0, "Use libssh2_channel_receive_window_adjust2()") +LIBSSH2_API unsigned long +libssh2_channel_receive_window_adjust(LIBSSH2_CHANNEL *channel, + unsigned long adjustment, + unsigned char force); +#endif +LIBSSH2_API int +libssh2_channel_receive_window_adjust2(LIBSSH2_CHANNEL *channel, + unsigned long adjustment, + unsigned char force, + unsigned int *storewindow); + +LIBSSH2_API ssize_t libssh2_channel_write_ex(LIBSSH2_CHANNEL *channel, + int stream_id, const char *buf, + size_t buflen); + +#define libssh2_channel_write(channel, buf, buflen) \ + libssh2_channel_write_ex((channel), 0, \ + (buf), (buflen)) +#define libssh2_channel_write_stderr(channel, buf, buflen) \ + libssh2_channel_write_ex((channel), SSH_EXTENDED_DATA_STDERR, \ + (buf), (buflen)) + +LIBSSH2_API unsigned long +libssh2_channel_window_write_ex(LIBSSH2_CHANNEL *channel, + unsigned long *window_size_initial); +#define libssh2_channel_window_write(channel) \ + libssh2_channel_window_write_ex((channel), NULL) + +LIBSSH2_API void libssh2_session_set_blocking(LIBSSH2_SESSION* session, + int blocking); +LIBSSH2_API int libssh2_session_get_blocking(LIBSSH2_SESSION* session); + +LIBSSH2_API void libssh2_channel_set_blocking(LIBSSH2_CHANNEL *channel, + int blocking); + +LIBSSH2_API void libssh2_session_set_timeout(LIBSSH2_SESSION* session, + long timeout); +LIBSSH2_API long libssh2_session_get_timeout(LIBSSH2_SESSION* session); + +LIBSSH2_API void libssh2_session_set_read_timeout(LIBSSH2_SESSION* session, + long timeout); +LIBSSH2_API long libssh2_session_get_read_timeout(LIBSSH2_SESSION* session); + +#ifndef LIBSSH2_NO_DEPRECATED +LIBSSH2_DEPRECATED(1.1.0, "libssh2_channel_handle_extended_data2()") +LIBSSH2_API void libssh2_channel_handle_extended_data(LIBSSH2_CHANNEL *channel, + int ignore_mode); +#endif +LIBSSH2_API int libssh2_channel_handle_extended_data2(LIBSSH2_CHANNEL *channel, + int ignore_mode); + +#ifndef LIBSSH2_NO_DEPRECATED +/* libssh2_channel_ignore_extended_data() is defined below for BC with version + * 0.1 + * + * Future uses should use libssh2_channel_handle_extended_data() directly if + * LIBSSH2_CHANNEL_EXTENDED_DATA_MERGE is passed, extended data will be read + * (FIFO) from the standard data channel + */ +/* DEPRECATED since 0.3.0. Use libssh2_channel_handle_extended_data2(). */ +#define libssh2_channel_ignore_extended_data(channel, ignore) \ + libssh2_channel_handle_extended_data((channel), (ignore) ? \ + LIBSSH2_CHANNEL_EXTENDED_DATA_IGNORE : \ + LIBSSH2_CHANNEL_EXTENDED_DATA_NORMAL) +#endif + +#define LIBSSH2_CHANNEL_FLUSH_EXTENDED_DATA -1 +#define LIBSSH2_CHANNEL_FLUSH_ALL -2 +LIBSSH2_API int libssh2_channel_flush_ex(LIBSSH2_CHANNEL *channel, + int streamid); +#define libssh2_channel_flush(channel) libssh2_channel_flush_ex((channel), 0) +#define libssh2_channel_flush_stderr(channel) \ + libssh2_channel_flush_ex((channel), SSH_EXTENDED_DATA_STDERR) + +LIBSSH2_API int libssh2_channel_get_exit_status(LIBSSH2_CHANNEL* channel); +LIBSSH2_API int libssh2_channel_get_exit_signal(LIBSSH2_CHANNEL* channel, + char **exitsignal, + size_t *exitsignal_len, + char **errmsg, + size_t *errmsg_len, + char **langtag, + size_t *langtag_len); +LIBSSH2_API int libssh2_channel_send_eof(LIBSSH2_CHANNEL *channel); +LIBSSH2_API int libssh2_channel_eof(LIBSSH2_CHANNEL *channel); +LIBSSH2_API int libssh2_channel_wait_eof(LIBSSH2_CHANNEL *channel); +LIBSSH2_API int libssh2_channel_close(LIBSSH2_CHANNEL *channel); +LIBSSH2_API int libssh2_channel_wait_closed(LIBSSH2_CHANNEL *channel); +LIBSSH2_API int libssh2_channel_free(LIBSSH2_CHANNEL *channel); + +#ifndef LIBSSH2_NO_DEPRECATED +LIBSSH2_DEPRECATED(1.7.0, "Use libssh2_scp_recv2()") +LIBSSH2_API LIBSSH2_CHANNEL *libssh2_scp_recv(LIBSSH2_SESSION *session, + const char *path, + struct stat *sb); +#endif +/* Use libssh2_scp_recv2() for large (> 2GB) file support on windows */ +LIBSSH2_API LIBSSH2_CHANNEL *libssh2_scp_recv2(LIBSSH2_SESSION *session, + const char *path, + libssh2_struct_stat *sb); +LIBSSH2_API LIBSSH2_CHANNEL *libssh2_scp_send_ex(LIBSSH2_SESSION *session, + const char *path, int mode, + size_t size, long mtime, + long atime); +LIBSSH2_API LIBSSH2_CHANNEL * +libssh2_scp_send64(LIBSSH2_SESSION *session, const char *path, int mode, + libssh2_int64_t size, time_t mtime, time_t atime); + +#define libssh2_scp_send(session, path, mode, size) \ + libssh2_scp_send_ex((session), (path), (mode), (size), 0, 0) + +/* DEPRECATED */ +LIBSSH2_API int libssh2_base64_decode(LIBSSH2_SESSION *session, char **dest, + unsigned int *dest_len, + const char *src, unsigned int src_len); + +LIBSSH2_API +const char *libssh2_version(int req_version_num); + +typedef enum { + libssh2_no_crypto = 0, + libssh2_openssl, + libssh2_gcrypt, + libssh2_mbedtls, + libssh2_wincng, + libssh2_os400qc3 +} libssh2_crypto_engine_t; + +LIBSSH2_API +libssh2_crypto_engine_t libssh2_crypto_engine(void); + +#define HAVE_LIBSSH2_KNOWNHOST_API 0x010101 /* since 1.1.1 */ +#define HAVE_LIBSSH2_VERSION_API 0x010100 /* libssh2_version since 1.1 */ +#define HAVE_LIBSSH2_CRYPTOENGINE_API 0x011100 /* libssh2_crypto_engine + since 1.11 */ + +struct libssh2_knownhost { + unsigned int magic; /* magic stored by the library */ + void *node; /* handle to the internal representation of this host */ + char *name; /* this is NULL if no plain text host name exists */ + char *key; /* key in base64/printable format */ + int typemask; +}; + +/* + * libssh2_knownhost_init() + * + * Init a collection of known hosts. Returns the pointer to a collection. + * + */ +LIBSSH2_API LIBSSH2_KNOWNHOSTS * +libssh2_knownhost_init(LIBSSH2_SESSION *session); + +/* + * libssh2_knownhost_add() + * + * Add a host and its associated key to the collection of known hosts. + * + * The 'type' argument specifies on what format the given host and keys are: + * + * plain - ascii "hostname.domain.tld" + * sha1 - SHA1( ) base64-encoded! + * custom - another hash + * + * If 'sha1' is selected as type, the salt must be provided to the salt + * argument. This too base64 encoded. + * + * The SHA-1 hash is what OpenSSH can be told to use in known_hosts files. If + * a custom type is used, salt is ignored and you must provide the host + * pre-hashed when checking for it in the libssh2_knownhost_check() function. + * + * The keylen parameter may be omitted (zero) if the key is provided as a + * NULL-terminated base64-encoded string. + */ + +/* host format (2 bits) */ +#define LIBSSH2_KNOWNHOST_TYPE_MASK 0xffff +#define LIBSSH2_KNOWNHOST_TYPE_PLAIN 1 +#define LIBSSH2_KNOWNHOST_TYPE_SHA1 2 /* always base64 encoded */ +#define LIBSSH2_KNOWNHOST_TYPE_CUSTOM 3 + +/* key format (2 bits) */ +#define LIBSSH2_KNOWNHOST_KEYENC_MASK (3<<16) +#define LIBSSH2_KNOWNHOST_KEYENC_RAW (1<<16) +#define LIBSSH2_KNOWNHOST_KEYENC_BASE64 (2<<16) + +/* type of key (4 bits) */ +#define LIBSSH2_KNOWNHOST_KEY_MASK (15<<18) +#define LIBSSH2_KNOWNHOST_KEY_SHIFT 18 +#define LIBSSH2_KNOWNHOST_KEY_RSA1 (1<<18) +#define LIBSSH2_KNOWNHOST_KEY_SSHRSA (2<<18) +#define LIBSSH2_KNOWNHOST_KEY_SSHDSS (3<<18) /* deprecated */ +#define LIBSSH2_KNOWNHOST_KEY_ECDSA_256 (4<<18) +#define LIBSSH2_KNOWNHOST_KEY_ECDSA_384 (5<<18) +#define LIBSSH2_KNOWNHOST_KEY_ECDSA_521 (6<<18) +#define LIBSSH2_KNOWNHOST_KEY_ED25519 (7<<18) +#define LIBSSH2_KNOWNHOST_KEY_UNKNOWN (15<<18) + +LIBSSH2_API int +libssh2_knownhost_add(LIBSSH2_KNOWNHOSTS *hosts, + const char *host, + const char *salt, + const char *key, size_t keylen, int typemask, + struct libssh2_knownhost **store); + +/* + * libssh2_knownhost_addc() + * + * Add a host and its associated key to the collection of known hosts. + * + * Takes a comment argument that may be NULL. A NULL comment indicates + * there is no comment and the entry will end directly after the key + * when written out to a file. An empty string "" comment will indicate an + * empty comment which will cause a single space to be written after the key. + * + * The 'type' argument specifies on what format the given host and keys are: + * + * plain - ascii "hostname.domain.tld" + * sha1 - SHA1( ) base64-encoded! + * custom - another hash + * + * If 'sha1' is selected as type, the salt must be provided to the salt + * argument. This too base64 encoded. + * + * The SHA-1 hash is what OpenSSH can be told to use in known_hosts files. + * If a custom type is used, salt is ignored and you must provide the host + * pre-hashed when checking for it in the libssh2_knownhost_check() function. + * + * The keylen parameter may be omitted (zero) if the key is provided as a + * NULL-terminated base64-encoded string. + */ + +LIBSSH2_API int +libssh2_knownhost_addc(LIBSSH2_KNOWNHOSTS *hosts, + const char *host, + const char *salt, + const char *key, size_t keylen, + const char *comment, size_t commentlen, int typemask, + struct libssh2_knownhost **store); + +/* + * libssh2_knownhost_check() + * + * Check a host and its associated key against the collection of known hosts. + * + * The type is the type/format of the given host name. + * + * plain - ascii "hostname.domain.tld" + * custom - prehashed base64 encoded. Note that this cannot use any salts. + * + * + * 'knownhost' may be set to NULL if you don't care about that info. + * + * Returns: + * + * LIBSSH2_KNOWNHOST_CHECK_* values, see below + * + */ + +#define LIBSSH2_KNOWNHOST_CHECK_MATCH 0 +#define LIBSSH2_KNOWNHOST_CHECK_MISMATCH 1 +#define LIBSSH2_KNOWNHOST_CHECK_NOTFOUND 2 +#define LIBSSH2_KNOWNHOST_CHECK_FAILURE 3 + +LIBSSH2_API int +libssh2_knownhost_check(LIBSSH2_KNOWNHOSTS *hosts, + const char *host, const char *key, size_t keylen, + int typemask, + struct libssh2_knownhost **knownhost); + +/* this function is identital to the above one, but also takes a port + argument that allows libssh2 to do a better check */ +LIBSSH2_API int +libssh2_knownhost_checkp(LIBSSH2_KNOWNHOSTS *hosts, + const char *host, int port, + const char *key, size_t keylen, + int typemask, + struct libssh2_knownhost **knownhost); + +/* + * libssh2_knownhost_del() + * + * Remove a host from the collection of known hosts. The 'entry' struct is + * retrieved by a call to libssh2_knownhost_check(). + * + */ +LIBSSH2_API int +libssh2_knownhost_del(LIBSSH2_KNOWNHOSTS *hosts, + struct libssh2_knownhost *entry); + +/* + * libssh2_knownhost_free() + * + * Free an entire collection of known hosts. + * + */ +LIBSSH2_API void +libssh2_knownhost_free(LIBSSH2_KNOWNHOSTS *hosts); + +/* + * libssh2_knownhost_readline() + * + * Pass in a line of a file of 'type'. It makes libssh2 read this line. + * + * LIBSSH2_KNOWNHOST_FILE_OPENSSH is the only supported type. + * + */ +LIBSSH2_API int +libssh2_knownhost_readline(LIBSSH2_KNOWNHOSTS *hosts, + const char *line, size_t len, int type); + +/* + * libssh2_knownhost_readfile() + * + * Add hosts+key pairs from a given file. + * + * Returns a negative value for error or number of successfully added hosts. + * + * This implementation currently only knows one 'type' (openssh), all others + * are reserved for future use. + */ + +#define LIBSSH2_KNOWNHOST_FILE_OPENSSH 1 + +LIBSSH2_API int +libssh2_knownhost_readfile(LIBSSH2_KNOWNHOSTS *hosts, + const char *filename, int type); + +/* + * libssh2_knownhost_writeline() + * + * Ask libssh2 to convert a known host to an output line for storage. + * + * Note that this function returns LIBSSH2_ERROR_BUFFER_TOO_SMALL if the given + * output buffer is too small to hold the desired output. + * + * This implementation currently only knows one 'type' (openssh), all others + * are reserved for future use. + * + */ +LIBSSH2_API int +libssh2_knownhost_writeline(LIBSSH2_KNOWNHOSTS *hosts, + struct libssh2_knownhost *known, + char *buffer, size_t buflen, + size_t *outlen, /* the amount of written data */ + int type); + +/* + * libssh2_knownhost_writefile() + * + * Write hosts+key pairs to a given file. + * + * This implementation currently only knows one 'type' (openssh), all others + * are reserved for future use. + */ + +LIBSSH2_API int +libssh2_knownhost_writefile(LIBSSH2_KNOWNHOSTS *hosts, + const char *filename, int type); + +/* + * libssh2_knownhost_get() + * + * Traverse the internal list of known hosts. Pass NULL to 'prev' to get + * the first one. Or pass a pointer to the previously returned one to get the + * next. + * + * Returns: + * 0 if a fine host was stored in 'store' + * 1 if end of hosts + * [negative] on errors + */ +LIBSSH2_API int +libssh2_knownhost_get(LIBSSH2_KNOWNHOSTS *hosts, + struct libssh2_knownhost **store, + struct libssh2_knownhost *prev); + +#define HAVE_LIBSSH2_AGENT_API 0x010202 /* since 1.2.2 */ + +struct libssh2_agent_publickey { + unsigned int magic; /* magic stored by the library */ + void *node; /* handle to the internal representation of key */ + unsigned char *blob; /* public key blob */ + size_t blob_len; /* length of the public key blob */ + char *comment; /* comment in printable format */ +}; + +/* + * libssh2_agent_init() + * + * Init an ssh-agent handle. Returns the pointer to the handle. + * + */ +LIBSSH2_API LIBSSH2_AGENT * +libssh2_agent_init(LIBSSH2_SESSION *session); + +/* + * libssh2_agent_connect() + * + * Connect to an ssh-agent. + * + * Returns 0 if succeeded, or a negative value for error. + */ +LIBSSH2_API int +libssh2_agent_connect(LIBSSH2_AGENT *agent); + +/* + * libssh2_agent_list_identities() + * + * Request an ssh-agent to list identities. + * + * Returns 0 if succeeded, or a negative value for error. + */ +LIBSSH2_API int +libssh2_agent_list_identities(LIBSSH2_AGENT *agent); + +/* + * libssh2_agent_get_identity() + * + * Traverse the internal list of public keys. Pass NULL to 'prev' to get + * the first one. Or pass a pointer to the previously returned one to get the + * next. + * + * Returns: + * 0 if a fine public key was stored in 'store' + * 1 if end of public keys + * [negative] on errors + */ +LIBSSH2_API int +libssh2_agent_get_identity(LIBSSH2_AGENT *agent, + struct libssh2_agent_publickey **store, + struct libssh2_agent_publickey *prev); + +/* + * libssh2_agent_userauth() + * + * Do publickey user authentication with the help of ssh-agent. + * + * Returns 0 if succeeded, or a negative value for error. + */ +LIBSSH2_API int +libssh2_agent_userauth(LIBSSH2_AGENT *agent, + const char *username, + struct libssh2_agent_publickey *identity); + +/* + * libssh2_agent_sign() + * + * Sign a payload using a system-installed ssh-agent. + * + * Returns 0 if succeeded, or a negative value for error. + */ +LIBSSH2_API int +libssh2_agent_sign(LIBSSH2_AGENT *agent, + struct libssh2_agent_publickey *identity, + unsigned char **sig, + size_t *s_len, + const unsigned char *data, + size_t d_len, + const char *method, + unsigned int method_len); + +/* + * libssh2_agent_disconnect() + * + * Close a connection to an ssh-agent. + * + * Returns 0 if succeeded, or a negative value for error. + */ +LIBSSH2_API int +libssh2_agent_disconnect(LIBSSH2_AGENT *agent); + +/* + * libssh2_agent_free() + * + * Free an ssh-agent handle. This function also frees the internal + * collection of public keys. + */ +LIBSSH2_API void +libssh2_agent_free(LIBSSH2_AGENT *agent); + +/* + * libssh2_agent_set_identity_path() + * + * Allows a custom agent identity socket path beyond SSH_AUTH_SOCK env + * + */ +LIBSSH2_API void +libssh2_agent_set_identity_path(LIBSSH2_AGENT *agent, + const char *path); + +/* + * libssh2_agent_get_identity_path() + * + * Returns the custom agent identity socket path if set + * + */ +LIBSSH2_API const char * +libssh2_agent_get_identity_path(LIBSSH2_AGENT *agent); + +/* + * libssh2_keepalive_config() + * + * Set how often keepalive messages should be sent. WANT_REPLY + * indicates whether the keepalive messages should request a response + * from the server. INTERVAL is number of seconds that can pass + * without any I/O, use 0 (the default) to disable keepalives. To + * avoid some busy-loop corner-cases, if you specify an interval of 1 + * it will be treated as 2. + * + * Note that non-blocking applications are responsible for sending the + * keepalive messages using libssh2_keepalive_send(). + */ +LIBSSH2_API void libssh2_keepalive_config(LIBSSH2_SESSION *session, + int want_reply, + unsigned int interval); + +/* + * libssh2_keepalive_send() + * + * Send a keepalive message if needed. SECONDS_TO_NEXT indicates how + * many seconds you can sleep after this call before you need to call + * it again. Returns 0 on success, or LIBSSH2_ERROR_SOCKET_SEND on + * I/O errors. + */ +LIBSSH2_API int libssh2_keepalive_send(LIBSSH2_SESSION *session, + int *seconds_to_next); + +/* NOTE NOTE NOTE + libssh2_trace() has no function in builds that aren't built with debug + enabled + */ +LIBSSH2_API int libssh2_trace(LIBSSH2_SESSION *session, int bitmask); +#define LIBSSH2_TRACE_TRANS (1<<1) +#define LIBSSH2_TRACE_KEX (1<<2) +#define LIBSSH2_TRACE_AUTH (1<<3) +#define LIBSSH2_TRACE_CONN (1<<4) +#define LIBSSH2_TRACE_SCP (1<<5) +#define LIBSSH2_TRACE_SFTP (1<<6) +#define LIBSSH2_TRACE_ERROR (1<<7) +#define LIBSSH2_TRACE_PUBLICKEY (1<<8) +#define LIBSSH2_TRACE_SOCKET (1<<9) + +typedef void (*libssh2_trace_handler_func)(LIBSSH2_SESSION*, + void *, + const char *, + size_t); +LIBSSH2_API int libssh2_trace_sethandler(LIBSSH2_SESSION *session, + void *context, + libssh2_trace_handler_func callback); + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif /* !RC_INVOKED */ + +#endif /* LIBSSH2_H */ diff --git a/TablePro/Core/SSH/CLibSSH2/include/libssh2_publickey.h b/TablePro/Core/SSH/CLibSSH2/include/libssh2_publickey.h new file mode 100644 index 000000000..566acd653 --- /dev/null +++ b/TablePro/Core/SSH/CLibSSH2/include/libssh2_publickey.h @@ -0,0 +1,128 @@ +/* Copyright (C) Sara Golemon + * All rights reserved. + * + * Redistribution and use in source and binary forms, + * with or without modification, are permitted provided + * that the following conditions are met: + * + * Redistributions of source code must retain the above + * copyright notice, this list of conditions and the + * following disclaimer. + * + * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * Neither the name of the copyright holder nor the names + * of any other contributors may be used to endorse or + * promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY + * OF SUCH DAMAGE. + */ + +/* Note: This include file is only needed for using the + * publickey SUBSYSTEM which is not the same as publickey + * authentication. For authentication you only need libssh2.h + * + * For more information on the publickey subsystem, + * refer to IETF draft: secsh-publickey + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +#ifndef LIBSSH2_PUBLICKEY_H +#define LIBSSH2_PUBLICKEY_H 1 + +#include "libssh2.h" + +typedef struct _LIBSSH2_PUBLICKEY LIBSSH2_PUBLICKEY; + +typedef struct _libssh2_publickey_attribute { + const char *name; + unsigned long name_len; + const char *value; + unsigned long value_len; + char mandatory; +} libssh2_publickey_attribute; + +typedef struct _libssh2_publickey_list { + unsigned char *packet; /* For freeing */ + + const unsigned char *name; + unsigned long name_len; + const unsigned char *blob; + unsigned long blob_len; + unsigned long num_attrs; + libssh2_publickey_attribute *attrs; /* free me */ +} libssh2_publickey_list; + +/* Generally use the first macro here, but if both name and value are string + literals, you can use _fast() to take advantage of preprocessing */ +#define libssh2_publickey_attribute(name, value, mandatory) \ + { (name), strlen(name), (value), strlen(value), (mandatory) }, +#define libssh2_publickey_attribute_fast(name, value, mandatory) \ + { (name), sizeof(name) - 1, (value), sizeof(value) - 1, (mandatory) }, + +#ifdef __cplusplus +extern "C" { +#endif + +/* Publickey Subsystem */ +LIBSSH2_API LIBSSH2_PUBLICKEY * +libssh2_publickey_init(LIBSSH2_SESSION *session); + +LIBSSH2_API int +libssh2_publickey_add_ex(LIBSSH2_PUBLICKEY *pkey, + const unsigned char *name, + unsigned long name_len, + const unsigned char *blob, + unsigned long blob_len, char overwrite, + unsigned long num_attrs, + const libssh2_publickey_attribute attrs[]); +#define libssh2_publickey_add(pkey, name, blob, blob_len, overwrite, \ + num_attrs, attrs) \ + libssh2_publickey_add_ex((pkey), \ + (name), strlen(name), \ + (blob), (blob_len), \ + (overwrite), (num_attrs), (attrs)) + +LIBSSH2_API int libssh2_publickey_remove_ex(LIBSSH2_PUBLICKEY *pkey, + const unsigned char *name, + unsigned long name_len, + const unsigned char *blob, + unsigned long blob_len); +#define libssh2_publickey_remove(pkey, name, blob, blob_len) \ + libssh2_publickey_remove_ex((pkey), \ + (name), strlen(name), \ + (blob), (blob_len)) + +LIBSSH2_API int +libssh2_publickey_list_fetch(LIBSSH2_PUBLICKEY *pkey, + unsigned long *num_keys, + libssh2_publickey_list **pkey_list); +LIBSSH2_API void +libssh2_publickey_list_free(LIBSSH2_PUBLICKEY *pkey, + libssh2_publickey_list *pkey_list); + +LIBSSH2_API int libssh2_publickey_shutdown(LIBSSH2_PUBLICKEY *pkey); + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif /* LIBSSH2_PUBLICKEY_H */ diff --git a/TablePro/Core/SSH/CLibSSH2/include/libssh2_sftp.h b/TablePro/Core/SSH/CLibSSH2/include/libssh2_sftp.h new file mode 100644 index 000000000..ab7b0af4d --- /dev/null +++ b/TablePro/Core/SSH/CLibSSH2/include/libssh2_sftp.h @@ -0,0 +1,382 @@ +/* Copyright (C) Sara Golemon + * All rights reserved. + * + * Redistribution and use in source and binary forms, + * with or without modification, are permitted provided + * that the following conditions are met: + * + * Redistributions of source code must retain the above + * copyright notice, this list of conditions and the + * following disclaimer. + * + * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * Neither the name of the copyright holder nor the names + * of any other contributors may be used to endorse or + * promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY + * OF SUCH DAMAGE. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +#ifndef LIBSSH2_SFTP_H +#define LIBSSH2_SFTP_H 1 + +#include "libssh2.h" + +#ifndef _WIN32 +#include +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* Note: Version 6 was documented at the time of writing + * However it was marked as "DO NOT IMPLEMENT" due to pending changes + * + * Let's start with Version 3 (The version found in OpenSSH) and go from there + */ +#define LIBSSH2_SFTP_VERSION 3 + +typedef struct _LIBSSH2_SFTP LIBSSH2_SFTP; +typedef struct _LIBSSH2_SFTP_HANDLE LIBSSH2_SFTP_HANDLE; +typedef struct _LIBSSH2_SFTP_ATTRIBUTES LIBSSH2_SFTP_ATTRIBUTES; +typedef struct _LIBSSH2_SFTP_STATVFS LIBSSH2_SFTP_STATVFS; + +/* Flags for open_ex() */ +#define LIBSSH2_SFTP_OPENFILE 0 +#define LIBSSH2_SFTP_OPENDIR 1 + +/* Flags for rename_ex() */ +#define LIBSSH2_SFTP_RENAME_OVERWRITE 0x00000001 +#define LIBSSH2_SFTP_RENAME_ATOMIC 0x00000002 +#define LIBSSH2_SFTP_RENAME_NATIVE 0x00000004 + +/* Flags for stat_ex() */ +#define LIBSSH2_SFTP_STAT 0 +#define LIBSSH2_SFTP_LSTAT 1 +#define LIBSSH2_SFTP_SETSTAT 2 + +/* Flags for symlink_ex() */ +#define LIBSSH2_SFTP_SYMLINK 0 +#define LIBSSH2_SFTP_READLINK 1 +#define LIBSSH2_SFTP_REALPATH 2 + +/* Flags for sftp_mkdir() */ +#define LIBSSH2_SFTP_DEFAULT_MODE -1 + +/* SFTP attribute flag bits */ +#define LIBSSH2_SFTP_ATTR_SIZE 0x00000001 +#define LIBSSH2_SFTP_ATTR_UIDGID 0x00000002 +#define LIBSSH2_SFTP_ATTR_PERMISSIONS 0x00000004 +#define LIBSSH2_SFTP_ATTR_ACMODTIME 0x00000008 +#define LIBSSH2_SFTP_ATTR_EXTENDED 0x80000000 + +/* SFTP statvfs flag bits */ +#define LIBSSH2_SFTP_ST_RDONLY 0x00000001 +#define LIBSSH2_SFTP_ST_NOSUID 0x00000002 + +struct _LIBSSH2_SFTP_ATTRIBUTES { + /* If flags & ATTR_* bit is set, then the value in this struct will be + * meaningful Otherwise it should be ignored + */ + unsigned long flags; + + libssh2_uint64_t filesize; + unsigned long uid, gid; + unsigned long permissions; + unsigned long atime, mtime; +}; + +struct _LIBSSH2_SFTP_STATVFS { + libssh2_uint64_t f_bsize; /* file system block size */ + libssh2_uint64_t f_frsize; /* fragment size */ + libssh2_uint64_t f_blocks; /* size of fs in f_frsize units */ + libssh2_uint64_t f_bfree; /* # free blocks */ + libssh2_uint64_t f_bavail; /* # free blocks for non-root */ + libssh2_uint64_t f_files; /* # inodes */ + libssh2_uint64_t f_ffree; /* # free inodes */ + libssh2_uint64_t f_favail; /* # free inodes for non-root */ + libssh2_uint64_t f_fsid; /* file system ID */ + libssh2_uint64_t f_flag; /* mount flags */ + libssh2_uint64_t f_namemax; /* maximum filename length */ +}; + +/* SFTP filetypes */ +#define LIBSSH2_SFTP_TYPE_REGULAR 1 +#define LIBSSH2_SFTP_TYPE_DIRECTORY 2 +#define LIBSSH2_SFTP_TYPE_SYMLINK 3 +#define LIBSSH2_SFTP_TYPE_SPECIAL 4 +#define LIBSSH2_SFTP_TYPE_UNKNOWN 5 +#define LIBSSH2_SFTP_TYPE_SOCKET 6 +#define LIBSSH2_SFTP_TYPE_CHAR_DEVICE 7 +#define LIBSSH2_SFTP_TYPE_BLOCK_DEVICE 8 +#define LIBSSH2_SFTP_TYPE_FIFO 9 + +/* + * Reproduce the POSIX file modes here for systems that are not POSIX + * compliant. + * + * These is used in "permissions" of "struct _LIBSSH2_SFTP_ATTRIBUTES" + */ +/* File type */ +#define LIBSSH2_SFTP_S_IFMT 0170000 /* type of file mask */ +#define LIBSSH2_SFTP_S_IFIFO 0010000 /* named pipe (fifo) */ +#define LIBSSH2_SFTP_S_IFCHR 0020000 /* character special */ +#define LIBSSH2_SFTP_S_IFDIR 0040000 /* directory */ +#define LIBSSH2_SFTP_S_IFBLK 0060000 /* block special */ +#define LIBSSH2_SFTP_S_IFREG 0100000 /* regular */ +#define LIBSSH2_SFTP_S_IFLNK 0120000 /* symbolic link */ +#define LIBSSH2_SFTP_S_IFSOCK 0140000 /* socket */ + +/* File mode */ +/* Read, write, execute/search by owner */ +#define LIBSSH2_SFTP_S_IRWXU 0000700 /* RWX mask for owner */ +#define LIBSSH2_SFTP_S_IRUSR 0000400 /* R for owner */ +#define LIBSSH2_SFTP_S_IWUSR 0000200 /* W for owner */ +#define LIBSSH2_SFTP_S_IXUSR 0000100 /* X for owner */ +/* Read, write, execute/search by group */ +#define LIBSSH2_SFTP_S_IRWXG 0000070 /* RWX mask for group */ +#define LIBSSH2_SFTP_S_IRGRP 0000040 /* R for group */ +#define LIBSSH2_SFTP_S_IWGRP 0000020 /* W for group */ +#define LIBSSH2_SFTP_S_IXGRP 0000010 /* X for group */ +/* Read, write, execute/search by others */ +#define LIBSSH2_SFTP_S_IRWXO 0000007 /* RWX mask for other */ +#define LIBSSH2_SFTP_S_IROTH 0000004 /* R for other */ +#define LIBSSH2_SFTP_S_IWOTH 0000002 /* W for other */ +#define LIBSSH2_SFTP_S_IXOTH 0000001 /* X for other */ + +/* macros to check for specific file types, added in 1.2.5 */ +#define LIBSSH2_SFTP_S_ISLNK(m) \ + (((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFLNK) +#define LIBSSH2_SFTP_S_ISREG(m) \ + (((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFREG) +#define LIBSSH2_SFTP_S_ISDIR(m) \ + (((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFDIR) +#define LIBSSH2_SFTP_S_ISCHR(m) \ + (((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFCHR) +#define LIBSSH2_SFTP_S_ISBLK(m) \ + (((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFBLK) +#define LIBSSH2_SFTP_S_ISFIFO(m) \ + (((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFIFO) +#define LIBSSH2_SFTP_S_ISSOCK(m) \ + (((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFSOCK) + +/* SFTP File Transfer Flags -- (e.g. flags parameter to sftp_open()) + * Danger will robinson... APPEND doesn't have any effect on OpenSSH servers */ +#define LIBSSH2_FXF_READ 0x00000001 +#define LIBSSH2_FXF_WRITE 0x00000002 +#define LIBSSH2_FXF_APPEND 0x00000004 +#define LIBSSH2_FXF_CREAT 0x00000008 +#define LIBSSH2_FXF_TRUNC 0x00000010 +#define LIBSSH2_FXF_EXCL 0x00000020 + +/* SFTP Status Codes (returned by libssh2_sftp_last_error() ) */ +#define LIBSSH2_FX_OK 0UL +#define LIBSSH2_FX_EOF 1UL +#define LIBSSH2_FX_NO_SUCH_FILE 2UL +#define LIBSSH2_FX_PERMISSION_DENIED 3UL +#define LIBSSH2_FX_FAILURE 4UL +#define LIBSSH2_FX_BAD_MESSAGE 5UL +#define LIBSSH2_FX_NO_CONNECTION 6UL +#define LIBSSH2_FX_CONNECTION_LOST 7UL +#define LIBSSH2_FX_OP_UNSUPPORTED 8UL +#define LIBSSH2_FX_INVALID_HANDLE 9UL +#define LIBSSH2_FX_NO_SUCH_PATH 10UL +#define LIBSSH2_FX_FILE_ALREADY_EXISTS 11UL +#define LIBSSH2_FX_WRITE_PROTECT 12UL +#define LIBSSH2_FX_NO_MEDIA 13UL +#define LIBSSH2_FX_NO_SPACE_ON_FILESYSTEM 14UL +#define LIBSSH2_FX_QUOTA_EXCEEDED 15UL +#define LIBSSH2_FX_UNKNOWN_PRINCIPLE 16UL /* Initial mis-spelling */ +#define LIBSSH2_FX_UNKNOWN_PRINCIPAL 16UL +#define LIBSSH2_FX_LOCK_CONFlICT 17UL /* Initial mis-spelling */ +#define LIBSSH2_FX_LOCK_CONFLICT 17UL +#define LIBSSH2_FX_DIR_NOT_EMPTY 18UL +#define LIBSSH2_FX_NOT_A_DIRECTORY 19UL +#define LIBSSH2_FX_INVALID_FILENAME 20UL +#define LIBSSH2_FX_LINK_LOOP 21UL + +/* Returned by any function that would block during a read/write operation */ +#define LIBSSH2SFTP_EAGAIN LIBSSH2_ERROR_EAGAIN + +/* SFTP API */ +LIBSSH2_API LIBSSH2_SFTP *libssh2_sftp_init(LIBSSH2_SESSION *session); +LIBSSH2_API int libssh2_sftp_shutdown(LIBSSH2_SFTP *sftp); +LIBSSH2_API unsigned long libssh2_sftp_last_error(LIBSSH2_SFTP *sftp); +LIBSSH2_API LIBSSH2_CHANNEL *libssh2_sftp_get_channel(LIBSSH2_SFTP *sftp); + +/* File / Directory Ops */ +LIBSSH2_API LIBSSH2_SFTP_HANDLE * +libssh2_sftp_open_ex(LIBSSH2_SFTP *sftp, + const char *filename, + unsigned int filename_len, + unsigned long flags, + long mode, int open_type); +#define libssh2_sftp_open(sftp, filename, flags, mode) \ + libssh2_sftp_open_ex((sftp), \ + (filename), (unsigned int)strlen(filename), \ + (flags), (mode), LIBSSH2_SFTP_OPENFILE) +#define libssh2_sftp_opendir(sftp, path) \ + libssh2_sftp_open_ex((sftp), \ + (path), (unsigned int)strlen(path), \ + 0, 0, LIBSSH2_SFTP_OPENDIR) +LIBSSH2_API LIBSSH2_SFTP_HANDLE * +libssh2_sftp_open_ex_r(LIBSSH2_SFTP *sftp, + const char *filename, + size_t filename_len, + unsigned long flags, + long mode, int open_type, + LIBSSH2_SFTP_ATTRIBUTES *attrs); +#define libssh2_sftp_open_r(sftp, filename, flags, mode, attrs) \ + libssh2_sftp_open_ex_r((sftp), (filename), strlen(filename), \ + (flags), (mode), LIBSSH2_SFTP_OPENFILE, \ + (attrs)) + +LIBSSH2_API ssize_t libssh2_sftp_read(LIBSSH2_SFTP_HANDLE *handle, + char *buffer, size_t buffer_maxlen); + +LIBSSH2_API int libssh2_sftp_readdir_ex(LIBSSH2_SFTP_HANDLE *handle, \ + char *buffer, size_t buffer_maxlen, + char *longentry, + size_t longentry_maxlen, + LIBSSH2_SFTP_ATTRIBUTES *attrs); +#define libssh2_sftp_readdir(handle, buffer, buffer_maxlen, attrs) \ + libssh2_sftp_readdir_ex((handle), (buffer), (buffer_maxlen), NULL, 0, \ + (attrs)) + +LIBSSH2_API ssize_t libssh2_sftp_write(LIBSSH2_SFTP_HANDLE *handle, + const char *buffer, size_t count); +LIBSSH2_API int libssh2_sftp_fsync(LIBSSH2_SFTP_HANDLE *handle); + +LIBSSH2_API int libssh2_sftp_close_handle(LIBSSH2_SFTP_HANDLE *handle); +#define libssh2_sftp_close(handle) libssh2_sftp_close_handle(handle) +#define libssh2_sftp_closedir(handle) libssh2_sftp_close_handle(handle) + +LIBSSH2_API void libssh2_sftp_seek(LIBSSH2_SFTP_HANDLE *handle, size_t offset); +LIBSSH2_API void libssh2_sftp_seek64(LIBSSH2_SFTP_HANDLE *handle, + libssh2_uint64_t offset); +#define libssh2_sftp_rewind(handle) libssh2_sftp_seek64((handle), 0) + +LIBSSH2_API size_t libssh2_sftp_tell(LIBSSH2_SFTP_HANDLE *handle); +LIBSSH2_API libssh2_uint64_t libssh2_sftp_tell64(LIBSSH2_SFTP_HANDLE *handle); + +LIBSSH2_API int libssh2_sftp_fstat_ex(LIBSSH2_SFTP_HANDLE *handle, + LIBSSH2_SFTP_ATTRIBUTES *attrs, + int setstat); +#define libssh2_sftp_fstat(handle, attrs) \ + libssh2_sftp_fstat_ex((handle), (attrs), 0) +#define libssh2_sftp_fsetstat(handle, attrs) \ + libssh2_sftp_fstat_ex((handle), (attrs), 1) + +/* Miscellaneous Ops */ +LIBSSH2_API int libssh2_sftp_rename_ex(LIBSSH2_SFTP *sftp, + const char *source_filename, + unsigned int srouce_filename_len, + const char *dest_filename, + unsigned int dest_filename_len, + long flags); +#define libssh2_sftp_rename(sftp, sourcefile, destfile) \ + libssh2_sftp_rename_ex((sftp), \ + (sourcefile), (unsigned int)strlen(sourcefile), \ + (destfile), (unsigned int)strlen(destfile), \ + LIBSSH2_SFTP_RENAME_OVERWRITE | \ + LIBSSH2_SFTP_RENAME_ATOMIC | \ + LIBSSH2_SFTP_RENAME_NATIVE) + +LIBSSH2_API int libssh2_sftp_posix_rename_ex(LIBSSH2_SFTP *sftp, + const char *source_filename, + size_t srouce_filename_len, + const char *dest_filename, + size_t dest_filename_len); +#define libssh2_sftp_posix_rename(sftp, sourcefile, destfile) \ + libssh2_sftp_posix_rename_ex((sftp), (sourcefile), strlen(sourcefile), \ + (destfile), strlen(destfile)) + +LIBSSH2_API int libssh2_sftp_unlink_ex(LIBSSH2_SFTP *sftp, + const char *filename, + unsigned int filename_len); +#define libssh2_sftp_unlink(sftp, filename) \ + libssh2_sftp_unlink_ex((sftp), (filename), (unsigned int)strlen(filename)) + +LIBSSH2_API int libssh2_sftp_fstatvfs(LIBSSH2_SFTP_HANDLE *handle, + LIBSSH2_SFTP_STATVFS *st); + +LIBSSH2_API int libssh2_sftp_statvfs(LIBSSH2_SFTP *sftp, + const char *path, + size_t path_len, + LIBSSH2_SFTP_STATVFS *st); + +LIBSSH2_API int libssh2_sftp_mkdir_ex(LIBSSH2_SFTP *sftp, + const char *path, + unsigned int path_len, long mode); +#define libssh2_sftp_mkdir(sftp, path, mode) \ + libssh2_sftp_mkdir_ex((sftp), (path), (unsigned int)strlen(path), (mode)) + +LIBSSH2_API int libssh2_sftp_rmdir_ex(LIBSSH2_SFTP *sftp, + const char *path, + unsigned int path_len); +#define libssh2_sftp_rmdir(sftp, path) \ + libssh2_sftp_rmdir_ex((sftp), (path), (unsigned int)strlen(path)) + +LIBSSH2_API int libssh2_sftp_stat_ex(LIBSSH2_SFTP *sftp, + const char *path, + unsigned int path_len, + int stat_type, + LIBSSH2_SFTP_ATTRIBUTES *attrs); +#define libssh2_sftp_stat(sftp, path, attrs) \ + libssh2_sftp_stat_ex((sftp), (path), (unsigned int)strlen(path), \ + LIBSSH2_SFTP_STAT, (attrs)) +#define libssh2_sftp_lstat(sftp, path, attrs) \ + libssh2_sftp_stat_ex((sftp), (path), (unsigned int)strlen(path), \ + LIBSSH2_SFTP_LSTAT, (attrs)) +#define libssh2_sftp_setstat(sftp, path, attrs) \ + libssh2_sftp_stat_ex((sftp), (path), (unsigned int)strlen(path), \ + LIBSSH2_SFTP_SETSTAT, (attrs)) + +LIBSSH2_API int libssh2_sftp_symlink_ex(LIBSSH2_SFTP *sftp, + const char *path, + unsigned int path_len, + char *target, + unsigned int target_len, + int link_type); +#define libssh2_sftp_symlink(sftp, orig, linkpath) \ + libssh2_sftp_symlink_ex((sftp), \ + (orig), (unsigned int)strlen(orig), \ + (linkpath), (unsigned int)strlen(linkpath), \ + LIBSSH2_SFTP_SYMLINK) +#define libssh2_sftp_readlink(sftp, path, target, maxlen) \ + libssh2_sftp_symlink_ex((sftp), \ + (path), (unsigned int)strlen(path), \ + (target), (maxlen), \ + LIBSSH2_SFTP_READLINK) +#define libssh2_sftp_realpath(sftp, path, target, maxlen) \ + libssh2_sftp_symlink_ex((sftp), \ + (path), (unsigned int)strlen(path), \ + (target), (maxlen), \ + LIBSSH2_SFTP_REALPATH) + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif /* LIBSSH2_SFTP_H */ diff --git a/TablePro/Core/SSH/CLibSSH2/module.modulemap b/TablePro/Core/SSH/CLibSSH2/module.modulemap new file mode 100644 index 000000000..3a64cb241 --- /dev/null +++ b/TablePro/Core/SSH/CLibSSH2/module.modulemap @@ -0,0 +1,4 @@ +module CLibSSH2 [system] { + header "CLibSSH2.h" + export * +} diff --git a/TablePro/Core/SSH/HostKeyStore.swift b/TablePro/Core/SSH/HostKeyStore.swift new file mode 100644 index 000000000..a124f741a --- /dev/null +++ b/TablePro/Core/SSH/HostKeyStore.swift @@ -0,0 +1,195 @@ +// +// HostKeyStore.swift +// TablePro +// +// Manages SSH host key verification for known hosts. +// Stores trusted host keys in a line-based file at +// ~/Library/Application Support/TablePro/known_hosts +// + +import CryptoKit +import Foundation +import os + +/// Manages SSH host key verification for known hosts +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() { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let tableProDir = appSupport.appendingPathComponent("TablePro") + + try? FileManager.default.createDirectory(at: tableProDir, withIntermediateDirectories: true) + + self.filePath = tableProDir.appendingPathComponent("known_hosts").path + } + + /// Testing initializer with a custom file path + init(filePath: String) { + self.filePath = filePath + } + + // MARK: - Public API + + /// Verify a host key against the known_hosts store + /// - Parameters: + /// - keyData: The raw host key bytes + /// - keyType: The key type string (e.g. "ssh-rsa", "ssh-ed25519") + /// - hostname: The remote hostname + /// - port: The remote port + /// - Returns: The verification result + 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() + + guard let existing = entries.first(where: { $0.host == hostKey }) 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 { + Self.logger.debug("Host key trusted for \(hostKey)") + return .trusted + } + + Self.logger.warning("Host key mismatch for \(hostKey)") + return .mismatch(expected: storedFingerprint, actual: currentFingerprint) + } + + /// Add or update a trusted host key + /// - Parameters: + /// - hostname: The remote hostname + /// - port: The remote port + /// - key: The raw host key bytes + /// - keyType: The key type string + func trust(hostname: String, port: Int, key: Data, keyType: String) { + lock.lock() + defer { lock.unlock() } + + let hostKey = hostIdentifier(hostname, port) + var entries = loadEntries() + + // Remove existing entry for this host if present + entries.removeAll { $0.host == hostKey } + + entries.append((host: hostKey, keyType: keyType, keyData: key)) + saveEntries(entries) + + Self.logger.info("Trusted host key for \(hostKey) (\(keyType))") + } + + /// Remove a stored host key + /// - Parameters: + /// - hostname: The remote hostname + /// - port: The remote port + func remove(hostname: String, port: Int) { + lock.lock() + defer { lock.unlock() } + + let hostKey = hostIdentifier(hostname, port) + var entries = loadEntries() + + let countBefore = entries.count + entries.removeAll { $0.host == hostKey } + + if entries.count < countBefore { + saveEntries(entries) + Self.logger.info("Removed host key for \(hostKey)") + } + } + + // MARK: - Key Type Mapping + + /// Convert a numeric key type to its string name + 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" + } + } + + // MARK: - Fingerprint + + /// Compute a SHA-256 fingerprint of a host key + /// - Parameter key: The raw key bytes + /// - Returns: Fingerprint in "SHA256:base64" format (matches ssh-keygen -l output) + static func fingerprint(of key: Data) -> String { + let hash = SHA256.hash(data: key) + let base64 = Data(hash).base64EncodedString() + // Remove trailing '=' padding to match OpenSSH format + let trimmed = base64.replacingOccurrences(of: "=", with: "") + return "SHA256:\(trimmed)" + } + + // MARK: - Private Helpers + + /// Build the host identifier string: [hostname]:port + private func hostIdentifier(_ hostname: String, _ port: Int) -> String { + return "[\(hostname)]:\(port)" + } + + /// Load all entries from the known_hosts file + /// File format: [hostname]:port keyType base64EncodedKey + 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: \(trimmed)") + continue + } + + entries.append((host: parts[0], keyType: parts[1], keyData: keyData)) + } + + return entries + } + + /// Save entries to the known_hosts file + private func saveEntries(_ entries: [(host: String, keyType: String, keyData: Data)]) { + let lines = entries.map { entry in + let base64Key = entry.keyData.base64EncodedString() + return "\(entry.host) \(entry.keyType) \(base64Key)" + } + + 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/TablePro/Core/SSH/HostKeyVerifier.swift b/TablePro/Core/SSH/HostKeyVerifier.swift new file mode 100644 index 000000000..c7eb595fe --- /dev/null +++ b/TablePro/Core/SSH/HostKeyVerifier.swift @@ -0,0 +1,167 @@ +// +// HostKeyVerifier.swift +// TablePro +// +// Handles SSH host key verification with UI prompts. +// Called during SSH tunnel establishment, after handshake but before auth. +// + +import AppKit +import Foundation +import os + +/// Handles host key verification with UI prompts +enum HostKeyVerifier { + private static let logger = Logger(subsystem: "com.TablePro", category: "HostKeyVerifier") + + /// Verify the host key, prompting the user if needed. + /// This method blocks the calling thread while showing UI prompts. + /// Must be called from a background thread. + /// - Parameters: + /// - keyData: The raw host key bytes from the SSH session + /// - keyType: The key type string (e.g. "ssh-rsa", "ssh-ed25519") + /// - hostname: The remote hostname + /// - port: The remote port + /// - Throws: `SSHTunnelError.hostKeyVerificationFailed` if the user rejects the key + static func verify( + keyData: Data, + keyType: String, + hostname: String, + port: Int + ) throws { + let result = HostKeyStore.shared.verify( + keyData: keyData, + keyType: keyType, + hostname: hostname, + port: port + ) + + switch result { + case .trusted: + logger.debug("Host key trusted for [\(hostname)]:\(port)") + return + + case .unknown(let fingerprint, let keyType): + logger.info("Unknown host key for [\(hostname)]:\(port), prompting user") + let accepted = promptUnknownHost( + hostname: hostname, + port: port, + fingerprint: fingerprint, + keyType: keyType + ) + guard accepted else { + logger.info("User rejected unknown host key for [\(hostname)]:\(port)") + throw SSHTunnelError.hostKeyVerificationFailed + } + HostKeyStore.shared.trust( + hostname: hostname, + port: port, + key: keyData, + keyType: keyType + ) + + case .mismatch(let expected, let actual): + logger.warning("Host key mismatch for [\(hostname)]:\(port)") + let accepted = promptHostKeyMismatch( + hostname: hostname, + port: port, + expected: expected, + actual: actual + ) + guard accepted else { + logger.info("User rejected changed host key for [\(hostname)]:\(port)") + throw SSHTunnelError.hostKeyVerificationFailed + } + HostKeyStore.shared.trust( + hostname: hostname, + port: port, + key: keyData, + keyType: keyType + ) + } + } + + // MARK: - UI Prompts + + /// Show a dialog asking the user whether to trust an unknown host + /// Blocks the calling thread until the user responds. + private static func promptUnknownHost( + hostname: String, + port: Int, + fingerprint: String, + keyType: String + ) -> Bool { + let semaphore = DispatchSemaphore(value: 0) + var accepted = false + + let hostDisplay = "[\(hostname)]:\(port)" + let title = String(localized: "Unknown SSH Host") + let message = String(localized: """ + The authenticity of host '\(hostDisplay)' can't be established. + + \(keyType) key fingerprint is: + \(fingerprint) + + Are you sure you want to continue connecting? + """) + + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .informational + alert.addButton(withTitle: String(localized: "Trust")) + alert.addButton(withTitle: String(localized: "Cancel")) + + let response = alert.runModal() + accepted = (response == .alertFirstButtonReturn) + semaphore.signal() + } + + semaphore.wait() + return accepted + } + + /// Show a warning dialog about a changed host key (potential MITM attack) + /// Blocks the calling thread until the user responds. + private static func promptHostKeyMismatch( + hostname: String, + port: Int, + expected: String, + actual: String + ) -> Bool { + let semaphore = DispatchSemaphore(value: 0) + var accepted = false + + let hostDisplay = "[\(hostname)]:\(port)" + let title = String(localized: "SSH Host Key Changed") + let message = String(localized: """ + WARNING: The host key for '\(hostDisplay)' has changed! + + This could mean someone is doing something malicious, or the server was reinstalled. + + Previous fingerprint: \(expected) + Current fingerprint: \(actual) + """) + + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .critical + alert.addButton(withTitle: String(localized: "Connect Anyway")) + alert.addButton(withTitle: String(localized: "Disconnect")) + + // Make "Disconnect" the default button (Return key) instead of "Connect Anyway" + alert.buttons[1].keyEquivalent = "\r" + alert.buttons[0].keyEquivalent = "" + + let response = alert.runModal() + accepted = (response == .alertFirstButtonReturn) + semaphore.signal() + } + + semaphore.wait() + return accepted + } +} diff --git a/TablePro/Core/SSH/LibSSH2Tunnel.swift b/TablePro/Core/SSH/LibSSH2Tunnel.swift new file mode 100644 index 000000000..c9ae88e82 --- /dev/null +++ b/TablePro/Core/SSH/LibSSH2Tunnel.swift @@ -0,0 +1,336 @@ +// +// LibSSH2Tunnel.swift +// TablePro +// + +import CLibSSH2 +import Foundation +import os + +/// Represents an active SSH tunnel backed by libssh2. +/// Each instance owns a TCP socket, libssh2 session, a local listening socket, +/// and the forwarding/keep-alive tasks. +final class LibSSH2Tunnel: @unchecked Sendable { + let connectionId: UUID + let localPort: Int + let createdAt: Date + + private static let logger = Logger(subsystem: "com.TablePro", category: "LibSSH2Tunnel") + + private let session: OpaquePointer // LIBSSH2_SESSION* + private let socketFD: Int32 // TCP socket to SSH server + private let listenFD: Int32 // Local listening socket + + // Jump host chain (in connection order) + private let jumpChain: [JumpHop] + + private var forwardingTask: Task? + private var keepAliveTask: Task? + private let isAlive = OSAllocatedUnfairLock(initialState: true) + + /// Callback invoked when the tunnel dies (keep-alive failure, etc.) + var onDeath: ((UUID) -> Void)? + + struct JumpHop { + let session: OpaquePointer // LIBSSH2_SESSION* + let socket: Int32 // TCP or socketpair fd + let channel: OpaquePointer // LIBSSH2_CHANNEL* (direct-tcpip to next hop) + let relayTask: Task? // socketpair relay task (nil for first hop) + } + + private static let relayBufferSize = 32_768 // 32KB + + init(connectionId: UUID, localPort: Int, session: OpaquePointer, + socketFD: Int32, listenFD: Int32, jumpChain: [JumpHop] = []) { + self.connectionId = connectionId + self.localPort = localPort + self.session = session + self.socketFD = socketFD + self.listenFD = listenFD + self.jumpChain = jumpChain + self.createdAt = Date() + } + + var isRunning: Bool { + isAlive.withLock { $0 } + } + + // MARK: - Forwarding + + func startForwarding(remoteHost: String, remotePort: Int) { + libssh2_session_set_blocking(session, 0) + + forwardingTask = Task.detached { [weak self] in + guard let self else { return } + Self.logger.info("Forwarding started on port \(self.localPort) -> \(remoteHost):\(remotePort)") + + while !Task.isCancelled && self.isRunning { + let clientFD = self.acceptClient() + guard clientFD >= 0 else { + if !Task.isCancelled && self.isRunning { + // accept timed out or was interrupted, retry + continue + } + break + } + + let channel = self.openDirectTcpipChannel( + remoteHost: remoteHost, + remotePort: remotePort + ) + + guard let channel else { + Self.logger.error("Failed to open direct-tcpip channel") + Darwin.close(clientFD) + continue + } + + Self.logger.debug("Client connected, relaying to \(remoteHost):\(remotePort)") + self.spawnRelay(clientFD: clientFD, channel: channel) + } + + Self.logger.info("Forwarding loop ended for port \(self.localPort)") + } + } + + // MARK: - Keep-Alive + + func startKeepAlive() { + libssh2_keepalive_config(session, 1, 30) + + keepAliveTask = Task.detached { [weak self] in + guard let self else { return } + + while !Task.isCancelled && self.isRunning { + var secondsToNext: Int32 = 0 + let rc = libssh2_keepalive_send(self.session, &secondsToNext) + + if rc != 0 { + Self.logger.warning("Keep-alive failed with error \(rc), marking tunnel dead") + self.markDead() + break + } + + let sleepInterval = max(Int(secondsToNext), 10) + try? await Task.sleep(for: .seconds(sleepInterval)) + } + } + } + + // MARK: - Lifecycle + + func close() { + let wasAlive = isAlive.withLock { alive -> Bool in + let was = alive + alive = false + return was + } + guard wasAlive else { return } + + forwardingTask?.cancel() + keepAliveTask?.cancel() + + Darwin.close(listenFD) + + libssh2_session_set_blocking(session, 1) + tablepro_libssh2_session_disconnect(session, "Closing tunnel") + libssh2_session_free(session) + Darwin.close(socketFD) + + for hop in jumpChain.reversed() { + hop.relayTask?.cancel() + libssh2_channel_free(hop.channel) + tablepro_libssh2_session_disconnect(hop.session, "Closing") + libssh2_session_free(hop.session) + Darwin.close(hop.socket) + } + + Self.logger.info("Tunnel closed for connection \(self.connectionId)") + } + + /// Synchronous cleanup for app termination. No Task needed. + func closeSync() { + let wasAlive = isAlive.withLock { alive -> Bool in + let was = alive + alive = false + return was + } + guard wasAlive else { return } + + forwardingTask?.cancel() + keepAliveTask?.cancel() + + Darwin.close(listenFD) + + libssh2_session_set_blocking(session, 1) + tablepro_libssh2_session_disconnect(session, "Closing tunnel") + libssh2_session_free(session) + Darwin.close(socketFD) + + for hop in jumpChain.reversed() { + hop.relayTask?.cancel() + libssh2_channel_free(hop.channel) + tablepro_libssh2_session_disconnect(hop.session, "Closing") + libssh2_session_free(hop.session) + Darwin.close(hop.socket) + } + } + + // MARK: - Private + + private func markDead() { + let wasAlive = isAlive.withLock { alive -> Bool in + let was = alive + alive = false + return was + } + if wasAlive { + onDeath?(connectionId) + } + } + + /// Accept a client connection on the listening socket with a 1-second poll timeout. + private func acceptClient() -> Int32 { + var pollFD = pollfd(fd: listenFD, events: Int16(POLLIN), revents: 0) + let pollResult = poll(&pollFD, 1, 1_000) // 1 second timeout + + guard pollResult > 0, pollFD.revents & Int16(POLLIN) != 0 else { + return -1 + } + + var clientAddr = sockaddr_in() + var addrLen = socklen_t(MemoryLayout.size) + + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + accept(listenFD, $0, &addrLen) + } + } + + return clientFD + } + + /// Open a direct-tcpip channel, handling EAGAIN with select(). + private func openDirectTcpipChannel(remoteHost: String, remotePort: Int) -> OpaquePointer? { + while true { + let channel = libssh2_channel_direct_tcpip_ex( + session, + remoteHost, + Int32(remotePort), + "127.0.0.1", + Int32(localPort) + ) + + if let channel { + return channel + } + + let errno = libssh2_session_last_errno(session) + guard errno == LIBSSH2_ERROR_EAGAIN else { + return nil + } + + if !waitForSocket(session: session, socketFD: socketFD, timeoutMs: 5_000) { + return nil + } + } + } + + /// Bidirectional relay between a client socket and an SSH channel. + private func spawnRelay(clientFD: Int32, channel: OpaquePointer) { + Task.detached { [weak self] in + guard let self else { + libssh2_channel_free(channel) + Darwin.close(clientFD) + return + } + + let buffer = UnsafeMutablePointer.allocate(capacity: Self.relayBufferSize) + defer { + buffer.deallocate() + libssh2_channel_close(channel) + libssh2_channel_free(channel) + Darwin.close(clientFD) + } + + while !Task.isCancelled && self.isRunning { + var pollFDs = [ + pollfd(fd: clientFD, events: Int16(POLLIN), revents: 0), + pollfd(fd: self.socketFD, events: Int16(POLLIN), revents: 0), + ] + + let pollResult = poll(&pollFDs, 2, 100) // 100ms timeout + if pollResult < 0 { break } + + // Read from SSH channel -> write to client + let channelRead = tablepro_libssh2_channel_read( + channel, buffer, Self.relayBufferSize + ) + if channelRead > 0 { + var totalSent = 0 + while totalSent < Int(channelRead) { + let sent = send( + clientFD, + buffer.advanced(by: totalSent), + Int(channelRead) - totalSent, + 0 + ) + if sent <= 0 { return } + totalSent += sent + } + } else if channelRead == 0 || libssh2_channel_eof(channel) != 0 { + // Channel EOF + return + } else if channelRead != Int(LIBSSH2_ERROR_EAGAIN) { + // Real error + return + } + + // Read from client -> write to SSH channel + if pollFDs[0].revents & Int16(POLLIN) != 0 { + let clientRead = recv(clientFD, buffer, Self.relayBufferSize, 0) + if clientRead <= 0 { return } + + var totalWritten = 0 + while totalWritten < Int(clientRead) { + let written = tablepro_libssh2_channel_write( + channel, + buffer.advanced(by: totalWritten), + Int(clientRead) - totalWritten + ) + if written > 0 { + totalWritten += Int(written) + } else if written == Int(LIBSSH2_ERROR_EAGAIN) { + _ = self.waitForSocket( + session: self.session, + socketFD: self.socketFD, + timeoutMs: 1_000 + ) + } else { + return + } + } + } + } + } + } + + /// Wait for the SSH socket to become ready, based on libssh2's block directions. + private func waitForSocket(session: OpaquePointer, socketFD: Int32, timeoutMs: Int32) -> Bool { + let directions = libssh2_session_block_directions(session) + + var events: Int16 = 0 + if directions & LIBSSH2_SESSION_BLOCK_INBOUND != 0 { + events |= Int16(POLLIN) + } + if directions & LIBSSH2_SESSION_BLOCK_OUTBOUND != 0 { + events |= Int16(POLLOUT) + } + + guard events != 0 else { return true } + + var pollFD = pollfd(fd: socketFD, events: events, revents: 0) + let rc = poll(&pollFD, 1, timeoutMs) + return rc > 0 + } +} diff --git a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift new file mode 100644 index 000000000..48d670f82 --- /dev/null +++ b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift @@ -0,0 +1,521 @@ +// +// LibSSH2TunnelFactory.swift +// TablePro +// + +import CLibSSH2 +import Foundation +import os + +/// Credentials needed for SSH tunnel creation +struct SSHTunnelCredentials: Sendable { + let sshPassword: String? + let keyPassphrase: String? + let totpSecret: String? + let totpProvider: (any TOTPProvider)? +} + +/// Creates fully-connected and authenticated SSH tunnels using libssh2. +enum LibSSH2TunnelFactory { + private static let logger = Logger(subsystem: "com.TablePro", category: "LibSSH2TunnelFactory") + + private static let connectionTimeout: Int32 = 10 // seconds + + // MARK: - Global Init + + private static let initialized: Bool = { + libssh2_init(0) + return true + }() + + // MARK: - Public + + // swiftlint:disable:next function_body_length + static func createTunnel( + connectionId: UUID, + config: SSHConfiguration, + credentials: SSHTunnelCredentials, + remoteHost: String, + remotePort: Int, + localPort: Int + ) throws -> LibSSH2Tunnel { + _ = initialized + + // Connect to the SSH server (or first jump host if jumps are configured) + let targetHost: String + let targetPort: Int + + if let firstJump = config.jumpHosts.first { + targetHost = firstJump.host + targetPort = firstJump.port + } else { + targetHost = config.host + targetPort = config.port + } + + let socketFD = try connectTCP(host: targetHost, port: targetPort) + + do { + let session = try createSession(socketFD: socketFD) + var jumpHops: [LibSSH2Tunnel.JumpHop] = [] + var currentSession = session + var currentSocketFD = socketFD + + do { + // Verify host key + try verifyHostKey(session: session, hostname: targetHost, port: targetPort) + + // Authenticate first hop + if let firstJump = config.jumpHosts.first { + let jumpAuthenticator = try buildJumpAuthenticator(jumpHost: firstJump) + try jumpAuthenticator.authenticate(session: session, username: firstJump.username) + } else { + let authenticator = try buildAuthenticator(config: config, credentials: credentials) + try authenticator.authenticate(session: session, username: config.username) + } + + if !config.jumpHosts.isEmpty { + let jumps = config.jumpHosts + // First hop session is already `session` above + + for jumpIndex in 0.. 127.0.0.1:\(localPort) -> \(remoteHost):\(remotePort)" + ) + + return tunnel + + } catch { + // Clean up currentSession if it differs from all hop sessions + // (happens when a nextSession was created but failed auth/verify) + let sessionInHops = jumpHops.contains { $0.session == currentSession } + if !sessionInHops { + tablepro_libssh2_session_disconnect(currentSession, "Error") + libssh2_session_free(currentSession) + if currentSocketFD != socketFD { + Darwin.close(currentSocketFD) + } + } + + // Clean up any jump hops that were created (reverse order) + for hop in jumpHops.reversed() { + hop.relayTask?.cancel() + libssh2_channel_free(hop.channel) + tablepro_libssh2_session_disconnect(hop.session, "Error") + libssh2_session_free(hop.session) + Darwin.close(hop.socket) + } + + throw error + } + } catch { + Darwin.close(socketFD) + throw error + } + } + + // MARK: - TCP Connection + + private static func connectTCP(host: String, port: Int) throws -> Int32 { + var hints = addrinfo() + hints.ai_family = AF_UNSPEC + hints.ai_socktype = SOCK_STREAM + hints.ai_protocol = IPPROTO_TCP + + var result: UnsafeMutablePointer? + let portString = String(port) + let rc = getaddrinfo(host, portString, &hints, &result) + + guard rc == 0, let addrInfo = result else { + let errorMsg = rc != 0 ? String(cString: gai_strerror(rc)) : "No address found" + throw SSHTunnelError.tunnelCreationFailed("DNS resolution failed for \(host): \(errorMsg)") + } + defer { freeaddrinfo(result) } + + let fd = socket(addrInfo.pointee.ai_family, addrInfo.pointee.ai_socktype, addrInfo.pointee.ai_protocol) + guard fd >= 0 else { + throw SSHTunnelError.tunnelCreationFailed("Failed to create socket") + } + + // Set non-blocking for connection timeout + let flags = fcntl(fd, F_GETFL, 0) + fcntl(fd, F_SETFL, flags | O_NONBLOCK) + + let connectResult = connect(fd, addrInfo.pointee.ai_addr, addrInfo.pointee.ai_addrlen) + + if connectResult != 0 && errno != EINPROGRESS { + Darwin.close(fd) + throw SSHTunnelError.tunnelCreationFailed("Connection to \(host):\(port) failed") + } + + if connectResult != 0 { + // Wait for connection with timeout using poll() + var writePollFD = pollfd(fd: fd, events: Int16(POLLOUT), revents: 0) + let pollResult = poll(&writePollFD, 1, connectionTimeout * 1_000) + + if pollResult <= 0 { + Darwin.close(fd) + throw SSHTunnelError.connectionTimeout + } + + // Check for connection error + var socketError: Int32 = 0 + var errorLen = socklen_t(MemoryLayout.size) + getsockopt(fd, SOL_SOCKET, SO_ERROR, &socketError, &errorLen) + + if socketError != 0 { + Darwin.close(fd) + throw SSHTunnelError.tunnelCreationFailed( + "Connection to \(host):\(port) failed: \(String(cString: strerror(socketError)))" + ) + } + } + + // Restore blocking mode for handshake/auth + fcntl(fd, F_SETFL, flags) + + logger.debug("TCP connected to \(host):\(port)") + return fd + } + + // MARK: - Session + + private static func createSession(socketFD: Int32) throws -> OpaquePointer { + guard let session = tablepro_libssh2_session_init() else { + throw SSHTunnelError.tunnelCreationFailed("Failed to initialize libssh2 session") + } + + libssh2_session_set_blocking(session, 1) + + let rc = libssh2_session_handshake(session, socketFD) + if rc != 0 { + libssh2_session_free(session) + throw SSHTunnelError.tunnelCreationFailed("SSH handshake failed (error \(rc))") + } + + return session + } + + // MARK: - Host Key Verification + + private static func verifyHostKey( + session: OpaquePointer, + hostname: String, + port: Int + ) throws { + var keyLength = 0 + var keyType: Int32 = 0 + guard let keyPtr = libssh2_session_hostkey(session, &keyLength, &keyType) else { + throw SSHTunnelError.tunnelCreationFailed("Failed to get host key") + } + + let keyData = Data(bytes: keyPtr, count: keyLength) + let keyTypeName = HostKeyStore.keyTypeName(keyType) + + try HostKeyVerifier.verify( + keyData: keyData, + keyType: keyTypeName, + hostname: hostname, + port: port + ) + } + + // MARK: - Authentication + + private static func buildAuthenticator( + config: SSHConfiguration, + credentials: SSHTunnelCredentials + ) throws -> any SSHAuthenticator { + switch config.authMethod { + case .password where config.totpMode != .none: + // Server requires password + keyboard-interactive for TOTP + let totpProvider = buildTOTPProvider(config: config, credentials: credentials) + return CompositeAuthenticator(authenticators: [ + PasswordAuthenticator(password: credentials.sshPassword ?? ""), + KeyboardInteractiveAuthenticator(password: nil, totpProvider: totpProvider), + ]) + + case .password: + return PasswordAuthenticator(password: credentials.sshPassword ?? "") + + case .privateKey: + return PublicKeyAuthenticator( + privateKeyPath: config.privateKeyPath, + passphrase: credentials.keyPassphrase + ) + + case .sshAgent: + let socketPath = config.agentSocketPath.isEmpty ? nil : config.agentSocketPath + return AgentAuthenticator(socketPath: socketPath) + + case .keyboardInteractive: + let totpProvider = buildTOTPProvider(config: config, credentials: credentials) + return KeyboardInteractiveAuthenticator( + password: credentials.sshPassword, + totpProvider: totpProvider + ) + } + } + + private static func buildJumpAuthenticator(jumpHost: SSHJumpHost) throws -> any SSHAuthenticator { + switch jumpHost.authMethod { + case .privateKey: + return PublicKeyAuthenticator( + privateKeyPath: jumpHost.privateKeyPath, + passphrase: nil + ) + case .sshAgent: + return AgentAuthenticator(socketPath: nil) + } + } + + private static func buildTOTPProvider( + config: SSHConfiguration, + credentials: SSHTunnelCredentials + ) -> (any TOTPProvider)? { + switch config.totpMode { + case .none: + return nil + case .autoGenerate: + guard let secret = credentials.totpSecret, + let generator = TOTPGenerator.fromBase32Secret( + secret, + algorithm: config.totpAlgorithm.toGeneratorAlgorithm, + digits: config.totpDigits, + period: config.totpPeriod + ) else { + return nil + } + return AutoTOTPProvider(generator: generator) + case .promptAtConnect: + return credentials.totpProvider ?? PromptTOTPProvider() + } + } + + // MARK: - Channel Operations + + private static func openChannel( + session: OpaquePointer, + socketFD: Int32, + remoteHost: String, + remotePort: Int + ) throws -> OpaquePointer { + // Use blocking mode for channel open during setup + libssh2_session_set_blocking(session, 1) + defer { libssh2_session_set_blocking(session, 0) } + + guard let channel = libssh2_channel_direct_tcpip_ex( + session, + remoteHost, + Int32(remotePort), + "127.0.0.1", + 0 + ) else { + throw SSHTunnelError.channelOpenFailed + } + + return channel + } + + /// Start a relay task that copies data between a channel and a socketpair fd. + private static func startChannelRelay( + channel: OpaquePointer, + socketFD: Int32, + sshSocketFD: Int32, + session: OpaquePointer + ) -> Task { + Task.detached { + let bufferSize = 32_768 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + while !Task.isCancelled { + var pollFDs = [ + pollfd(fd: socketFD, events: Int16(POLLIN), revents: 0), + pollfd(fd: sshSocketFD, events: Int16(POLLIN), revents: 0), + ] + + let pollResult = poll(&pollFDs, 2, 100) + if pollResult < 0 { break } + + // Channel -> socketpair + let channelRead = tablepro_libssh2_channel_read(channel, buffer, bufferSize) + if channelRead > 0 { + var totalSent = 0 + while totalSent < Int(channelRead) { + let sent = send(socketFD, buffer.advanced(by: totalSent), Int(channelRead) - totalSent, 0) + if sent <= 0 { return } + totalSent += sent + } + } else if channelRead == 0 || libssh2_channel_eof(channel) != 0 { + return + } else if channelRead != Int(LIBSSH2_ERROR_EAGAIN) { + return + } + + // Socketpair -> channel + if pollFDs[0].revents & Int16(POLLIN) != 0 { + let socketRead = recv(socketFD, buffer, bufferSize, 0) + if socketRead <= 0 { return } + + var totalWritten = 0 + while totalWritten < Int(socketRead) { + let written = tablepro_libssh2_channel_write( + channel, + buffer.advanced(by: totalWritten), + Int(socketRead) - totalWritten + ) + if written > 0 { + totalWritten += Int(written) + } else if written == Int(LIBSSH2_ERROR_EAGAIN) { + // Wait for socket readiness + var writePollFD = pollfd( + fd: sshSocketFD, events: Int16(POLLOUT), revents: 0 + ) + _ = poll(&writePollFD, 1, 1_000) + } else { + return + } + } + } + } + } + } + + // MARK: - Local Socket + + private static func bindListenSocket(port: Int) throws -> Int32 { + let listenFD = socket(AF_INET, SOCK_STREAM, 0) + guard listenFD >= 0 else { + throw SSHTunnelError.tunnelCreationFailed("Failed to create listening socket") + } + + var reuseAddr: Int32 = 1 + setsockopt(listenFD, SOL_SOCKET, SO_REUSEADDR, &reuseAddr, socklen_t(MemoryLayout.size)) + + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = in_port_t(port).bigEndian + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + + let bindResult = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(listenFD, $0, socklen_t(MemoryLayout.size)) + } + } + + guard bindResult == 0 else { + Darwin.close(listenFD) + throw SSHTunnelError.tunnelCreationFailed("Port \(port) already in use") + } + + listen(listenFD, 5) + return listenFD + } +} + +// MARK: - TOTPAlgorithm Extension + +extension TOTPAlgorithm { + var toGeneratorAlgorithm: TOTPGenerator.Algorithm { + switch self { + case .sha1: return .sha1 + case .sha256: return .sha256 + case .sha512: return .sha512 + } + } +} diff --git a/TablePro/Core/SSH/SSHTunnelManager.swift b/TablePro/Core/SSH/SSHTunnelManager.swift index 9e9de7f84..1e41e6f23 100644 --- a/TablePro/Core/SSH/SSHTunnelManager.swift +++ b/TablePro/Core/SSH/SSHTunnelManager.swift @@ -2,7 +2,7 @@ // SSHTunnelManager.swift // TablePro // -// Manages SSH tunnel lifecycle for database connections +// Manages SSH tunnel lifecycle for database connections using libssh2 // import Foundation @@ -13,9 +13,10 @@ enum SSHTunnelError: Error, LocalizedError { case tunnelCreationFailed(String) case tunnelAlreadyExists(UUID) case noAvailablePort - case sshCommandNotFound case authenticationFailed case connectionTimeout + case hostKeyVerificationFailed + case channelOpenFailed var errorDescription: String? { switch self { @@ -25,87 +26,35 @@ enum SSHTunnelError: Error, LocalizedError { return String(localized: "SSH tunnel already exists for connection: \(id.uuidString)") case .noAvailablePort: return String(localized: "No available local port for SSH tunnel") - case .sshCommandNotFound: - return String(localized: "SSH command not found. Please ensure OpenSSH is installed.") case .authenticationFailed: return String(localized: "SSH authentication failed. Check your credentials or private key.") case .connectionTimeout: return String(localized: "SSH connection timed out") + case .hostKeyVerificationFailed: + return String(localized: "SSH host key verification failed") + case .channelOpenFailed: + return String(localized: "Failed to open SSH channel for port forwarding") } } } -/// Represents an active SSH tunnel -struct SSHTunnel { - let connectionId: UUID - let localPort: Int - let remoteHost: String - let remotePort: Int - let process: Process - let createdAt: Date -} - -private struct SSHTunnelLaunch { - let process: Process - let errorPipe: Pipe - let askpassScriptPath: String? -} - -/// Manages SSH tunnels for database connections using system ssh command +/// Manages SSH tunnels for database connections using libssh2 actor SSHTunnelManager { static let shared = SSHTunnelManager() private static let logger = Logger(subsystem: "com.TablePro", category: "SSHTunnelManager") - private var tunnels: [UUID: SSHTunnel] = [:] + private var tunnels: [UUID: LibSSH2Tunnel] = [:] private let portRangeStart = 60_000 private let portRangeEnd = 65_000 - private var healthCheckTask: Task? - private static let processRegistry = OSAllocatedUnfairLock(initialState: [UUID: Process]()) - private init() { - Task { [weak self] in - await self?.startHealthCheck() - } - } + /// Static registry for synchronous termination during app shutdown + private static let tunnelRegistry = OSAllocatedUnfairLock(initialState: [UUID: LibSSH2Tunnel]()) - private func startHealthCheck() { - healthCheckTask = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(90)) - guard !Task.isCancelled else { break } - await self?.checkTunnelHealth() - } - } - } - - private func checkTunnelHealth() async { - for (connectionId, tunnel) in tunnels { - if !tunnel.process.isRunning { - Self.logger.warning("SSH tunnel for \(connectionId) died (detected by fallback health check)") - await handleTunnelDeath(connectionId: connectionId) - } - } - } - - private func handleTunnelDeath(connectionId: UUID) async { - guard tunnels.removeValue(forKey: connectionId) != nil else { return } - Self.processRegistry.withLock { $0[connectionId] = nil } - await DatabaseManager.shared.handleSSHTunnelDied(connectionId: connectionId) - } + private init() {} /// Create an SSH tunnel for a database connection - /// - Parameters: - /// - connectionId: The database connection ID - /// - sshHost: SSH server hostname - /// - sshPort: SSH server port (default 22) - /// - sshUsername: SSH username - /// - authMethod: Authentication method - /// - privateKeyPath: Path to private key file (for key auth) - /// - keyPassphrase: Passphrase for encrypted private key (optional) - /// - sshPassword: SSH password (for password auth) - Note: password auth requires sshpass - /// - remoteHost: Database host (as seen from SSH server) - /// - remotePort: Database port /// - Returns: Local port number for the tunnel + // swiftlint:disable:next function_parameter_count func createTunnel( connectionId: UUID, sshHost: String, @@ -118,90 +67,74 @@ actor SSHTunnelManager { agentSocketPath: String? = nil, remoteHost: String, remotePort: Int, - jumpHosts: [SSHJumpHost] = [] + jumpHosts: [SSHJumpHost] = [], + totpMode: TOTPMode = .none, + totpSecret: String? = nil, + totpAlgorithm: TOTPAlgorithm = .sha1, + totpDigits: Int = 6, + totpPeriod: Int = 30 ) async throws -> Int { - // Check if tunnel already exists + // Close existing tunnel if any if tunnels[connectionId] != nil { try await closeTunnel(connectionId: connectionId) } - for localPort in localPortCandidates() { - let launch: SSHTunnelLaunch + let config = SSHConfiguration( + enabled: true, + host: sshHost, + port: sshPort, + username: sshUsername, + authMethod: authMethod, + privateKeyPath: privateKeyPath ?? "", + agentSocketPath: agentSocketPath ?? "", + jumpHosts: jumpHosts, + totpMode: totpMode, + totpAlgorithm: totpAlgorithm, + totpDigits: totpDigits, + totpPeriod: totpPeriod + ) - do { - launch = try createTunnelLaunch( - localPort: localPort, - sshHost: sshHost, - sshPort: sshPort, - sshUsername: sshUsername, - authMethod: authMethod, - privateKeyPath: privateKeyPath, - keyPassphrase: keyPassphrase, - sshPassword: sshPassword, - agentSocketPath: agentSocketPath, - remoteHost: remoteHost, - remotePort: remotePort, - jumpHosts: jumpHosts - ) - } catch let error as SSHTunnelError { - throw error - } catch { - throw SSHTunnelError.tunnelCreationFailed(error.localizedDescription) - } + let credentials = SSHTunnelCredentials( + sshPassword: sshPassword, + keyPassphrase: keyPassphrase, + totpSecret: totpSecret, + totpProvider: nil + ) + // Try ports until one works + for localPort in localPortCandidates() { do { - try launch.process.run() - } catch { - removeAskpassScript(launch.askpassScriptPath) - throw SSHTunnelError.tunnelCreationFailed(error.localizedDescription) - } - - let tunnelReady = await waitForTunnelReady( - localPort: localPort, - process: launch.process, - timeoutSeconds: 15 - ) - - removeAskpassScript(launch.askpassScriptPath) - - if !tunnelReady { - if !launch.process.isRunning { - let errorData = launch.errorPipe.fileHandleForReading.readDataToEndOfFile() - let errorMessage = String(data: errorData, encoding: .utf8) ?? "Unknown error" + let tunnel = try await Task.detached { + try LibSSH2TunnelFactory.createTunnel( + connectionId: connectionId, + config: config, + credentials: credentials, + remoteHost: remoteHost, + remotePort: remotePort, + localPort: localPort + ) + }.value - if Self.isLocalPortBindFailure(errorMessage) { - Self.logger.notice("SSH tunnel bind race on local port \(localPort), retrying with another port") - continue + tunnel.onDeath = { [weak self] id in + Task { [weak self] in + await self?.handleTunnelDeath(connectionId: id) } - - throw classifySSHError( - errorMessage: errorMessage, - authMethod: authMethod - ) } - launch.process.terminate() - throw SSHTunnelError.connectionTimeout - } + tunnels[connectionId] = tunnel + Self.tunnelRegistry.withLock { $0[connectionId] = tunnel } - let tunnel = SSHTunnel( - connectionId: connectionId, - localPort: localPort, - remoteHost: remoteHost, - remotePort: remotePort, - process: launch.process, - createdAt: Date() - ) - tunnels[connectionId] = tunnel - Self.processRegistry.withLock { $0[connectionId] = launch.process } + Self.logger.info("Tunnel created for \(connectionId) on local port \(localPort)") + return localPort - launch.process.terminationHandler = { [weak self] _ in - Task { [weak self] in - await self?.handleTunnelDeath(connectionId: connectionId) + } catch let error as SSHTunnelError { + if case .tunnelCreationFailed(let msg) = error, + msg.contains("already in use") { + Self.logger.notice("Port \(localPort) in use, trying another") + continue } + throw error } - - return localPort } throw SSHTunnelError.noAvailablePort @@ -209,426 +142,64 @@ actor SSHTunnelManager { /// Close an SSH tunnel func closeTunnel(connectionId: UUID) async throws { - guard let tunnel = tunnels[connectionId] else { return } - - if tunnel.process.isRunning { - tunnel.process.terminate() - await waitForProcessExit(tunnel.process) - } - - tunnels.removeValue(forKey: connectionId) - Self.processRegistry.withLock { $0[connectionId] = nil } + guard let tunnel = tunnels.removeValue(forKey: connectionId) else { return } + Self.tunnelRegistry.withLock { $0[connectionId] = nil } + tunnel.close() } /// Close all SSH tunnels func closeAllTunnels() async { let currentTunnels = tunnels tunnels.removeAll() - Self.processRegistry.withLock { $0.removeAll(); return } + Self.tunnelRegistry.withLock { $0.removeAll(); return } - await withTaskGroup(of: Void.self) { group in - for (_, tunnel) in currentTunnels where tunnel.process.isRunning { - group.addTask { - tunnel.process.terminate() - await self.waitForProcessExit(tunnel.process, timeout: .seconds(3)) - } - } + for (_, tunnel) in currentTunnels { + tunnel.close() } } /// Synchronously terminate all SSH tunnel processes. /// Called from `applicationWillTerminate` where async is not available. nonisolated func terminateAllProcessesSync() { - let processes = Self.processRegistry.withLock { dict -> [Process] in - let procs = Array(dict.values) + let tunnelsToClose = Self.tunnelRegistry.withLock { dict -> [LibSSH2Tunnel] in + let tunnels = Array(dict.values) dict.removeAll() - return procs + return tunnels } - for process in processes where process.isRunning { - process.terminate() - let deadline = Date().addingTimeInterval(1.0) - while process.isRunning, Date() < deadline { - Thread.sleep(forTimeInterval: 0.05) - } - if process.isRunning { - kill(process.processIdentifier, SIGKILL) - } + for tunnel in tunnelsToClose { + tunnel.closeSync() } } /// Check if a tunnel exists for a connection func hasTunnel(connectionId: UUID) -> Bool { guard let tunnel = tunnels[connectionId] else { return false } - return tunnel.process.isRunning + return tunnel.isRunning } /// Get the local port for an existing tunnel func getLocalPort(connectionId: UUID) -> Int? { - guard let tunnel = tunnels[connectionId], tunnel.process.isRunning else { + guard let tunnel = tunnels[connectionId], tunnel.isRunning else { return nil } return tunnel.localPort } - // MARK: - Private Helpers - - private func localPortCandidates() -> [Int] { - Array(portRangeStart...portRangeEnd).shuffled() - } - - // swiftlint:disable:next function_parameter_count - private func createTunnelLaunch( - localPort: Int, - sshHost: String, - sshPort: Int, - sshUsername: String, - authMethod: SSHAuthMethod, - privateKeyPath: String?, - keyPassphrase: String?, - sshPassword: String?, - agentSocketPath: String?, - remoteHost: String, - remotePort: Int, - jumpHosts: [SSHJumpHost] - ) throws -> SSHTunnelLaunch { - let process = Process() - let errorPipe = Pipe() - var askpassScriptPath: String? - - do { - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - - var arguments = [ - "-N", // Don't execute remote command - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "ServerAliveInterval=60", - "-o", "ServerAliveCountMax=3", - "-o", "ConnectTimeout=10", - "-o", "ExitOnForwardFailure=yes", - "-L", "127.0.0.1:\(localPort):\(remoteHost):\(remotePort)", - "-p", String(sshPort), - ] - - switch authMethod { - case .privateKey: - guard let keyPath = privateKeyPath, !keyPath.isEmpty else { - throw SSHTunnelError.tunnelCreationFailed("Private key path is required for key authentication") - } - - let expandedPath = expandPath(keyPath) - let fileManager = FileManager.default - guard fileManager.fileExists(atPath: expandedPath) else { - throw SSHTunnelError.tunnelCreationFailed("Private key file not found at: \(expandedPath)") - } - guard fileManager.isReadableFile(atPath: expandedPath) else { - throw SSHTunnelError.tunnelCreationFailed("Private key file is not readable. Check permissions (should be 600): \(expandedPath)") - } - - arguments.append(contentsOf: ["-i", expandedPath]) - arguments.append(contentsOf: ["-o", "PubkeyAuthentication=yes"]) - arguments.append(contentsOf: ["-o", "PasswordAuthentication=no"]) - arguments.append(contentsOf: ["-o", "PreferredAuthentications=publickey"]) - - case .password: - arguments.append(contentsOf: ["-o", "PasswordAuthentication=yes"]) - arguments.append(contentsOf: ["-o", "PreferredAuthentications=password"]) - arguments.append(contentsOf: ["-o", "PubkeyAuthentication=no"]) - - case .sshAgent: - arguments.append(contentsOf: ["-o", "PubkeyAuthentication=yes"]) - arguments.append(contentsOf: ["-o", "PasswordAuthentication=no"]) - arguments.append(contentsOf: ["-o", "PreferredAuthentications=publickey"]) - } - - for jumpHost in jumpHosts where jumpHost.authMethod == .privateKey && !jumpHost.privateKeyPath.isEmpty { - arguments.append(contentsOf: ["-i", expandPath(jumpHost.privateKeyPath)]) - } - - if !jumpHosts.isEmpty { - let jumpString = jumpHosts.map(\.proxyJumpString).joined(separator: ",") - arguments.append(contentsOf: ["-J", jumpString]) - } - - arguments.append("\(sshUsername)@\(sshHost)") - process.arguments = arguments - - if authMethod == .privateKey, let passphrase = keyPassphrase { - askpassScriptPath = try createAskpassScript(password: passphrase) - } else if authMethod == .password, let password = sshPassword { - askpassScriptPath = try createAskpassScript(password: password) - } - - if let scriptPath = askpassScriptPath { - var environment = ProcessInfo.processInfo.environment - environment["SSH_ASKPASS"] = scriptPath - environment["SSH_ASKPASS_REQUIRE"] = "force" - environment["DISPLAY"] = ":0" - process.environment = environment - } - - if authMethod == .sshAgent, let socketPath = agentSocketPath, !socketPath.isEmpty { - var environment = process.environment ?? ProcessInfo.processInfo.environment - environment["SSH_AUTH_SOCK"] = expandPath(socketPath) - process.environment = environment - } - - process.standardError = errorPipe - process.standardOutput = FileHandle.nullDevice - - return SSHTunnelLaunch( - process: process, - errorPipe: errorPipe, - askpassScriptPath: askpassScriptPath - ) - } catch { - removeAskpassScript(askpassScriptPath) - throw error - } - } - - private func expandPath(_ path: String) -> String { - if path.hasPrefix("~") { - return FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(String(path.dropFirst(2))).path(percentEncoded: false) - } - return path - } - - private func createAskpassScript(password: String) throws -> String { - let scriptPath = NSTemporaryDirectory() + "ssh_askpass_\(UUID().uuidString)" - let scriptContent = "#!/bin/bash\necho '\(password.replacingOccurrences(of: "'", with: "'\\''"))'\n" - - guard let data = scriptContent.data(using: .utf8) else { - throw SSHTunnelError.tunnelCreationFailed("Failed to encode askpass script") - } - - let created = FileManager.default.createFile( - atPath: scriptPath, - contents: data, - attributes: [.posixPermissions: 0o700] - ) - - guard created else { - throw SSHTunnelError.tunnelCreationFailed("Failed to create askpass script") - } - - return scriptPath - } - - private func waitForProcessExit(_ process: Process, timeout: Duration = .seconds(5)) async { - guard process.isRunning else { return } - - await withTaskGroup(of: Void.self) { group in - group.addTask { - await withCheckedContinuation { (continuation: CheckedContinuation) in - process.terminationHandler = { _ in - continuation.resume() - } - } - } - group.addTask { - try? await Task.sleep(for: timeout) - } - _ = await group.next() - group.cancelAll() - } - - if process.isRunning { - kill(process.processIdentifier, SIGKILL) - } - } - - /// Probe the local port to detect when the SSH tunnel is ready to accept connections. - /// Returns `true` when the port is reachable, `false` on timeout or process death. - private func waitForTunnelReady( - localPort: Int, - process: Process, - timeoutSeconds: Int - ) async -> Bool { - let pollInterval: UInt64 = 250_000_000 // 250ms - let maxAttempts = timeoutSeconds * 4 // 4 polls per second - - for _ in 0.. Bool { - let socketFD = socket(AF_INET, SOCK_STREAM, 0) - guard socketFD >= 0 else { return false } - defer { close(socketFD) } - - var addr = sockaddr_in() - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = in_port_t(port).bigEndian - addr.sin_addr.s_addr = inet_addr("127.0.0.1") - - let result = withUnsafePointer(to: &addr) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - connect(socketFD, $0, socklen_t(MemoryLayout.size)) - } - } - - return result == 0 - } - - private func isPortOwnedByProcessTree(_ port: Int, rootProcessId: Int32) -> Bool { - let listeningProcessIds = listeningProcessIds(for: port) - guard !listeningProcessIds.isEmpty else { return false } - - let processTreeIds = processTreeIds(rootProcessId: rootProcessId) - return !listeningProcessIds.isDisjoint(with: processTreeIds) - } - - private func listeningProcessIds(for port: Int) -> Set { - let output = runCommand( - executablePath: "/usr/sbin/lsof", - arguments: ["-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-t"] - ) - - return Set( - output - .split(whereSeparator: \.isNewline) - .compactMap { Int32($0) } - ) - } - - private func processTreeIds(rootProcessId: Int32) -> Set { - let parentProcessIds = currentParentProcessIds() - return Self.descendantProcessIds( - rootProcessId: rootProcessId, - parentProcessIds: parentProcessIds - ) - } - - private func currentParentProcessIds() -> [Int32: Int32] { - let output = runCommand( - executablePath: "/bin/ps", - arguments: ["-axo", "pid=,ppid="] - ) - - var parentProcessIds: [Int32: Int32] = [:] - for line in output.split(whereSeparator: \.isNewline) { - let parts = line.split(whereSeparator: \.isWhitespace) - guard parts.count == 2, - let pid = Int32(parts[0]), - let parentPid = Int32(parts[1]) else { - continue - } - parentProcessIds[pid] = parentPid - } - return parentProcessIds - } - - private func runCommand(executablePath: String, arguments: [String]) -> String { - let process = Process() - let outputPipe = Pipe() - - process.executableURL = URL(fileURLWithPath: executablePath) - process.arguments = arguments - process.standardOutput = outputPipe - process.standardError = FileHandle.nullDevice - - do { - try process.run() - process.waitUntilExit() - } catch { - return "" - } - - let data = outputPipe.fileHandleForReading.readDataToEndOfFile() - return String(data: data, encoding: .utf8) ?? "" - } - - internal static func descendantProcessIds( - rootProcessId: Int32, - parentProcessIds: [Int32: Int32] - ) -> Set { - var discovered: Set = [rootProcessId] - var queue: [Int32] = [rootProcessId] - - while let currentProcessId = queue.first { - queue.removeFirst() - - for (processId, parentProcessId) in parentProcessIds - where parentProcessId == currentProcessId && !discovered.contains(processId) { - discovered.insert(processId) - queue.append(processId) - } - } - - return discovered - } - + /// Check if an error message indicates a local port bind failure static func isLocalPortBindFailure(_ errorMessage: String) -> Bool { - let normalized = errorMessage.lowercased() - return normalized.contains("address already in use") - || normalized.contains("cannot listen to port") - || normalized.contains("could not request local forwarding") - || normalized.contains("port forwarding failed") + errorMessage.lowercased().contains("already in use") } - /// Classify an SSH stderr message into a specific error type - private func classifySSHError( - errorMessage: String, - authMethod: SSHAuthMethod - ) -> SSHTunnelError { - if errorMessage.contains("Permission denied") { - if authMethod == .sshAgent { - return .tunnelCreationFailed( - "SSH agent authentication failed. Possible causes:\n" + - "• No keys loaded in SSH agent (run ssh-add -l to check)\n" + - "• Agent key doesn't match the public key on server\n" + - "• Wrong user or server\n" + - "Debug: \(errorMessage)" - ) - } else if authMethod == .privateKey { - return .tunnelCreationFailed( - "Private key authentication failed. Possible causes:\n" + - "• Private key doesn't match the public key on server\n" + - "• Wrong passphrase for encrypted private key\n" + - "• Wrong user or server\n" + - "Debug: \(errorMessage)" - ) - } else { - return .authenticationFailed - } - } - - if errorMessage.contains("authentication") { - return .authenticationFailed - } + // MARK: - Private - if errorMessage.contains("Connection timed out") || errorMessage.contains("Connection refused") { - return .tunnelCreationFailed( - "Cannot connect to SSH server. Check:\n" + - "• Server address and port are correct\n" + - "• Server is reachable (firewall, network)\n" + - "Debug: \(errorMessage)" - ) - } - - return .tunnelCreationFailed(errorMessage) + private func localPortCandidates() -> [Int] { + Array(portRangeStart...portRangeEnd).shuffled() } - private func removeAskpassScript(_ path: String?) { - guard let path else { return } - do { - try FileManager.default.removeItem(atPath: path) - } catch { - Self.logger.warning("Failed to remove askpass script at \(path): \(error.localizedDescription)") - } + private func handleTunnelDeath(connectionId: UUID) async { + guard tunnels.removeValue(forKey: connectionId) != nil else { return } + Self.tunnelRegistry.withLock { $0[connectionId] = nil } + Self.logger.warning("Tunnel died for connection \(connectionId)") + await DatabaseManager.shared.handleSSHTunnelDied(connectionId: connectionId) } } diff --git a/TablePro/Core/SSH/TOTP/Base32.swift b/TablePro/Core/SSH/TOTP/Base32.swift new file mode 100644 index 000000000..6e2b84843 --- /dev/null +++ b/TablePro/Core/SSH/TOTP/Base32.swift @@ -0,0 +1,63 @@ +// +// Base32.swift +// TablePro +// + +import Foundation + +enum Base32 { + private static let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567") + + private static let decodeTable: [UInt8] = { + var table = [UInt8](repeating: 255, count: 128) + for (index, char) in alphabet.enumerated() { + let asciiValue = Int(char.asciiValue ?? 0) + table[asciiValue] = UInt8(index) + // Lowercase mapping + if let lower = Character(char.lowercased()).asciiValue { + table[Int(lower)] = UInt8(index) + } + } + return table + }() + + /// Decode a base32-encoded string to Data. + /// - Parameter string: Base32-encoded string (case-insensitive, padding optional) + /// - Returns: Decoded data, or nil if invalid + static func decode(_ string: String) -> Data? { + // Strip whitespace, dashes, and padding + let cleaned = string.filter { char in + char != " " && char != "-" && char != "=" && char != "\n" && char != "\r" && char != "\t" + } + + if cleaned.isEmpty { + return Data() + } + + var output = Data() + var buffer: UInt64 = 0 + var bitsLeft = 0 + + for char in cleaned { + guard let ascii = char.asciiValue, ascii < 128 else { + return nil + } + + let value = decodeTable[Int(ascii)] + if value == 255 { + return nil + } + + buffer = (buffer << 5) | UInt64(value) + bitsLeft += 5 + + if bitsLeft >= 8 { + bitsLeft -= 8 + let byte = UInt8((buffer >> bitsLeft) & 0xFF) + output.append(byte) + } + } + + return output + } +} diff --git a/TablePro/Core/SSH/TOTP/TOTPGenerator.swift b/TablePro/Core/SSH/TOTP/TOTPGenerator.swift new file mode 100644 index 000000000..065b25dd1 --- /dev/null +++ b/TablePro/Core/SSH/TOTP/TOTPGenerator.swift @@ -0,0 +1,90 @@ +// +// TOTPGenerator.swift +// TablePro +// + +import CryptoKit +import Foundation + +struct TOTPGenerator { + enum Algorithm { + case sha1, sha256, sha512 + } + + let secret: Data + let algorithm: Algorithm + let digits: Int + let period: Int + + init(secret: Data, algorithm: Algorithm = .sha1, digits: Int = 6, period: Int = 30) { + self.secret = secret + self.algorithm = algorithm + self.digits = digits + self.period = period + } + + /// Generate the TOTP code for the given date. + func generate(at date: Date = Date()) -> String { + let timestamp = UInt64(date.timeIntervalSince1970) + let counter = timestamp / UInt64(period) + + // Convert counter to 8-byte big-endian + var bigEndianCounter = counter.bigEndian + let counterData = Data(bytes: &bigEndianCounter, count: 8) + + // Compute HMAC + let hmac = computeHmac(key: secret, message: counterData) + + // Dynamic truncation + let offset = Int(hmac[hmac.count - 1] & 0x0F) + let truncated = (UInt32(hmac[offset]) & 0x7F) << 24 + | UInt32(hmac[offset + 1]) << 16 + | UInt32(hmac[offset + 2]) << 8 + | UInt32(hmac[offset + 3]) + + // Modulo and zero-pad + var divisor: UInt32 = 1 + for _ in 0.. Int { + let elapsed = Int(date.timeIntervalSince1970) % period + return period - elapsed + } + + /// Create a generator from a base32-encoded secret string. + static func fromBase32Secret( + _ secretString: String, + algorithm: Algorithm = .sha1, + digits: Int = 6, + period: Int = 30 + ) -> TOTPGenerator? { + guard let secretData = Base32.decode(secretString), !secretData.isEmpty else { + return nil + } + return TOTPGenerator(secret: secretData, algorithm: algorithm, digits: digits, period: period) + } + + // MARK: - Private + + private func computeHmac(key: Data, message: Data) -> Data { + let symmetricKey = SymmetricKey(data: key) + switch algorithm { + case .sha1: + let mac = HMAC.authenticationCode(for: message, using: symmetricKey) + return Data(mac) + case .sha256: + let mac = HMAC.authenticationCode(for: message, using: symmetricKey) + return Data(mac) + case .sha512: + let mac = HMAC.authenticationCode(for: message, using: symmetricKey) + return Data(mac) + } + } +} diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 0ad97664c..58baf91f6 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -98,6 +98,7 @@ final class ConnectionStorage { deletePassword(for: connection.id) deleteSSHPassword(for: connection.id) deleteKeyPassphrase(for: connection.id) + deleteTOTPSecret(for: connection.id) } /// Duplicate a connection with a new UUID and "(Copy)" suffix @@ -141,6 +142,9 @@ final class ConnectionStorage { if let keyPassphrase = loadKeyPassphrase(for: connection.id) { saveKeyPassphrase(keyPassphrase, for: newId) } + if let totpSecret = loadTOTPSecret(for: connection.id) { + saveTOTPSecret(totpSecret, for: newId) + } return duplicate } @@ -325,6 +329,53 @@ final class ConnectionStorage { SecItemDelete(query as CFDictionary) } + + // MARK: - TOTP Secret Storage + + /// Save TOTP secret to Keychain + func saveTOTPSecret(_ secret: String, for connectionId: UUID) { + let key = "com.TablePro.totpsecret.\(connectionId.uuidString)" + guard let data = secret.data(using: .utf8) else { return } + keychainUpsert(key: key, data: data) + } + + /// Load TOTP secret from Keychain + func loadTOTPSecret(for connectionId: UUID) -> String? { + let key = "com.TablePro.totpsecret.\(connectionId.uuidString)" + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.TablePro", + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let data = result as? Data, + let secret = String(data: data, encoding: .utf8) + else { + return nil + } + + return secret + } + + /// Delete TOTP secret from Keychain + func deleteTOTPSecret(for connectionId: UUID) { + let key = "com.TablePro.totpsecret.\(connectionId.uuidString)" + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.TablePro", + kSecAttrAccount as String: key, + ] + + SecItemDelete(query as CFDictionary) + } } // MARK: - Stored Connection (Codable wrapper) @@ -382,6 +433,12 @@ private struct StoredConnection: Codable { // Startup commands let startupCommands: String? + // TOTP configuration + let totpMode: String + let totpAlgorithm: String + let totpDigits: Int + let totpPeriod: Int + // Plugin-driven additional fields let additionalFields: [String: String]? @@ -404,6 +461,12 @@ private struct StoredConnection: Codable { self.sshUseSSHConfig = connection.sshConfig.useSSHConfig self.sshAgentSocketPath = connection.sshConfig.agentSocketPath + // TOTP configuration + self.totpMode = connection.sshConfig.totpMode.rawValue + self.totpAlgorithm = connection.sshConfig.totpAlgorithm.rawValue + self.totpDigits = connection.sshConfig.totpDigits + self.totpPeriod = connection.sshConfig.totpPeriod + // SSL Configuration self.sslMode = connection.sslConfig.mode.rawValue self.sslCaCertificatePath = connection.sslConfig.caCertificatePath @@ -446,6 +509,7 @@ private struct StoredConnection: Codable { case id, name, host, port, database, username, type case sshEnabled, sshHost, sshPort, sshUsername, sshAuthMethod, sshPrivateKeyPath case sshUseSSHConfig, sshAgentSocketPath + case totpMode, totpAlgorithm, totpDigits, totpPeriod case sslMode, sslCaCertificatePath, sslClientCertificatePath, sslClientKeyPath case color, tagId, groupId case safeModeLevel @@ -473,6 +537,10 @@ private struct StoredConnection: Codable { try container.encode(sshPrivateKeyPath, forKey: .sshPrivateKeyPath) try container.encode(sshUseSSHConfig, forKey: .sshUseSSHConfig) try container.encode(sshAgentSocketPath, forKey: .sshAgentSocketPath) + try container.encode(totpMode, forKey: .totpMode) + try container.encode(totpAlgorithm, forKey: .totpAlgorithm) + try container.encode(totpDigits, forKey: .totpDigits) + try container.encode(totpPeriod, forKey: .totpPeriod) try container.encode(sslMode, forKey: .sslMode) try container.encode(sslCaCertificatePath, forKey: .sslCaCertificatePath) try container.encode(sslClientCertificatePath, forKey: .sslClientCertificatePath) @@ -508,6 +576,14 @@ private struct StoredConnection: Codable { sshUseSSHConfig = try container.decode(Bool.self, forKey: .sshUseSSHConfig) sshAgentSocketPath = try container.decodeIfPresent(String.self, forKey: .sshAgentSocketPath) ?? "" + // TOTP configuration (migration: use defaults if missing) + totpMode = try container.decodeIfPresent(String.self, forKey: .totpMode) ?? TOTPMode.none.rawValue + totpAlgorithm = try container.decodeIfPresent( + String.self, forKey: .totpAlgorithm + ) ?? TOTPAlgorithm.sha1.rawValue + totpDigits = try container.decodeIfPresent(Int.self, forKey: .totpDigits) ?? 6 + totpPeriod = try container.decodeIfPresent(Int.self, forKey: .totpPeriod) ?? 30 + // SSL Configuration (migration: use defaults if missing) sslMode = try container.decodeIfPresent(String.self, forKey: .sslMode) ?? SSLMode.disabled.rawValue sslCaCertificatePath = try container.decodeIfPresent(String.self, forKey: .sslCaCertificatePath) ?? "" @@ -539,7 +615,7 @@ private struct StoredConnection: Codable { } func toConnection() -> DatabaseConnection { - let sshConfig = SSHConfiguration( + var sshConfig = SSHConfiguration( enabled: sshEnabled, host: sshHost, port: sshPort, @@ -549,6 +625,10 @@ private struct StoredConnection: Codable { useSSHConfig: sshUseSSHConfig, agentSocketPath: sshAgentSocketPath ) + sshConfig.totpMode = TOTPMode(rawValue: totpMode) ?? .none + sshConfig.totpAlgorithm = TOTPAlgorithm(rawValue: totpAlgorithm) ?? .sha1 + sshConfig.totpDigits = totpDigits + sshConfig.totpPeriod = totpPeriod let sslConfig = SSLConfiguration( mode: SSLMode(rawValue: sslMode) ?? .disabled, diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index d62c94fbb..9ba68c146 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -16,6 +16,7 @@ enum SSHAuthMethod: String, CaseIterable, Identifiable, Codable { case password = "Password" case privateKey = "Private Key" case sshAgent = "SSH Agent" + case keyboardInteractive = "Keyboard Interactive" var id: String { rawValue } @@ -24,6 +25,7 @@ enum SSHAuthMethod: String, CaseIterable, Identifiable, Codable { case .password: return String(localized: "Password") case .privateKey: return String(localized: "Private Key") case .sshAgent: return String(localized: "SSH Agent") + case .keyboardInteractive: return String(localized: "Keyboard Interactive") } } @@ -32,6 +34,7 @@ enum SSHAuthMethod: String, CaseIterable, Identifiable, Codable { case .password: return "key.fill" case .privateKey: return "doc.text.fill" case .sshAgent: return "person.badge.key.fill" + case .keyboardInteractive: return "lock.rotation" } } } @@ -118,6 +121,10 @@ struct SSHConfiguration: Codable, Hashable { var useSSHConfig: Bool = true // Auto-fill from ~/.ssh/config when selecting host var agentSocketPath: String = "" // Custom SSH_AUTH_SOCK path (empty = use system default) var jumpHosts: [SSHJumpHost] = [] + var totpMode: TOTPMode = .none + var totpAlgorithm: TOTPAlgorithm = .sha1 + var totpDigits: Int = 6 + var totpPeriod: Int = 30 /// Check if SSH configuration is complete enough for connection var isValid: Bool { @@ -132,6 +139,8 @@ struct SSHConfiguration: Codable, Hashable { authValid = !privateKeyPath.isEmpty case .sshAgent: authValid = true + case .keyboardInteractive: + authValid = true } return authValid && jumpHosts.allSatisfy(\.isValid) @@ -141,6 +150,7 @@ struct SSHConfiguration: Codable, Hashable { extension SSHConfiguration { enum CodingKeys: String, CodingKey { case enabled, host, port, username, authMethod, privateKeyPath, useSSHConfig, agentSocketPath, jumpHosts + case totpMode, totpAlgorithm, totpDigits, totpPeriod } init(from decoder: Decoder) throws { @@ -154,6 +164,10 @@ extension SSHConfiguration { useSSHConfig = try container.decode(Bool.self, forKey: .useSSHConfig) agentSocketPath = try container.decode(String.self, forKey: .agentSocketPath) jumpHosts = try container.decodeIfPresent([SSHJumpHost].self, forKey: .jumpHosts) ?? [] + totpMode = try container.decodeIfPresent(TOTPMode.self, forKey: .totpMode) ?? .none + totpAlgorithm = try container.decodeIfPresent(TOTPAlgorithm.self, forKey: .totpAlgorithm) ?? .sha1 + totpDigits = try container.decodeIfPresent(Int.self, forKey: .totpDigits) ?? 6 + totpPeriod = try container.decodeIfPresent(Int.self, forKey: .totpPeriod) ?? 30 } } diff --git a/TablePro/Models/Connection/TOTPConfiguration.swift b/TablePro/Models/Connection/TOTPConfiguration.swift new file mode 100644 index 000000000..30a903c34 --- /dev/null +++ b/TablePro/Models/Connection/TOTPConfiguration.swift @@ -0,0 +1,32 @@ +// +// TOTPConfiguration.swift +// TablePro +// + +import Foundation + +/// TOTP (Time-based One-Time Password) mode for SSH connections +enum TOTPMode: String, CaseIterable, Identifiable, Codable { + case none = "None" + case autoGenerate = "Auto Generate" + case promptAtConnect = "Prompt at Connect" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .none: return String(localized: "None") + case .autoGenerate: return String(localized: "Auto Generate") + case .promptAtConnect: return String(localized: "Prompt at Connect") + } + } +} + +/// TOTP hash algorithm +enum TOTPAlgorithm: String, CaseIterable, Identifiable, Codable { + case sha1 = "SHA1" + case sha256 = "SHA256" + case sha512 = "SHA512" + + var id: String { rawValue } +} diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 8fdeda5da..2b47f0c1c 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -67,6 +67,11 @@ struct ConnectionFormView: View { @State private var sshConfigEntries: [SSHConfigEntry] = [] @State private var selectedSSHConfigHost: String = "" @State private var jumpHosts: [SSHJumpHost] = [] + @State private var totpMode: TOTPMode = .none + @State private var totpSecret: String = "" + @State private var totpAlgorithm: TOTPAlgorithm = .sha1 + @State private var totpDigits: Int = 6 + @State private var totpPeriod: Int = 30 // SSL Configuration @State private var sslMode: SSLMode = .disabled @@ -482,6 +487,13 @@ struct ConnectionFormView: View { Text("Keys are provided by the SSH agent (e.g. 1Password, ssh-agent).") .font(.caption) .foregroundStyle(.secondary) + } else if sshAuthMethod == .keyboardInteractive { + SecureField(String(localized: "Password"), text: $sshPassword) + Text( + String(localized: "Password is sent via keyboard-interactive challenge-response.") + ) + .font(.caption) + .foregroundStyle(.secondary) } else { LabeledContent(String(localized: "Key File")) { HStack { @@ -495,6 +507,46 @@ struct ConnectionFormView: View { } } + if sshAuthMethod == .keyboardInteractive || sshAuthMethod == .password { + Section(String(localized: "Two-Factor Authentication")) { + Picker(String(localized: "TOTP"), selection: $totpMode) { + ForEach(TOTPMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + + if totpMode == .autoGenerate { + SecureField(String(localized: "TOTP Secret"), text: $totpSecret) + .help(String(localized: "Base32-encoded secret from your authenticator setup")) + + Picker(String(localized: "Algorithm"), selection: $totpAlgorithm) { + ForEach(TOTPAlgorithm.allCases) { algo in + Text(algo.rawValue).tag(algo) + } + } + + Picker(String(localized: "Digits"), selection: $totpDigits) { + Text("6").tag(6) + Text("8").tag(8) + } + + Picker(String(localized: "Period"), selection: $totpPeriod) { + Text("30s").tag(30) + Text("60s").tag(60) + } + } else if totpMode == .promptAtConnect { + Text( + String( + localized: + "You will be prompted for a verification code each time you connect." + ) + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + Section { DisclosureGroup(String(localized: "Jump Hosts")) { ForEach($jumpHosts) { $jumpHost in @@ -790,7 +842,7 @@ struct ConnectionFormView: View { let sshValid = !sshHost.isEmpty && !sshUsername.isEmpty let authValid = sshAuthMethod == .password || sshAuthMethod == .sshAgent - || !sshPrivateKeyPath.isEmpty + || sshAuthMethod == .keyboardInteractive || !sshPrivateKeyPath.isEmpty let jumpValid = jumpHosts.allSatisfy(\.isValid) return basicValid && sshValid && authValid && jumpValid } @@ -832,6 +884,10 @@ struct ConnectionFormView: View { sshPrivateKeyPath = existing.sshConfig.privateKeyPath applySSHAgentSocketPath(existing.sshConfig.agentSocketPath) jumpHosts = existing.sshConfig.jumpHosts + totpMode = existing.sshConfig.totpMode + totpAlgorithm = existing.sshConfig.totpAlgorithm + totpDigits = existing.sshConfig.totpDigits + totpPeriod = existing.sshConfig.totpPeriod // Load SSL configuration sslMode = existing.sslConfig.mode @@ -875,6 +931,9 @@ struct ConnectionFormView: View { if let savedPassword = storage.loadPassword(for: existing.id) { password = savedPassword } + if let savedTOTPSecret = storage.loadTOTPSecret(for: existing.id) { + totpSecret = savedTOTPSecret + } } Task { @MainActor in hasLoadedData = true @@ -891,7 +950,11 @@ struct ConnectionFormView: View { privateKeyPath: sshPrivateKeyPath, useSSHConfig: !selectedSSHConfigHost.isEmpty, agentSocketPath: resolvedSSHAgentSocketPath, - jumpHosts: jumpHosts + jumpHosts: jumpHosts, + totpMode: totpMode, + totpAlgorithm: totpAlgorithm, + totpDigits: totpDigits, + totpPeriod: totpPeriod ) let sslConfig = SSLConfiguration( @@ -941,12 +1004,19 @@ struct ConnectionFormView: View { if !password.isEmpty { storage.savePassword(password, for: connectionToSave.id) } - if sshEnabled && sshAuthMethod == .password && !sshPassword.isEmpty { + if sshEnabled && (sshAuthMethod == .password || sshAuthMethod == .keyboardInteractive) + && !sshPassword.isEmpty + { storage.saveSSHPassword(sshPassword, for: connectionToSave.id) } if sshEnabled && sshAuthMethod == .privateKey && !keyPassphrase.isEmpty { storage.saveKeyPassphrase(keyPassphrase, for: connectionToSave.id) } + if sshEnabled && totpMode == .autoGenerate && !totpSecret.isEmpty { + storage.saveTOTPSecret(totpSecret, for: connectionToSave.id) + } else { + storage.deleteTOTPSecret(for: connectionToSave.id) + } // Save to storage var savedConnections = storage.loadConnections() @@ -1043,7 +1113,11 @@ struct ConnectionFormView: View { privateKeyPath: sshPrivateKeyPath, useSSHConfig: !selectedSSHConfigHost.isEmpty, agentSocketPath: resolvedSSHAgentSocketPath, - jumpHosts: jumpHosts + jumpHosts: jumpHosts, + totpMode: totpMode, + totpAlgorithm: totpAlgorithm, + totpDigits: totpDigits, + totpPeriod: totpPeriod ) let sslConfig = SSLConfiguration( @@ -1092,12 +1166,18 @@ struct ConnectionFormView: View { if !password.isEmpty { ConnectionStorage.shared.savePassword(password, for: testConn.id) } - if sshEnabled && sshAuthMethod == .password && !sshPassword.isEmpty { + if sshEnabled + && (sshAuthMethod == .password || sshAuthMethod == .keyboardInteractive) + && !sshPassword.isEmpty + { ConnectionStorage.shared.saveSSHPassword(sshPassword, for: testConn.id) } if sshEnabled && sshAuthMethod == .privateKey && !keyPassphrase.isEmpty { ConnectionStorage.shared.saveKeyPassphrase(keyPassphrase, for: testConn.id) } + if sshEnabled && totpMode == .autoGenerate && !totpSecret.isEmpty { + ConnectionStorage.shared.saveTOTPSecret(totpSecret, for: testConn.id) + } let success = try await DatabaseManager.shared.testConnection( testConn, sshPassword: sshPassword) diff --git a/TableProTests/Core/SSH/HostKeyStoreTests.swift b/TableProTests/Core/SSH/HostKeyStoreTests.swift new file mode 100644 index 000000000..002c557bc --- /dev/null +++ b/TableProTests/Core/SSH/HostKeyStoreTests.swift @@ -0,0 +1,198 @@ +// +// HostKeyStoreTests.swift +// TableProTests +// +// Tests for HostKeyStore file-based SSH host key storage. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("HostKeyStore") +struct HostKeyStoreTests { + /// Create a temporary file path for test isolation + private func makeTempFilePath() -> String { + let tempDir = NSTemporaryDirectory() + return (tempDir as NSString).appendingPathComponent("test_known_hosts_\(UUID().uuidString)") + } + + /// Create a deterministic test key + private func makeTestKey(_ seed: UInt8 = 0x42, length: Int = 32) -> Data { + Data(repeating: seed, count: length) + } + + @Test("Trust a host and verify returns .trusted") + func testTrustAndVerify() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let key = makeTestKey(0xAA) + + store.trust(hostname: "example.com", port: 22, key: key, keyType: "ssh-rsa") + + let result = store.verify(keyData: key, keyType: "ssh-rsa", hostname: "example.com", port: 22) + #expect(result == .trusted) + } + + @Test("Verify unknown host returns .unknown with fingerprint and key type") + func testUnknownHost() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let key = makeTestKey(0xBB) + let expectedFingerprint = HostKeyStore.fingerprint(of: key) + + let result = store.verify(keyData: key, keyType: "ssh-ed25519", hostname: "unknown.host", port: 22) + #expect(result == .unknown(fingerprint: expectedFingerprint, keyType: "ssh-ed25519")) + } + + @Test("Changed key returns .mismatch with expected and actual fingerprints") + func testMismatch() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let originalKey = makeTestKey(0xCC) + let changedKey = makeTestKey(0xDD) + + store.trust(hostname: "example.com", port: 22, key: originalKey, keyType: "ssh-rsa") + + let expectedFingerprint = HostKeyStore.fingerprint(of: originalKey) + let actualFingerprint = HostKeyStore.fingerprint(of: changedKey) + + let result = store.verify(keyData: changedKey, keyType: "ssh-rsa", hostname: "example.com", port: 22) + #expect(result == .mismatch(expected: expectedFingerprint, actual: actualFingerprint)) + } + + @Test("Remove a host key then verify returns .unknown") + func testRemove() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let key = makeTestKey(0xEE) + + store.trust(hostname: "example.com", port: 22, key: key, keyType: "ssh-rsa") + #expect(store.verify(keyData: key, keyType: "ssh-rsa", hostname: "example.com", port: 22) == .trusted) + + store.remove(hostname: "example.com", port: 22) + + let result = store.verify(keyData: key, keyType: "ssh-rsa", hostname: "example.com", port: 22) + switch result { + case .unknown: + break // expected + default: + Issue.record("Expected .unknown after removal, got \(result)") + } + } + + @Test("SHA256 fingerprint format is correct") + func testFingerprint() { + let key = makeTestKey(0xFF, length: 64) + let fingerprint = HostKeyStore.fingerprint(of: key) + + #expect(fingerprint.hasPrefix("SHA256:")) + + // Fingerprint should not contain '=' padding (matches OpenSSH format) + #expect(!fingerprint.contains("=")) + + // Same key should produce the same fingerprint + let fingerprint2 = HostKeyStore.fingerprint(of: key) + #expect(fingerprint == fingerprint2) + + // Different key should produce a different fingerprint + let otherKey = makeTestKey(0x00, length: 64) + let otherFingerprint = HostKeyStore.fingerprint(of: otherKey) + #expect(fingerprint != otherFingerprint) + } + + @Test("Multiple hosts are stored and verified independently") + func testMultipleHosts() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let key1 = makeTestKey(0x11) + let key2 = makeTestKey(0x22) + let key3 = makeTestKey(0x33) + + store.trust(hostname: "host-a.com", port: 22, key: key1, keyType: "ssh-rsa") + store.trust(hostname: "host-b.com", port: 22, key: key2, keyType: "ssh-ed25519") + store.trust(hostname: "host-c.com", port: 22, key: key3, keyType: "ecdsa-sha2-nistp256") + + #expect(store.verify(keyData: key1, keyType: "ssh-rsa", hostname: "host-a.com", port: 22) == .trusted) + #expect(store.verify(keyData: key2, keyType: "ssh-ed25519", hostname: "host-b.com", port: 22) == .trusted) + #expect(store.verify(keyData: key3, keyType: "ecdsa-sha2-nistp256", hostname: "host-c.com", port: 22) == .trusted) + + // Removing one host should not affect others + store.remove(hostname: "host-b.com", port: 22) + #expect(store.verify(keyData: key1, keyType: "ssh-rsa", hostname: "host-a.com", port: 22) == .trusted) + #expect(store.verify(keyData: key3, keyType: "ecdsa-sha2-nistp256", hostname: "host-c.com", port: 22) == .trusted) + } + + @Test("Same hostname with different ports are separate entries") + func testPortDifferentiation() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let key22 = makeTestKey(0x44) + let key2222 = makeTestKey(0x55) + + store.trust(hostname: "example.com", port: 22, key: key22, keyType: "ssh-rsa") + store.trust(hostname: "example.com", port: 2222, key: key2222, keyType: "ssh-ed25519") + + #expect(store.verify(keyData: key22, keyType: "ssh-rsa", hostname: "example.com", port: 22) == .trusted) + #expect(store.verify(keyData: key2222, keyType: "ssh-ed25519", hostname: "example.com", port: 2222) == .trusted) + + // Key from port 22 should not match port 2222 + let result = store.verify(keyData: key22, keyType: "ssh-rsa", hostname: "example.com", port: 2222) + switch result { + case .mismatch: + break // expected — different key stored for this port + default: + Issue.record("Expected .mismatch when using wrong port's key, got \(result)") + } + } + + @Test("Key type name mapping from numeric constants") + func testKeyTypeName() { + #expect(HostKeyStore.keyTypeName(1) == "ssh-rsa") + #expect(HostKeyStore.keyTypeName(2) == "ssh-dss") + #expect(HostKeyStore.keyTypeName(3) == "ecdsa-sha2-nistp256") + #expect(HostKeyStore.keyTypeName(4) == "ecdsa-sha2-nistp384") + #expect(HostKeyStore.keyTypeName(5) == "ecdsa-sha2-nistp521") + #expect(HostKeyStore.keyTypeName(6) == "ssh-ed25519") + #expect(HostKeyStore.keyTypeName(0) == "unknown") + #expect(HostKeyStore.keyTypeName(99) == "unknown") + } + + @Test("Trusting the same host again updates the stored key") + func testTrustUpdatesExistingEntry() { + let path = makeTempFilePath() + defer { try? FileManager.default.removeItem(atPath: path) } + + let store = HostKeyStore(filePath: path) + let oldKey = makeTestKey(0x66) + let newKey = makeTestKey(0x77) + + store.trust(hostname: "example.com", port: 22, key: oldKey, keyType: "ssh-rsa") + #expect(store.verify(keyData: oldKey, keyType: "ssh-rsa", hostname: "example.com", port: 22) == .trusted) + + // Trust with new key + store.trust(hostname: "example.com", port: 22, key: newKey, keyType: "ssh-ed25519") + #expect(store.verify(keyData: newKey, keyType: "ssh-ed25519", hostname: "example.com", port: 22) == .trusted) + + // Old key should no longer match + let result = store.verify(keyData: oldKey, keyType: "ssh-rsa", hostname: "example.com", port: 22) + switch result { + case .mismatch: + break // expected + default: + Issue.record("Expected .mismatch for old key after update, got \(result)") + } + } +} diff --git a/TableProTests/Core/SSH/TOTP/Base32Tests.swift b/TableProTests/Core/SSH/TOTP/Base32Tests.swift new file mode 100644 index 000000000..6f395482a --- /dev/null +++ b/TableProTests/Core/SSH/TOTP/Base32Tests.swift @@ -0,0 +1,127 @@ +// +// Base32Tests.swift +// TableProTests +// + +@testable import TablePro +import XCTest + +final class Base32Tests: XCTestCase { + // MARK: - RFC 4648 Test Vectors + + func testDecodeEmptyString() { + let result = Base32.decode("") + XCTAssertNotNil(result) + XCTAssertEqual(result, Data()) + } + + func testDecodeSingleCharacter() { + // "MY" → "f" (0x66) + let result = Base32.decode("MY") + XCTAssertEqual(result, Data([0x66])) + } + + func testDecodeTwoCharacters() { + // "MZXQ" → "fo" + let result = Base32.decode("MZXQ") + XCTAssertEqual(result, Data("fo".utf8)) + } + + func testDecodeThreeCharacters() { + // "MZXW6" → "foo" + let result = Base32.decode("MZXW6") + XCTAssertEqual(result, Data("foo".utf8)) + } + + func testDecodeFourCharacters() { + // "MZXW6YQ" → "foob" + let result = Base32.decode("MZXW6YQ") + XCTAssertEqual(result, Data("foob".utf8)) + } + + func testDecodeFiveCharacters() { + // "MZXW6YTB" → "fooba" + let result = Base32.decode("MZXW6YTB") + XCTAssertEqual(result, Data("fooba".utf8)) + } + + func testDecodeSixCharacters() { + // "MZXW6YTBOI" → "foobar" + let result = Base32.decode("MZXW6YTBOI") + XCTAssertEqual(result, Data("foobar".utf8)) + } + + // MARK: - Case Insensitivity + + func testDecodeLowercase() { + let result = Base32.decode("mzxw6ytboi") + XCTAssertEqual(result, Data("foobar".utf8)) + } + + func testDecodeMixedCase() { + let result = Base32.decode("MzXw6YtBoI") + XCTAssertEqual(result, Data("foobar".utf8)) + } + + // MARK: - Padding + + func testDecodeWithPadding() { + let result = Base32.decode("MZXW6YTBOI======") + XCTAssertEqual(result, Data("foobar".utf8)) + } + + func testDecodeWithPartialPadding() { + let result = Base32.decode("MY======") + XCTAssertEqual(result, Data([0x66])) + } + + // MARK: - Whitespace and Dashes + + func testDecodeWithSpaces() { + let result = Base32.decode("MZXW 6YTB OI") + XCTAssertEqual(result, Data("foobar".utf8)) + } + + func testDecodeWithDashes() { + let result = Base32.decode("MZXW-6YTB-OI") + XCTAssertEqual(result, Data("foobar".utf8)) + } + + func testDecodeWithSpacesAndDashes() { + let result = Base32.decode("MZXW - 6YTB - OI") + XCTAssertEqual(result, Data("foobar".utf8)) + } + + func testDecodeWithTabs() { + let result = Base32.decode("MZXW6\tYTBOI") + XCTAssertEqual(result, Data("foobar".utf8)) + } + + // MARK: - Invalid Input + + func testDecodeInvalidCharacter() { + let result = Base32.decode("1") + XCTAssertNil(result) + } + + func testDecodeInvalidCharacterInMiddle() { + let result = Base32.decode("MF!GG") + XCTAssertNil(result) + } + + // MARK: - Real-World TOTP Secrets + + func testDecodeTypicalTotpSecret() { + // "JBSWY3DPEHPK3PXP" is a common TOTP example secret + let result = Base32.decode("JBSWY3DPEHPK3PXP") + XCTAssertNotNil(result) + XCTAssertEqual(result?.count, 10) + } + + func testDecodeSecretWithSpacesAndDashes() { + // Same secret formatted as users might copy it + let clean = Base32.decode("JBSWY3DPEHPK3PXP") + let withFormatting = Base32.decode("JBSW Y3DP-EHPK-3PXP") + XCTAssertEqual(clean, withFormatting) + } +} diff --git a/TableProTests/Core/SSH/TOTP/TOTPGeneratorTests.swift b/TableProTests/Core/SSH/TOTP/TOTPGeneratorTests.swift new file mode 100644 index 000000000..ba3b11e80 --- /dev/null +++ b/TableProTests/Core/SSH/TOTP/TOTPGeneratorTests.swift @@ -0,0 +1,205 @@ +// +// TOTPGeneratorTests.swift +// TableProTests +// + +@testable import TablePro +import XCTest + +final class TOTPGeneratorTests: XCTestCase { + // MARK: - RFC 6238 SHA1 Test Vectors (8 digits) + + /// RFC 6238 SHA1 secret: "12345678901234567890" (20 bytes ASCII) + private var sha1Secret: Data { + Data("12345678901234567890".utf8) + } + + /// RFC 6238 SHA256 secret: "12345678901234567890123456789012" (32 bytes ASCII) + private var sha256Secret: Data { + Data("12345678901234567890123456789012".utf8) + } + + /// RFC 6238 SHA512 secret: "1234567890123456789012345678901234567890123456789012345678901234" (64 bytes ASCII) + private var sha512Secret: Data { + Data("1234567890123456789012345678901234567890123456789012345678901234".utf8) + } + + func testSha1At59Seconds() { + let generator = TOTPGenerator(secret: sha1Secret, algorithm: .sha1, digits: 8, period: 30) + let date = Date(timeIntervalSince1970: 59) + XCTAssertEqual(generator.generate(at: date), "94287082") + } + + func testSha1At1111111109() { + let generator = TOTPGenerator(secret: sha1Secret, algorithm: .sha1, digits: 8, period: 30) + let date = Date(timeIntervalSince1970: 1_111_111_109) + XCTAssertEqual(generator.generate(at: date), "07081804") + } + + func testSha1At1111111111() { + let generator = TOTPGenerator(secret: sha1Secret, algorithm: .sha1, digits: 8, period: 30) + let date = Date(timeIntervalSince1970: 1_111_111_111) + XCTAssertEqual(generator.generate(at: date), "14050471") + } + + func testSha1At1234567890() { + let generator = TOTPGenerator(secret: sha1Secret, algorithm: .sha1, digits: 8, period: 30) + let date = Date(timeIntervalSince1970: 1_234_567_890) + XCTAssertEqual(generator.generate(at: date), "89005924") + } + + func testSha1At2000000000() { + let generator = TOTPGenerator(secret: sha1Secret, algorithm: .sha1, digits: 8, period: 30) + let date = Date(timeIntervalSince1970: 2_000_000_000) + XCTAssertEqual(generator.generate(at: date), "69279037") + } + + // MARK: - RFC 6238 SHA256 Test Vectors (8 digits) + + func testSha256At59Seconds() { + let generator = TOTPGenerator(secret: sha256Secret, algorithm: .sha256, digits: 8, period: 30) + let date = Date(timeIntervalSince1970: 59) + XCTAssertEqual(generator.generate(at: date), "46119246") + } + + func testSha256At1111111109() { + let generator = TOTPGenerator(secret: sha256Secret, algorithm: .sha256, digits: 8, period: 30) + let date = Date(timeIntervalSince1970: 1_111_111_109) + XCTAssertEqual(generator.generate(at: date), "68084774") + } + + func testSha256At1234567890() { + let generator = TOTPGenerator(secret: sha256Secret, algorithm: .sha256, digits: 8, period: 30) + let date = Date(timeIntervalSince1970: 1_234_567_890) + XCTAssertEqual(generator.generate(at: date), "91819424") + } + + func testSha256At2000000000() { + let generator = TOTPGenerator(secret: sha256Secret, algorithm: .sha256, digits: 8, period: 30) + let date = Date(timeIntervalSince1970: 2_000_000_000) + XCTAssertEqual(generator.generate(at: date), "90698825") + } + + // MARK: - RFC 6238 SHA512 Test Vectors (8 digits) + + func testSha512At59Seconds() { + let generator = TOTPGenerator(secret: sha512Secret, algorithm: .sha512, digits: 8, period: 30) + let date = Date(timeIntervalSince1970: 59) + XCTAssertEqual(generator.generate(at: date), "90693936") + } + + func testSha512At1111111109() { + let generator = TOTPGenerator(secret: sha512Secret, algorithm: .sha512, digits: 8, period: 30) + let date = Date(timeIntervalSince1970: 1_111_111_109) + XCTAssertEqual(generator.generate(at: date), "25091201") + } + + func testSha512At1234567890() { + let generator = TOTPGenerator(secret: sha512Secret, algorithm: .sha512, digits: 8, period: 30) + let date = Date(timeIntervalSince1970: 1_234_567_890) + XCTAssertEqual(generator.generate(at: date), "93441116") + } + + func testSha512At2000000000() { + let generator = TOTPGenerator(secret: sha512Secret, algorithm: .sha512, digits: 8, period: 30) + let date = Date(timeIntervalSince1970: 2_000_000_000) + XCTAssertEqual(generator.generate(at: date), "38618901") + } + + // MARK: - 6-Digit Tests (last 6 digits of 8-digit result) + + func testSixDigitSha1At59Seconds() { + let generator = TOTPGenerator(secret: sha1Secret, algorithm: .sha1, digits: 6, period: 30) + let date = Date(timeIntervalSince1970: 59) + XCTAssertEqual(generator.generate(at: date), "287082") + } + + func testSixDigitSha1At1111111109() { + let generator = TOTPGenerator(secret: sha1Secret, algorithm: .sha1, digits: 6, period: 30) + let date = Date(timeIntervalSince1970: 1_111_111_109) + XCTAssertEqual(generator.generate(at: date), "081804") + } + + func testSixDigitOutputLength() { + let generator = TOTPGenerator(secret: sha1Secret, algorithm: .sha1, digits: 6, period: 30) + let code = generator.generate(at: Date(timeIntervalSince1970: 59)) + XCTAssertEqual(code.count, 6) + } + + func testEightDigitOutputLength() { + let generator = TOTPGenerator(secret: sha1Secret, algorithm: .sha1, digits: 8, period: 30) + let code = generator.generate(at: Date(timeIntervalSince1970: 59)) + XCTAssertEqual(code.count, 8) + } + + // MARK: - secondsRemaining + + func testSecondsRemainingAtPeriodStart() { + let generator = TOTPGenerator(secret: sha1Secret) + // Timestamp 0 is exactly at a period boundary + let date = Date(timeIntervalSince1970: 0) + XCTAssertEqual(generator.secondsRemaining(at: date), 30) + } + + func testSecondsRemainingMidPeriod() { + let generator = TOTPGenerator(secret: sha1Secret) + let date = Date(timeIntervalSince1970: 10) + XCTAssertEqual(generator.secondsRemaining(at: date), 20) + } + + func testSecondsRemainingNearEnd() { + let generator = TOTPGenerator(secret: sha1Secret) + let date = Date(timeIntervalSince1970: 29) + XCTAssertEqual(generator.secondsRemaining(at: date), 1) + } + + // MARK: - fromBase32Secret + + func testFromBase32SecretValid() { + // "GEZDGNBVGY3TQOJQ" is base32 for "12345678901234" (14 bytes) + let generator = TOTPGenerator.fromBase32Secret("GEZDGNBVGY3TQOJQ") + XCTAssertNotNil(generator) + } + + func testFromBase32SecretWithSpaces() { + let clean = TOTPGenerator.fromBase32Secret("GEZDGNBVGY3TQOJQ") + let spaced = TOTPGenerator.fromBase32Secret("GEZD GNBV GY3T QOJQ") + XCTAssertNotNil(clean) + XCTAssertNotNil(spaced) + // Both should produce the same code at any given time + let date = Date(timeIntervalSince1970: 59) + XCTAssertEqual(clean?.generate(at: date), spaced?.generate(at: date)) + } + + func testFromBase32SecretInvalid() { + let generator = TOTPGenerator.fromBase32Secret("!!!invalid!!!") + XCTAssertNil(generator) + } + + func testFromBase32SecretEmpty() { + let generator = TOTPGenerator.fromBase32Secret("") + XCTAssertNil(generator) + } + + // MARK: - Default Parameters + + func testDefaultAlgorithm() { + let generator = TOTPGenerator(secret: sha1Secret) + // Default is SHA1, 6 digits, 30s period + let date = Date(timeIntervalSince1970: 59) + // 6-digit SHA1 at T=59 should be "287082" + XCTAssertEqual(generator.generate(at: date), "287082") + } + + // MARK: - Code Changes at Period Boundary + + func testCodeChangesAtPeriodBoundary() { + let generator = TOTPGenerator(secret: sha1Secret, algorithm: .sha1, digits: 8, period: 30) + let beforeBoundary = Date(timeIntervalSince1970: 59) + let afterBoundary = Date(timeIntervalSince1970: 60) + let codeBefore = generator.generate(at: beforeBoundary) + let codeAfter = generator.generate(at: afterBoundary) + // T=59 → counter 1, T=60 → counter 2 — different codes + XCTAssertNotEqual(codeBefore, codeAfter) + } +} diff --git a/docs/databases/ssh-tunneling.mdx b/docs/databases/ssh-tunneling.mdx index c8faf24c5..84d49e150 100644 --- a/docs/databases/ssh-tunneling.mdx +++ b/docs/databases/ssh-tunneling.mdx @@ -138,6 +138,48 @@ TablePro supports three SSH authentication methods: /> +### Two-Factor Authentication (TOTP) + +If your SSH server requires two-factor authentication via PAM (e.g., `google-authenticator`, `duo_unix`), TablePro can handle TOTP (Time-based One-Time Password) codes during login. + +The TOTP option appears under **Two-Factor Authentication** when you select **Password** or **Keyboard Interactive** as your auth method. + + + + TablePro generates the TOTP code automatically at connect time using a secret you provide. No need to open an authenticator app. + + | Field | Description | + |-------|-------------| + | **TOTP Secret** | Your base32-encoded secret (the same key you used when setting up your authenticator app) | + | **Algorithm** | Hash algorithm: SHA1 (default), SHA256, or SHA512 | + | **Digits** | Code length: 6 (default) or 8 | + | **Period** | Code rotation interval: 30 seconds (default) or 60 seconds | + + + The TOTP secret is the base32 string you got when first enrolling in 2FA. If you only have a QR code, most authenticator apps let you view the underlying secret. + + + + TablePro shows a dialog asking for your verification code each time you connect. Use this if you prefer entering codes from your authenticator app manually. + + No additional configuration needed. Just select this mode and TablePro will prompt you when it needs the code. + + + +**Setup steps:** + +1. In the SSH tab of your connection settings, select **Password** or **Keyboard Interactive** as the auth method +2. Under **Two-Factor Authentication**, choose your TOTP mode +3. For Auto Generate: paste your base32-encoded TOTP secret + +TOTP works with common PAM configurations including `google-authenticator`, `duo_unix`, and similar modules. + +### Host Key Verification + +TablePro verifies SSH host keys to protect against man-in-the-middle attacks. On first connection to a server, you'll see the server's fingerprint and can choose to trust it. The key is then stored locally. + +If a previously trusted server's host key changes, TablePro shows a warning. This could mean the server was reinstalled, or it could indicate a security issue. You can choose to accept the new key or abort the connection. + ### Using SSH Config If you have entries in `~/.ssh/config`, TablePro reads them automatically: diff --git a/docs/vi/databases/ssh-tunneling.mdx b/docs/vi/databases/ssh-tunneling.mdx index 8a625a862..aab1d587d 100644 --- a/docs/vi/databases/ssh-tunneling.mdx +++ b/docs/vi/databases/ssh-tunneling.mdx @@ -138,6 +138,48 @@ TablePro hỗ trợ ba phương thức: /> +### Xác thực Hai yếu tố (TOTP) + +Nếu SSH server yêu cầu xác thực hai yếu tố qua PAM (ví dụ: `google-authenticator`, `duo_unix`), TablePro có thể xử lý mã TOTP (Time-based One-Time Password) khi đăng nhập. + +Tùy chọn TOTP xuất hiện trong phần **Two-Factor Authentication** khi bạn chọn **Password** hoặc **Keyboard Interactive** làm phương thức xác thực. + + + + TablePro tự tạo mã TOTP khi kết nối bằng secret bạn cung cấp. Không cần mở ứng dụng authenticator. + + | Trường | Mô tả | + |-------|-------------| + | **TOTP Secret** | Secret dạng base32 (cùng key bạn dùng khi thiết lập authenticator) | + | **Algorithm** | Thuật toán hash: SHA1 (mặc định), SHA256, hoặc SHA512 | + | **Digits** | Độ dài mã: 6 (mặc định) hoặc 8 | + | **Period** | Chu kỳ xoay mã: 30 giây (mặc định) hoặc 60 giây | + + + TOTP secret là chuỗi base32 bạn nhận khi đăng ký 2FA lần đầu. Nếu chỉ có mã QR, hầu hết authenticator app cho phép xem secret bên dưới. + + + + TablePro hiện hộp thoại yêu cầu mã xác thực mỗi lần kết nối. Dùng chế độ này nếu bạn muốn nhập mã từ authenticator app thủ công. + + Không cần cấu hình thêm. Chọn chế độ này và TablePro sẽ hỏi mã khi cần. + + + +**Các bước thiết lập:** + +1. Trong tab SSH của cài đặt kết nối, chọn **Password** hoặc **Keyboard Interactive** làm phương thức xác thực +2. Trong phần **Two-Factor Authentication**, chọn chế độ TOTP +3. Với Auto Generate: dán TOTP secret dạng base32 + +TOTP tương thích với các cấu hình PAM phổ biến gồm `google-authenticator`, `duo_unix`, và các module tương tự. + +### Xác minh Host Key + +TablePro xác minh host key SSH để bảo vệ chống tấn công man-in-the-middle. Khi kết nối lần đầu đến server, bạn sẽ thấy fingerprint của server và có thể chọn tin tưởng. Key sau đó được lưu local. + +Nếu host key của server đã tin tưởng thay đổi, TablePro hiển thị cảnh báo. Có thể server đã cài lại, hoặc có vấn đề bảo mật. Bạn có thể chấp nhận key mới hoặc hủy kết nối. + ### Dùng SSH Config Nếu có entry trong `~/.ssh/config`, TablePro đọc tự động: diff --git a/scripts/build-libssh2.sh b/scripts/build-libssh2.sh new file mode 100755 index 000000000..465849e04 --- /dev/null +++ b/scripts/build-libssh2.sh @@ -0,0 +1,268 @@ +#!/bin/bash +set -eo pipefail + +# Run a command silently, showing output only on failure. +run_quiet() { + local logfile + logfile=$(mktemp) + if ! "$@" > "$logfile" 2>&1; then + tail -30 "$logfile" + rm -f "$logfile" + return 1 + fi + rm -f "$logfile" +} + +# Build static libssh2 (with OpenSSL backend) for TablePro +# +# Produces architecture-specific and universal static libraries in Libs/: +# libssh2_arm64.a, libssh2_x86_64.a, libssh2_universal.a +# +# OpenSSL is built from source to match the app's deployment target, +# preventing "Symbol not found" crashes from Homebrew-built libraries. +# +# All libraries are built with MACOSX_DEPLOYMENT_TARGET=14.0 to match +# the app's minimum deployment target. +# +# Usage: +# ./scripts/build-libssh2.sh [arm64|x86_64|both] +# +# Prerequisites: +# - Xcode Command Line Tools +# - CMake (brew install cmake) +# - curl (for downloading source tarballs) + +DEPLOY_TARGET="14.0" +LIBSSH2_VERSION="1.11.1" +OPENSSL_VERSION="3.4.1" +OPENSSL_SHA256="002a2d6b30b58bf4bea46c43bdd96365aaf8daa6c428782aa4feee06da197df3" +LIBSSH2_SHA256="d9ec76cbe34db98eec3539fe2c899d26b0c837cb3eb466a56b0f109cabf658f7" + +ARCH="${1:-both}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +LIBS_DIR="$PROJECT_DIR/Libs" +BUILD_DIR="$(mktemp -d)" +NCPU=$(sysctl -n hw.ncpu) + +echo "🔧 Building static libssh2 $LIBSSH2_VERSION + OpenSSL $OPENSSL_VERSION" +echo " Deployment target: macOS $DEPLOY_TARGET" +echo " Architecture: $ARCH" +echo " Build dir: $BUILD_DIR" +echo "" + +cleanup() { + echo "🧹 Cleaning up build directory..." + rm -rf "$BUILD_DIR" +} +trap cleanup EXIT + +download_sources() { + echo "📥 Downloading source tarballs..." + + if [ ! -f "$BUILD_DIR/openssl-$OPENSSL_VERSION.tar.gz" ]; then + curl -fSL "https://github.com/openssl/openssl/releases/download/openssl-$OPENSSL_VERSION/openssl-$OPENSSL_VERSION.tar.gz" \ + -o "$BUILD_DIR/openssl-$OPENSSL_VERSION.tar.gz" + fi + echo "$OPENSSL_SHA256 $BUILD_DIR/openssl-$OPENSSL_VERSION.tar.gz" | shasum -a 256 -c - + + if [ ! -f "$BUILD_DIR/libssh2-$LIBSSH2_VERSION.tar.gz" ]; then + curl -fSL "https://github.com/libssh2/libssh2/releases/download/libssh2-$LIBSSH2_VERSION/libssh2-$LIBSSH2_VERSION.tar.gz" \ + -o "$BUILD_DIR/libssh2-$LIBSSH2_VERSION.tar.gz" + fi + echo "$LIBSSH2_SHA256 $BUILD_DIR/libssh2-$LIBSSH2_VERSION.tar.gz" | shasum -a 256 -c - + + echo "✅ Sources downloaded" +} + +build_openssl() { + local arch=$1 + local prefix="$BUILD_DIR/install-openssl-$arch" + + echo "" + echo "🔨 Building OpenSSL $OPENSSL_VERSION for $arch..." + + # Extract fresh copy for this arch + rm -rf "$BUILD_DIR/openssl-$OPENSSL_VERSION-$arch" + mkdir -p "$BUILD_DIR/openssl-$OPENSSL_VERSION-$arch" + tar xzf "$BUILD_DIR/openssl-$OPENSSL_VERSION.tar.gz" -C "$BUILD_DIR/openssl-$OPENSSL_VERSION-$arch" --strip-components=1 + + cd "$BUILD_DIR/openssl-$OPENSSL_VERSION-$arch" + + local target + if [ "$arch" = "arm64" ]; then + target="darwin64-arm64-cc" + else + target="darwin64-x86_64-cc" + fi + + MACOSX_DEPLOYMENT_TARGET=$DEPLOY_TARGET \ + ./Configure \ + "$target" \ + no-shared \ + no-tests \ + no-apps \ + no-docs \ + --prefix="$prefix" \ + -mmacosx-version-min=$DEPLOY_TARGET > /dev/null 2>&1 + + run_quiet make -j"$NCPU" + run_quiet make install_sw + + echo "✅ OpenSSL $arch: $(ls -lh "$prefix/lib/libssl.a" | awk '{print $5}') (libssl) $(ls -lh "$prefix/lib/libcrypto.a" | awk '{print $5}') (libcrypto)" +} + +build_libssh2() { + local arch=$1 + local openssl_prefix="$BUILD_DIR/install-openssl-$arch" + local prefix="$BUILD_DIR/install-libssh2-$arch" + + echo "" + echo "🔨 Building libssh2 $LIBSSH2_VERSION for $arch..." + + # Extract fresh copy for this arch + rm -rf "$BUILD_DIR/libssh2-$LIBSSH2_VERSION-$arch" + mkdir -p "$BUILD_DIR/libssh2-$LIBSSH2_VERSION-$arch" + tar xzf "$BUILD_DIR/libssh2-$LIBSSH2_VERSION.tar.gz" -C "$BUILD_DIR/libssh2-$LIBSSH2_VERSION-$arch" --strip-components=1 + + local build_dir="$BUILD_DIR/libssh2-$LIBSSH2_VERSION-$arch/cmake-build" + mkdir -p "$build_dir" + cd "$build_dir" + + # Resolve OpenSSL library path (may be lib/ or lib64/) + local openssl_lib_dir="$openssl_prefix/lib" + if [ -f "$openssl_prefix/lib64/libssl.a" ]; then + openssl_lib_dir="$openssl_prefix/lib64" + fi + + run_quiet env MACOSX_DEPLOYMENT_TARGET=$DEPLOY_TARGET \ + cmake .. \ + -DCMAKE_INSTALL_PREFIX="$prefix" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_OSX_ARCHITECTURES="$arch" \ + -DCMAKE_OSX_DEPLOYMENT_TARGET="$DEPLOY_TARGET" \ + -DCMAKE_C_FLAGS="-mmacosx-version-min=$DEPLOY_TARGET" \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ + -DBUILD_SHARED_LIBS=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DBUILD_TESTING=OFF \ + -DCRYPTO_BACKEND=OpenSSL \ + -DENABLE_ZLIB_COMPRESSION=OFF \ + -DOPENSSL_ROOT_DIR="$openssl_prefix" \ + -DOPENSSL_INCLUDE_DIR="$openssl_prefix/include" \ + -DOPENSSL_SSL_LIBRARY="$openssl_lib_dir/libssl.a" \ + -DOPENSSL_CRYPTO_LIBRARY="$openssl_lib_dir/libcrypto.a" + + run_quiet cmake --build . --parallel "$NCPU" + run_quiet cmake --install . + + echo "✅ libssh2 $arch: $(ls -lh "$prefix/lib/libssh2.a" | awk '{print $5}') (libssh2)" +} + +install_libs() { + local arch=$1 + local prefix="$BUILD_DIR/install-libssh2-$arch" + + echo "📦 Installing $arch libraries to Libs/..." + + # Find the actual lib directory (may be lib/ or lib64/) + local lib_dir="$prefix/lib" + if [ -f "$prefix/lib64/libssh2.a" ]; then + lib_dir="$prefix/lib64" + fi + + cp "$lib_dir/libssh2.a" "$LIBS_DIR/libssh2_${arch}.a" +} + +install_headers() { + local arch=$1 + local prefix="$BUILD_DIR/install-libssh2-$arch" + local dest="$PROJECT_DIR/TablePro/Core/SSH/CLibSSH2/include" + + echo "📦 Installing libssh2 headers..." + + mkdir -p "$dest" + cp "$prefix/include/libssh2.h" "$dest/" + cp "$prefix/include/libssh2_sftp.h" "$dest/" + cp "$prefix/include/libssh2_publickey.h" "$dest/" + + echo "✅ Headers installed to $dest" +} + +create_universal() { + echo "" + echo "🔗 Creating universal (fat) library..." + if [ -f "$LIBS_DIR/libssh2_arm64.a" ] && [ -f "$LIBS_DIR/libssh2_x86_64.a" ]; then + lipo -create \ + "$LIBS_DIR/libssh2_arm64.a" \ + "$LIBS_DIR/libssh2_x86_64.a" \ + -output "$LIBS_DIR/libssh2_universal.a" + echo " libssh2_universal.a ($(ls -lh "$LIBS_DIR/libssh2_universal.a" | awk '{print $5}'))" + fi +} + +build_for_arch() { + local arch=$1 + build_openssl "$arch" + build_libssh2 "$arch" + install_libs "$arch" + # Install headers once (they're arch-independent) + if [ ! -f "$PROJECT_DIR/TablePro/Core/SSH/CLibSSH2/include/libssh2.h" ]; then + install_headers "$arch" + fi +} + +verify_deployment_target() { + echo "" + echo "🔍 Verifying deployment targets..." + local failed=0 + for lib in "$LIBS_DIR"/libssh2_*.a; do + [ -f "$lib" ] || continue + local name min_ver + name=$(basename "$lib") + min_ver=$(otool -l "$lib" 2>/dev/null | awk '/LC_BUILD_VERSION/{found=1} found && /minos/{print $2; found=0}' | sort -V | tail -1) + if [ -z "$min_ver" ]; then + min_ver=$(otool -l "$lib" 2>/dev/null | awk '/LC_VERSION_MIN_MACOSX/{found=1} found && /version/{print $2; found=0}' | sort -V | tail -1) + fi + if [ -n "$min_ver" ]; then + if [ "$(printf '%s\n' "$DEPLOY_TARGET" "$min_ver" | sort -V | head -1)" != "$DEPLOY_TARGET" ]; then + echo " ❌ $name targets macOS $min_ver (expected $DEPLOY_TARGET)" + failed=1 + else + echo " ✅ $name targets macOS $min_ver" + fi + fi + done + if [ "$failed" -eq 1 ]; then + echo "❌ FATAL: Some libraries have incorrect deployment targets" + exit 1 + fi +} + +# Main +mkdir -p "$LIBS_DIR" +download_sources + +case "$ARCH" in + arm64) + build_for_arch arm64 + ;; + x86_64) + build_for_arch x86_64 + ;; + both) + build_for_arch arm64 + build_for_arch x86_64 + create_universal + ;; + *) + echo "Usage: $0 [arm64|x86_64|both]" + exit 1 + ;; +esac + +verify_deployment_target + +echo "" +echo "🎉 Build complete! Libraries in Libs/:" +ls -lh "$LIBS_DIR"/libssh2*.a 2>/dev/null From a9e56a84d508e3ea77d23081e67f7e7386292a97 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 14 Mar 2026 10:14:54 +0700 Subject: [PATCH 2/4] fix: address code review issues in SSH TOTP implementation --- .../Core/SSH/Auth/AgentAuthenticator.swift | 24 ++++++++++++------- .../KeyboardInteractiveAuthenticator.swift | 2 +- .../Core/SSH/Auth/PromptTOTPProvider.swift | 6 ++++- TablePro/Core/SSH/Auth/TOTPProvider.swift | 5 +++- TablePro/Core/SSH/LibSSH2TunnelFactory.swift | 9 +++++-- 5 files changed, 33 insertions(+), 13 deletions(-) diff --git a/TablePro/Core/SSH/Auth/AgentAuthenticator.swift b/TablePro/Core/SSH/Auth/AgentAuthenticator.swift index f212d433c..c423ff2f6 100644 --- a/TablePro/Core/SSH/Auth/AgentAuthenticator.swift +++ b/TablePro/Core/SSH/Auth/AgentAuthenticator.swift @@ -10,23 +10,31 @@ import os struct AgentAuthenticator: SSHAuthenticator { private static let logger = Logger(subsystem: "com.TablePro", category: "AgentAuthenticator") + /// Protects setenv/unsetenv of SSH_AUTH_SOCK across concurrent tunnel setups + private static let agentSocketLock = NSLock() + let socketPath: String? func authenticate(session: OpaquePointer, username: String) throws { // Save original SSH_AUTH_SOCK so we can restore it let originalSocketPath = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] + let needsSocketOverride = socketPath != nil - if let socketPath { - Self.logger.debug("Using custom SSH agent socket: \(socketPath, privacy: .private)") - setenv("SSH_AUTH_SOCK", socketPath, 1) + if needsSocketOverride { + Self.agentSocketLock.lock() + Self.logger.debug("Using custom SSH agent socket: \(socketPath!, privacy: .private)") + setenv("SSH_AUTH_SOCK", socketPath!, 1) } defer { - // Restore original SSH_AUTH_SOCK - if let originalSocketPath { - setenv("SSH_AUTH_SOCK", originalSocketPath, 1) - } else if socketPath != nil { - unsetenv("SSH_AUTH_SOCK") + if needsSocketOverride { + // Restore original SSH_AUTH_SOCK + if let originalSocketPath { + setenv("SSH_AUTH_SOCK", originalSocketPath, 1) + } else { + unsetenv("SSH_AUTH_SOCK") + } + Self.agentSocketLock.unlock() } } diff --git a/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift b/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift index d5388b496..91d4152da 100644 --- a/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift +++ b/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift @@ -78,7 +78,7 @@ private let kbdintCallback: @convention(c) ( let duplicated = strdup(responseText) responses[i].text = duplicated - responses[i].length = UInt32(strlen(duplicated!)) + responses[i].length = duplicated.map { UInt32(strlen($0)) } ?? 0 } } diff --git a/TablePro/Core/SSH/Auth/PromptTOTPProvider.swift b/TablePro/Core/SSH/Auth/PromptTOTPProvider.swift index c3a2d5fc2..29586c2aa 100644 --- a/TablePro/Core/SSH/Auth/PromptTOTPProvider.swift +++ b/TablePro/Core/SSH/Auth/PromptTOTPProvider.swift @@ -37,7 +37,11 @@ final class PromptTOTPProvider: TOTPProvider, @unchecked Sendable { semaphore.signal() } - semaphore.wait() + let result = semaphore.wait(timeout: .now() + 120) + + guard result == .success else { + throw SSHTunnelError.connectionTimeout + } guard let totpCode = code, !totpCode.isEmpty else { throw SSHTunnelError.authenticationFailed diff --git a/TablePro/Core/SSH/Auth/TOTPProvider.swift b/TablePro/Core/SSH/Auth/TOTPProvider.swift index 8304ddcf3..458868c4c 100644 --- a/TablePro/Core/SSH/Auth/TOTPProvider.swift +++ b/TablePro/Core/SSH/Auth/TOTPProvider.swift @@ -17,13 +17,16 @@ protocol TOTPProvider: Sendable { /// /// If the current code expires in less than 5 seconds, waits for the next /// period to avoid submitting a code that expires during the authentication handshake. +/// The maximum wait is ~6 seconds (bounded). struct AutoTOTPProvider: TOTPProvider { let generator: TOTPGenerator func provideCode() throws -> String { let remaining = generator.secondsRemaining() if remaining < 5 { - Thread.sleep(forTimeInterval: TimeInterval(remaining + 1)) + // Brief bounded sleep (max ~6s) to wait for next TOTP period. + // Uses usleep to avoid blocking a GCD worker thread via Thread.sleep. + usleep(UInt32((remaining + 1) * 1_000_000)) } return generator.generate() } diff --git a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift index 48d670f82..3ca65d856 100644 --- a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift +++ b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift @@ -146,10 +146,12 @@ enum LibSSH2TunnelFactory { ) } } catch { - // Clean up nextSession before re-throwing + // Clean up nextSession and both socketpair fds tablepro_libssh2_session_disconnect(nextSession, "Error") libssh2_session_free(nextSession) Darwin.close(fds[1]) + relayTask.cancel() + Darwin.close(fds[0]) throw error } @@ -421,7 +423,10 @@ enum LibSSH2TunnelFactory { Task.detached { let bufferSize = 32_768 let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) - defer { buffer.deallocate() } + defer { + buffer.deallocate() + Darwin.close(socketFD) + } while !Task.isCancelled { var pollFDs = [ From 326713288c6926c2f36486634190e7400976bc76 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 14 Mar 2026 10:30:23 +0700 Subject: [PATCH 3/4] fix: address all PR review comments --- .../Core/SSH/Auth/AgentAuthenticator.swift | 5 +- .../SSH/Auth/CompositeAuthenticator.swift | 20 ++-- .../KeyboardInteractiveAuthenticator.swift | 9 +- .../Core/SSH/Auth/PasswordAuthenticator.swift | 5 +- .../Core/SSH/Auth/PromptTOTPProvider.swift | 53 +++++---- .../SSH/Auth/PublicKeyAuthenticator.swift | 5 +- TablePro/Core/SSH/Auth/SSHAuthenticator.swift | 5 +- TablePro/Core/SSH/Auth/TOTPProvider.swift | 4 +- TablePro/Core/SSH/HostKeyStore.swift | 17 +-- TablePro/Core/SSH/HostKeyVerifier.swift | 2 +- TablePro/Core/SSH/LibSSH2Tunnel.swift | 5 +- TablePro/Core/SSH/LibSSH2TunnelFactory.swift | 111 +++++++++++------- TablePro/Core/SSH/SSHTunnelManager.swift | 3 + TablePro/Core/SSH/TOTP/Base32.swift | 2 +- TablePro/Core/SSH/TOTP/TOTPGenerator.swift | 2 +- TablePro/Core/Storage/ConnectionStorage.swift | 6 +- .../Models/Connection/TOTPConfiguration.swift | 10 +- .../Views/Connection/ConnectionFormView.swift | 2 + .../Core/SSH/HostKeyStoreTests.swift | 11 +- .../Core/SSH/TOTP/TOTPGeneratorTests.swift | 3 +- scripts/build-libssh2.sh | 7 +- 21 files changed, 171 insertions(+), 116 deletions(-) diff --git a/TablePro/Core/SSH/Auth/AgentAuthenticator.swift b/TablePro/Core/SSH/Auth/AgentAuthenticator.swift index c423ff2f6..0844ed74d 100644 --- a/TablePro/Core/SSH/Auth/AgentAuthenticator.swift +++ b/TablePro/Core/SSH/Auth/AgentAuthenticator.swift @@ -3,11 +3,12 @@ // TablePro // -import CLibSSH2 import Foundation import os -struct AgentAuthenticator: SSHAuthenticator { +import CLibSSH2 + +internal struct AgentAuthenticator: SSHAuthenticator { private static let logger = Logger(subsystem: "com.TablePro", category: "AgentAuthenticator") /// Protects setenv/unsetenv of SSH_AUTH_SOCK across concurrent tunnel setups diff --git a/TablePro/Core/SSH/Auth/CompositeAuthenticator.swift b/TablePro/Core/SSH/Auth/CompositeAuthenticator.swift index 08406f7a8..74455327e 100644 --- a/TablePro/Core/SSH/Auth/CompositeAuthenticator.swift +++ b/TablePro/Core/SSH/Auth/CompositeAuthenticator.swift @@ -3,24 +3,28 @@ // TablePro // -import CLibSSH2 import Foundation import os +import CLibSSH2 + /// Authenticator that tries multiple auth methods in sequence. /// Used for servers requiring e.g. password + keyboard-interactive (TOTP). -struct CompositeAuthenticator: SSHAuthenticator { +internal struct CompositeAuthenticator: SSHAuthenticator { private static let logger = Logger(subsystem: "com.TablePro", category: "CompositeAuthenticator") let authenticators: [any SSHAuthenticator] func authenticate(session: OpaquePointer, username: String) throws { + var lastError: Error? for (index, authenticator) in authenticators.enumerated() { - Self.logger.debug( - "Trying authenticator \(index + 1)/\(authenticators.count): \(String(describing: type(of: authenticator)))" - ) - - try authenticator.authenticate(session: session, username: username) + Self.logger.debug("Trying authenticator \(index + 1)/\(authenticators.count)") + do { + try authenticator.authenticate(session: session, username: username) + } catch { + Self.logger.debug("Authenticator \(index + 1) failed: \(error)") + lastError = error + } if libssh2_userauth_authenticated(session) != 0 { Self.logger.info("Authentication succeeded after \(index + 1) step(s)") @@ -29,7 +33,7 @@ struct CompositeAuthenticator: SSHAuthenticator { } if libssh2_userauth_authenticated(session) == 0 { - throw SSHTunnelError.authenticationFailed + throw lastError ?? SSHTunnelError.authenticationFailed } } } diff --git a/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift b/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift index 91d4152da..6f8a47cc1 100644 --- a/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift +++ b/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift @@ -3,19 +3,20 @@ // TablePro // -import CLibSSH2 import Foundation import os +import CLibSSH2 + /// Prompt type classification for keyboard-interactive authentication -enum KBDINTPromptType { +internal enum KBDINTPromptType { case password case totp case unknown } /// Context passed through the libssh2 session abstract pointer to the C callback -final class KeyboardInteractiveContext { +internal final class KeyboardInteractiveContext { var password: String? var totpCode: String? @@ -82,7 +83,7 @@ private let kbdintCallback: @convention(c) ( } } -struct KeyboardInteractiveAuthenticator: SSHAuthenticator { +internal struct KeyboardInteractiveAuthenticator: SSHAuthenticator { private static let logger = Logger( subsystem: "com.TablePro", category: "KeyboardInteractiveAuthenticator" diff --git a/TablePro/Core/SSH/Auth/PasswordAuthenticator.swift b/TablePro/Core/SSH/Auth/PasswordAuthenticator.swift index 476575bb2..1c90d4416 100644 --- a/TablePro/Core/SSH/Auth/PasswordAuthenticator.swift +++ b/TablePro/Core/SSH/Auth/PasswordAuthenticator.swift @@ -3,10 +3,11 @@ // TablePro // -import CLibSSH2 import Foundation -struct PasswordAuthenticator: SSHAuthenticator { +import CLibSSH2 + +internal struct PasswordAuthenticator: SSHAuthenticator { let password: String func authenticate(session: OpaquePointer, username: String) throws { diff --git a/TablePro/Core/SSH/Auth/PromptTOTPProvider.swift b/TablePro/Core/SSH/Auth/PromptTOTPProvider.swift index 29586c2aa..d09e2b6a2 100644 --- a/TablePro/Core/SSH/Auth/PromptTOTPProvider.swift +++ b/TablePro/Core/SSH/Auth/PromptTOTPProvider.swift @@ -10,39 +10,48 @@ import Foundation /// /// This provider blocks the calling thread while the alert is displayed on the main thread. /// It is intended for interactive SSH sessions where no TOTP secret is configured. -final class PromptTOTPProvider: TOTPProvider, @unchecked Sendable { +internal final class PromptTOTPProvider: TOTPProvider, @unchecked Sendable { func provideCode() throws -> String { + if Thread.isMainThread { + return try handleResult(showAlert()) + } + let semaphore = DispatchSemaphore(value: 0) var code: String? - DispatchQueue.main.async { - let alert = NSAlert() - alert.messageText = String(localized: "Verification Code Required") - alert.informativeText = String( - localized: "Enter the TOTP verification code for SSH authentication." - ) - alert.alertStyle = .informational - alert.addButton(withTitle: String(localized: "Connect")) - alert.addButton(withTitle: String(localized: "Cancel")) - - let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 24)) - textField.placeholderString = "000000" - alert.accessoryView = textField - alert.window.initialFirstResponder = textField - - let response = alert.runModal() - if response == .alertFirstButtonReturn { - code = textField.stringValue - } + code = self.showAlert() semaphore.signal() } - let result = semaphore.wait(timeout: .now() + 120) - guard result == .success else { throw SSHTunnelError.connectionTimeout } + return try handleResult(code) + } + + private func showAlert() -> String? { + let alert = NSAlert() + alert.messageText = String(localized: "Verification Code Required") + alert.informativeText = String( + localized: "Enter the TOTP verification code for SSH authentication." + ) + alert.alertStyle = .informational + alert.addButton(withTitle: String(localized: "Connect")) + alert.addButton(withTitle: String(localized: "Cancel")) + + let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 24)) + textField.placeholderString = "000000" + alert.accessoryView = textField + alert.window.initialFirstResponder = textField + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + return textField.stringValue + } + return nil + } + private func handleResult(_ code: String?) throws -> String { guard let totpCode = code, !totpCode.isEmpty else { throw SSHTunnelError.authenticationFailed } diff --git a/TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift b/TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift index 4a4d6d2ba..0f88e9b29 100644 --- a/TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift +++ b/TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift @@ -3,10 +3,11 @@ // TablePro // -import CLibSSH2 import Foundation -struct PublicKeyAuthenticator: SSHAuthenticator { +import CLibSSH2 + +internal struct PublicKeyAuthenticator: SSHAuthenticator { let privateKeyPath: String let passphrase: String? diff --git a/TablePro/Core/SSH/Auth/SSHAuthenticator.swift b/TablePro/Core/SSH/Auth/SSHAuthenticator.swift index a8b8f2036..6f086a630 100644 --- a/TablePro/Core/SSH/Auth/SSHAuthenticator.swift +++ b/TablePro/Core/SSH/Auth/SSHAuthenticator.swift @@ -3,11 +3,12 @@ // TablePro // -import CLibSSH2 import Foundation +import CLibSSH2 + /// Protocol for SSH authentication methods -protocol SSHAuthenticator: Sendable { +internal protocol SSHAuthenticator: Sendable { /// Authenticate the SSH session /// - Parameters: /// - session: libssh2 session pointer diff --git a/TablePro/Core/SSH/Auth/TOTPProvider.swift b/TablePro/Core/SSH/Auth/TOTPProvider.swift index 458868c4c..908936ec4 100644 --- a/TablePro/Core/SSH/Auth/TOTPProvider.swift +++ b/TablePro/Core/SSH/Auth/TOTPProvider.swift @@ -6,7 +6,7 @@ import Foundation /// Protocol for providing TOTP verification codes -protocol TOTPProvider: Sendable { +internal protocol TOTPProvider: Sendable { /// Generate or obtain a TOTP code /// - Returns: The TOTP code string /// - Throws: SSHTunnelError if the code cannot be obtained @@ -18,7 +18,7 @@ protocol TOTPProvider: Sendable { /// If the current code expires in less than 5 seconds, waits for the next /// period to avoid submitting a code that expires during the authentication handshake. /// The maximum wait is ~6 seconds (bounded). -struct AutoTOTPProvider: TOTPProvider { +internal struct AutoTOTPProvider: TOTPProvider { let generator: TOTPGenerator func provideCode() throws -> String { diff --git a/TablePro/Core/SSH/HostKeyStore.swift b/TablePro/Core/SSH/HostKeyStore.swift index a124f741a..79d84c8ae 100644 --- a/TablePro/Core/SSH/HostKeyStore.swift +++ b/TablePro/Core/SSH/HostKeyStore.swift @@ -12,7 +12,7 @@ import Foundation import os /// Manages SSH host key verification for known hosts -final class HostKeyStore: @unchecked Sendable { +internal final class HostKeyStore: @unchecked Sendable { static let shared = HostKeyStore() private static let logger = Logger(subsystem: "com.TablePro", category: "HostKeyStore") @@ -27,11 +27,14 @@ final class HostKeyStore: @unchecked Sendable { private let lock = NSLock() private init() { - let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + guard let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first else { + self.filePath = NSTemporaryDirectory() + "TablePro_known_hosts" + return + } let tableProDir = appSupport.appendingPathComponent("TablePro") - try? FileManager.default.createDirectory(at: tableProDir, withIntermediateDirectories: true) - self.filePath = tableProDir.appendingPathComponent("known_hosts").path } @@ -57,7 +60,7 @@ final class HostKeyStore: @unchecked Sendable { let currentFingerprint = Self.fingerprint(of: keyData) let entries = loadEntries() - guard let existing = entries.first(where: { $0.host == hostKey }) else { + guard let existing = entries.first(where: { $0.host == hostKey && $0.keyType == keyType }) else { Self.logger.info("Unknown host key for \(hostKey)") return .unknown(fingerprint: currentFingerprint, keyType: keyType) } @@ -85,8 +88,8 @@ final class HostKeyStore: @unchecked Sendable { let hostKey = hostIdentifier(hostname, port) var entries = loadEntries() - // Remove existing entry for this host if present - entries.removeAll { $0.host == hostKey } + // Remove existing entry for this host and key type if present + entries.removeAll { $0.host == hostKey && $0.keyType == keyType } entries.append((host: hostKey, keyType: keyType, keyData: key)) saveEntries(entries) diff --git a/TablePro/Core/SSH/HostKeyVerifier.swift b/TablePro/Core/SSH/HostKeyVerifier.swift index c7eb595fe..c6608b9e3 100644 --- a/TablePro/Core/SSH/HostKeyVerifier.swift +++ b/TablePro/Core/SSH/HostKeyVerifier.swift @@ -11,7 +11,7 @@ import Foundation import os /// Handles host key verification with UI prompts -enum HostKeyVerifier { +internal enum HostKeyVerifier { private static let logger = Logger(subsystem: "com.TablePro", category: "HostKeyVerifier") /// Verify the host key, prompting the user if needed. diff --git a/TablePro/Core/SSH/LibSSH2Tunnel.swift b/TablePro/Core/SSH/LibSSH2Tunnel.swift index c9ae88e82..9294efe85 100644 --- a/TablePro/Core/SSH/LibSSH2Tunnel.swift +++ b/TablePro/Core/SSH/LibSSH2Tunnel.swift @@ -3,14 +3,15 @@ // TablePro // -import CLibSSH2 import Foundation import os +import CLibSSH2 + /// Represents an active SSH tunnel backed by libssh2. /// Each instance owns a TCP socket, libssh2 session, a local listening socket, /// and the forwarding/keep-alive tasks. -final class LibSSH2Tunnel: @unchecked Sendable { +internal final class LibSSH2Tunnel: @unchecked Sendable { let connectionId: UUID let localPort: Int let createdAt: Date diff --git a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift index 3ca65d856..01098460c 100644 --- a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift +++ b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift @@ -3,12 +3,13 @@ // TablePro // -import CLibSSH2 import Foundation import os +import CLibSSH2 + /// Credentials needed for SSH tunnel creation -struct SSHTunnelCredentials: Sendable { +internal struct SSHTunnelCredentials: Sendable { let sshPassword: String? let keyPassphrase: String? let totpSecret: String? @@ -16,7 +17,7 @@ struct SSHTunnelCredentials: Sendable { } /// Creates fully-connected and authenticated SSH tunnels using libssh2. -enum LibSSH2TunnelFactory { +internal enum LibSSH2TunnelFactory { private static let logger = Logger(subsystem: "com.TablePro", category: "LibSSH2TunnelFactory") private static let connectionTimeout: Int32 = 10 // seconds @@ -172,9 +173,6 @@ enum LibSSH2TunnelFactory { jumpChain: jumpHops ) - tunnel.startForwarding(remoteHost: remoteHost, remotePort: remotePort) - tunnel.startKeepAlive() - logger.info( "Tunnel created: \(config.host):\(config.port) -> 127.0.0.1:\(localPort) -> \(remoteHost):\(remotePort)" ) @@ -222,56 +220,69 @@ enum LibSSH2TunnelFactory { let portString = String(port) let rc = getaddrinfo(host, portString, &hints, &result) - guard rc == 0, let addrInfo = result else { + guard rc == 0, let firstAddr = result else { let errorMsg = rc != 0 ? String(cString: gai_strerror(rc)) : "No address found" throw SSHTunnelError.tunnelCreationFailed("DNS resolution failed for \(host): \(errorMsg)") } defer { freeaddrinfo(result) } - let fd = socket(addrInfo.pointee.ai_family, addrInfo.pointee.ai_socktype, addrInfo.pointee.ai_protocol) - guard fd >= 0 else { - throw SSHTunnelError.tunnelCreationFailed("Failed to create socket") - } - - // Set non-blocking for connection timeout - let flags = fcntl(fd, F_GETFL, 0) - fcntl(fd, F_SETFL, flags | O_NONBLOCK) + // Iterate through all addresses returned by getaddrinfo + var currentAddr: UnsafeMutablePointer? = firstAddr + var lastError: String = "No address found" - let connectResult = connect(fd, addrInfo.pointee.ai_addr, addrInfo.pointee.ai_addrlen) + while let addrInfo = currentAddr { + let fd = socket(addrInfo.pointee.ai_family, addrInfo.pointee.ai_socktype, addrInfo.pointee.ai_protocol) + guard fd >= 0 else { + currentAddr = addrInfo.pointee.ai_next + continue + } - if connectResult != 0 && errno != EINPROGRESS { - Darwin.close(fd) - throw SSHTunnelError.tunnelCreationFailed("Connection to \(host):\(port) failed") - } + // Set non-blocking for connection timeout + let flags = fcntl(fd, F_GETFL, 0) + fcntl(fd, F_SETFL, flags | O_NONBLOCK) - if connectResult != 0 { - // Wait for connection with timeout using poll() - var writePollFD = pollfd(fd: fd, events: Int16(POLLOUT), revents: 0) - let pollResult = poll(&writePollFD, 1, connectionTimeout * 1_000) + let connectResult = connect(fd, addrInfo.pointee.ai_addr, addrInfo.pointee.ai_addrlen) - if pollResult <= 0 { + if connectResult != 0 && errno != EINPROGRESS { Darwin.close(fd) - throw SSHTunnelError.connectionTimeout + lastError = "Connection to \(host):\(port) failed" + currentAddr = addrInfo.pointee.ai_next + continue } - // Check for connection error - var socketError: Int32 = 0 - var errorLen = socklen_t(MemoryLayout.size) - getsockopt(fd, SOL_SOCKET, SO_ERROR, &socketError, &errorLen) + if connectResult != 0 { + // Wait for connection with timeout using poll() + var writePollFD = pollfd(fd: fd, events: Int16(POLLOUT), revents: 0) + let pollResult = poll(&writePollFD, 1, connectionTimeout * 1_000) - if socketError != 0 { - Darwin.close(fd) - throw SSHTunnelError.tunnelCreationFailed( - "Connection to \(host):\(port) failed: \(String(cString: strerror(socketError)))" - ) + if pollResult <= 0 { + Darwin.close(fd) + lastError = "Connection timed out" + currentAddr = addrInfo.pointee.ai_next + continue + } + + // Check for connection error + var socketError: Int32 = 0 + var errorLen = socklen_t(MemoryLayout.size) + getsockopt(fd, SOL_SOCKET, SO_ERROR, &socketError, &errorLen) + + if socketError != 0 { + Darwin.close(fd) + lastError = "Connection to \(host):\(port) failed: \(String(cString: strerror(socketError)))" + currentAddr = addrInfo.pointee.ai_next + continue + } } - } - // Restore blocking mode for handshake/auth - fcntl(fd, F_SETFL, flags) + // Restore blocking mode for handshake/auth + fcntl(fd, F_SETFL, flags) + + logger.debug("TCP connected to \(host):\(port)") + return fd + } - logger.debug("TCP connected to \(host):\(port)") - return fd + throw SSHTunnelError.tunnelCreationFailed(lastError) } // MARK: - Session @@ -335,14 +346,30 @@ enum LibSSH2TunnelFactory { return PasswordAuthenticator(password: credentials.sshPassword ?? "") case .privateKey: - return PublicKeyAuthenticator( + let primary = PublicKeyAuthenticator( privateKeyPath: config.privateKeyPath, passphrase: credentials.keyPassphrase ) + if config.totpMode != .none { + let totpAuth = KeyboardInteractiveAuthenticator( + password: nil, + totpProvider: buildTOTPProvider(config: config, credentials: credentials) + ) + return CompositeAuthenticator(authenticators: [primary, totpAuth]) + } + return primary case .sshAgent: let socketPath = config.agentSocketPath.isEmpty ? nil : config.agentSocketPath - return AgentAuthenticator(socketPath: socketPath) + let primary = AgentAuthenticator(socketPath: socketPath) + if config.totpMode != .none { + let totpAuth = KeyboardInteractiveAuthenticator( + password: nil, + totpProvider: buildTOTPProvider(config: config, credentials: credentials) + ) + return CompositeAuthenticator(authenticators: [primary, totpAuth]) + } + return primary case .keyboardInteractive: let totpProvider = buildTOTPProvider(config: config, credentials: credentials) diff --git a/TablePro/Core/SSH/SSHTunnelManager.swift b/TablePro/Core/SSH/SSHTunnelManager.swift index 1e41e6f23..c50712b83 100644 --- a/TablePro/Core/SSH/SSHTunnelManager.swift +++ b/TablePro/Core/SSH/SSHTunnelManager.swift @@ -124,6 +124,9 @@ actor SSHTunnelManager { tunnels[connectionId] = tunnel Self.tunnelRegistry.withLock { $0[connectionId] = tunnel } + tunnel.startForwarding(remoteHost: remoteHost, remotePort: remotePort) + tunnel.startKeepAlive() + Self.logger.info("Tunnel created for \(connectionId) on local port \(localPort)") return localPort diff --git a/TablePro/Core/SSH/TOTP/Base32.swift b/TablePro/Core/SSH/TOTP/Base32.swift index 6e2b84843..1828b9961 100644 --- a/TablePro/Core/SSH/TOTP/Base32.swift +++ b/TablePro/Core/SSH/TOTP/Base32.swift @@ -5,7 +5,7 @@ import Foundation -enum Base32 { +internal enum Base32 { private static let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567") private static let decodeTable: [UInt8] = { diff --git a/TablePro/Core/SSH/TOTP/TOTPGenerator.swift b/TablePro/Core/SSH/TOTP/TOTPGenerator.swift index 065b25dd1..3168d1686 100644 --- a/TablePro/Core/SSH/TOTP/TOTPGenerator.swift +++ b/TablePro/Core/SSH/TOTP/TOTPGenerator.swift @@ -6,7 +6,7 @@ import CryptoKit import Foundation -struct TOTPGenerator { +internal struct TOTPGenerator { enum Algorithm { case sha1, sha256, sha512 } diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 58baf91f6..f76bfed25 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -581,8 +581,10 @@ private struct StoredConnection: Codable { totpAlgorithm = try container.decodeIfPresent( String.self, forKey: .totpAlgorithm ) ?? TOTPAlgorithm.sha1.rawValue - totpDigits = try container.decodeIfPresent(Int.self, forKey: .totpDigits) ?? 6 - totpPeriod = try container.decodeIfPresent(Int.self, forKey: .totpPeriod) ?? 30 + let decodedDigits = try container.decodeIfPresent(Int.self, forKey: .totpDigits) ?? 6 + totpDigits = max(6, min(8, decodedDigits)) + let decodedPeriod = try container.decodeIfPresent(Int.self, forKey: .totpPeriod) ?? 30 + totpPeriod = max(15, min(120, decodedPeriod)) // SSL Configuration (migration: use defaults if missing) sslMode = try container.decodeIfPresent(String.self, forKey: .sslMode) ?? SSLMode.disabled.rawValue diff --git a/TablePro/Models/Connection/TOTPConfiguration.swift b/TablePro/Models/Connection/TOTPConfiguration.swift index 30a903c34..1f1c2550f 100644 --- a/TablePro/Models/Connection/TOTPConfiguration.swift +++ b/TablePro/Models/Connection/TOTPConfiguration.swift @@ -6,10 +6,10 @@ import Foundation /// TOTP (Time-based One-Time Password) mode for SSH connections -enum TOTPMode: String, CaseIterable, Identifiable, Codable { - case none = "None" - case autoGenerate = "Auto Generate" - case promptAtConnect = "Prompt at Connect" +internal enum TOTPMode: String, CaseIterable, Identifiable, Codable { + case none = "none" + case autoGenerate = "auto_generate" + case promptAtConnect = "prompt_at_connect" var id: String { rawValue } @@ -23,7 +23,7 @@ enum TOTPMode: String, CaseIterable, Identifiable, Codable { } /// TOTP hash algorithm -enum TOTPAlgorithm: String, CaseIterable, Identifiable, Codable { +internal enum TOTPAlgorithm: String, CaseIterable, Identifiable, Codable { case sha1 = "SHA1" case sha256 = "SHA256" case sha512 = "SHA512" diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 2b47f0c1c..4fd464ba0 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -1181,6 +1181,7 @@ struct ConnectionFormView: View { let success = try await DatabaseManager.shared.testConnection( testConn, sshPassword: sshPassword) + ConnectionStorage.shared.deleteTOTPSecret(for: testConn.id) await MainActor.run { isTesting = false if success { @@ -1194,6 +1195,7 @@ struct ConnectionFormView: View { } } } catch { + ConnectionStorage.shared.deleteTOTPSecret(for: testConn.id) await MainActor.run { isTesting = false if case PluginError.pluginNotInstalled = error { diff --git a/TableProTests/Core/SSH/HostKeyStoreTests.swift b/TableProTests/Core/SSH/HostKeyStoreTests.swift index 002c557bc..98e16f058 100644 --- a/TableProTests/Core/SSH/HostKeyStoreTests.swift +++ b/TableProTests/Core/SSH/HostKeyStoreTests.swift @@ -6,9 +6,10 @@ // import Foundation -@testable import TablePro import Testing +@testable import TablePro + @Suite("HostKeyStore") struct HostKeyStoreTests { /// Create a temporary file path for test isolation @@ -170,7 +171,7 @@ struct HostKeyStoreTests { #expect(HostKeyStore.keyTypeName(99) == "unknown") } - @Test("Trusting the same host again updates the stored key") + @Test("Trusting the same host and key type again updates the stored key") func testTrustUpdatesExistingEntry() { let path = makeTempFilePath() defer { try? FileManager.default.removeItem(atPath: path) } @@ -182,9 +183,9 @@ struct HostKeyStoreTests { store.trust(hostname: "example.com", port: 22, key: oldKey, keyType: "ssh-rsa") #expect(store.verify(keyData: oldKey, keyType: "ssh-rsa", hostname: "example.com", port: 22) == .trusted) - // Trust with new key - store.trust(hostname: "example.com", port: 22, key: newKey, keyType: "ssh-ed25519") - #expect(store.verify(keyData: newKey, keyType: "ssh-ed25519", hostname: "example.com", port: 22) == .trusted) + // Trust with new key (same key type) + store.trust(hostname: "example.com", port: 22, key: newKey, keyType: "ssh-rsa") + #expect(store.verify(keyData: newKey, keyType: "ssh-rsa", hostname: "example.com", port: 22) == .trusted) // Old key should no longer match let result = store.verify(keyData: oldKey, keyType: "ssh-rsa", hostname: "example.com", port: 22) diff --git a/TableProTests/Core/SSH/TOTP/TOTPGeneratorTests.swift b/TableProTests/Core/SSH/TOTP/TOTPGeneratorTests.swift index ba3b11e80..5a348c38f 100644 --- a/TableProTests/Core/SSH/TOTP/TOTPGeneratorTests.swift +++ b/TableProTests/Core/SSH/TOTP/TOTPGeneratorTests.swift @@ -3,9 +3,10 @@ // TableProTests // -@testable import TablePro import XCTest +@testable import TablePro + final class TOTPGeneratorTests: XCTestCase { // MARK: - RFC 6238 SHA1 Test Vectors (8 digits) diff --git a/scripts/build-libssh2.sh b/scripts/build-libssh2.sh index 465849e04..f0df765de 100755 --- a/scripts/build-libssh2.sh +++ b/scripts/build-libssh2.sh @@ -206,10 +206,7 @@ build_for_arch() { build_openssl "$arch" build_libssh2 "$arch" install_libs "$arch" - # Install headers once (they're arch-independent) - if [ ! -f "$PROJECT_DIR/TablePro/Core/SSH/CLibSSH2/include/libssh2.h" ]; then - install_headers "$arch" - fi + install_headers "$arch" } verify_deployment_target() { @@ -225,7 +222,7 @@ verify_deployment_target() { min_ver=$(otool -l "$lib" 2>/dev/null | awk '/LC_VERSION_MIN_MACOSX/{found=1} found && /version/{print $2; found=0}' | sort -V | tail -1) fi if [ -n "$min_ver" ]; then - if [ "$(printf '%s\n' "$DEPLOY_TARGET" "$min_ver" | sort -V | head -1)" != "$DEPLOY_TARGET" ]; then + if [ "$(printf '%s\n' "$DEPLOY_TARGET" "$min_ver" | sort -V | tail -1)" != "$DEPLOY_TARGET" ]; then echo " ❌ $name targets macOS $min_ver (expected $DEPLOY_TARGET)" failed=1 else From 831a0784b1a03dbe77ffd89129e180e7a9c1aa6c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 14 Mar 2026 10:33:21 +0700 Subject: [PATCH 4/4] fix: address latest PR review comments --- TablePro/Core/SSH/Auth/AgentAuthenticator.swift | 6 +++--- .../Auth/KeyboardInteractiveAuthenticator.swift | 2 +- TablePro/Core/SSH/LibSSH2TunnelFactory.swift | 14 ++++++++++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/TablePro/Core/SSH/Auth/AgentAuthenticator.swift b/TablePro/Core/SSH/Auth/AgentAuthenticator.swift index 0844ed74d..946e53d3b 100644 --- a/TablePro/Core/SSH/Auth/AgentAuthenticator.swift +++ b/TablePro/Core/SSH/Auth/AgentAuthenticator.swift @@ -21,10 +21,10 @@ internal struct AgentAuthenticator: SSHAuthenticator { let originalSocketPath = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] let needsSocketOverride = socketPath != nil - if needsSocketOverride { + if let overridePath = socketPath, needsSocketOverride { Self.agentSocketLock.lock() - Self.logger.debug("Using custom SSH agent socket: \(socketPath!, privacy: .private)") - setenv("SSH_AUTH_SOCK", socketPath!, 1) + Self.logger.debug("Using custom SSH agent socket: \(overridePath, privacy: .private)") + setenv("SSH_AUTH_SOCK", overridePath, 1) } defer { diff --git a/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift b/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift index 6f8a47cc1..76785f2b4 100644 --- a/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift +++ b/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift @@ -77,7 +77,7 @@ private let kbdintCallback: @convention(c) ( responseText = context.password ?? "" } - let duplicated = strdup(responseText) + let duplicated = strdup(responseText) ?? strdup("") responses[i].text = duplicated responses[i].length = duplicated.map { UInt32(strlen($0)) } ?? 0 } diff --git a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift index 01098460c..55c1cbe3f 100644 --- a/TablePro/Core/SSH/LibSSH2TunnelFactory.swift +++ b/TablePro/Core/SSH/LibSSH2TunnelFactory.swift @@ -124,7 +124,14 @@ internal enum LibSSH2TunnelFactory { jumpHops.append(hop) // Create new session on fds[1] - let nextSession = try createSession(socketFD: fds[1]) + let nextSession: OpaquePointer + do { + nextSession = try createSession(socketFD: fds[1]) + } catch { + Darwin.close(fds[1]) + relayTask.cancel() + throw error + } do { // Verify host key for next hop @@ -535,7 +542,10 @@ internal enum LibSSH2TunnelFactory { throw SSHTunnelError.tunnelCreationFailed("Port \(port) already in use") } - listen(listenFD, 5) + guard listen(listenFD, 5) == 0 else { + Darwin.close(listenFD) + throw SSHTunnelError.tunnelCreationFailed("Failed to listen on port \(port)") + } return listenFD } }