Skip to content

Commit 377fbee

Browse files
committed
feat: add SSH TOTP/two-factor authentication support (#312)
1 parent 37a9a8e commit 377fbee

38 files changed

+5130
-528
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2727

2828
### Added
2929

30+
- SSH TOTP/two-factor authentication support (auto-generate and prompt modes)
31+
- SSH host key verification with fingerprint confirmation
32+
- Keyboard Interactive SSH authentication method
3033
- 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
3134
- `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
3235
- 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

Libs/libssh2.a

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
libssh2_universal.a

Libs/libssh2_arm64.a

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:166e0e23ce60fd2edcae38b6005de106394f7e2bc922a4944317d6aa576f284c
3+
size 367728

Libs/libssh2_universal.a

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:445b51e6fdaa0a0eceb8090e6d552a551ec15d91e4370a4cc356c8f561e8b469
3+
size 729032

Libs/libssh2_x86_64.a

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:76681299c4305273cea62e59cfa366ceb5cc320831b87fd6a06143d342f8b7db
3+
size 361256

TablePro.xcodeproj/project.pbxproj

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1715,9 +1715,19 @@
17151715
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
17161716
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
17171717
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
1718+
HEADER_SEARCH_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2/include";
17181719
MACOSX_DEPLOYMENT_TARGET = 14.0;
17191720
MARKETING_VERSION = 0.17.0;
1720-
OTHER_LDFLAGS = "-Wl,-w";
1721+
OTHER_LDFLAGS = (
1722+
"-Wl,-w",
1723+
"-force_load",
1724+
"$(PROJECT_DIR)/Libs/libssh2.a",
1725+
"-force_load",
1726+
"$(PROJECT_DIR)/Libs/libssl.a",
1727+
"-force_load",
1728+
"$(PROJECT_DIR)/Libs/libcrypto.a",
1729+
"-lz",
1730+
);
17211731
PRODUCT_BUNDLE_IDENTIFIER = com.TablePro;
17221732
PRODUCT_NAME = "$(TARGET_NAME)";
17231733
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1734,6 +1744,7 @@
17341744
SUPPORTS_MACCATALYST = NO;
17351745
SWIFT_APPROACHABLE_CONCURRENCY = YES;
17361746
SWIFT_EMIT_LOC_STRINGS = YES;
1747+
SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2";
17371748
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
17381749
SWIFT_VERSION = 5.9;
17391750
XROS_DEPLOYMENT_TARGET = 26.2;
@@ -1757,6 +1768,7 @@
17571768
DEAD_CODE_STRIPPING = YES;
17581769
DEPLOYMENT_POSTPROCESSING = YES;
17591770
DEVELOPMENT_TEAM = D7HJ5TFYCU;
1771+
HEADER_SEARCH_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2/include";
17601772
ENABLE_APP_SANDBOX = NO;
17611773
ENABLE_HARDENED_RUNTIME = YES;
17621774
ENABLE_PREVIEWS = YES;
@@ -1779,7 +1791,16 @@
17791791
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
17801792
MACOSX_DEPLOYMENT_TARGET = 14.0;
17811793
MARKETING_VERSION = 0.17.0;
1782-
OTHER_LDFLAGS = "-Wl,-w";
1794+
OTHER_LDFLAGS = (
1795+
"-Wl,-w",
1796+
"-force_load",
1797+
"$(PROJECT_DIR)/Libs/libssh2.a",
1798+
"-force_load",
1799+
"$(PROJECT_DIR)/Libs/libssl.a",
1800+
"-force_load",
1801+
"$(PROJECT_DIR)/Libs/libcrypto.a",
1802+
"-lz",
1803+
);
17831804
PRODUCT_BUNDLE_IDENTIFIER = com.TablePro;
17841805
PRODUCT_NAME = "$(TARGET_NAME)";
17851806
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1796,6 +1817,7 @@
17961817
SUPPORTS_MACCATALYST = NO;
17971818
SWIFT_APPROACHABLE_CONCURRENCY = YES;
17981819
SWIFT_EMIT_LOC_STRINGS = YES;
1820+
SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2";
17991821
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
18001822
SWIFT_VERSION = 5.9;
18011823
XROS_DEPLOYMENT_TARGET = 26.2;

TablePro/Core/Database/DatabaseManager.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -353,10 +353,11 @@ final class DatabaseManager {
353353

354354
// Load Keychain credentials off the main thread to avoid blocking UI
355355
let connectionId = connection.id
356-
let (storedSshPassword, keyPassphrase) = await Task.detached {
356+
let (storedSshPassword, keyPassphrase, totpSecret) = await Task.detached {
357357
let pwd = ConnectionStorage.shared.loadSSHPassword(for: connectionId)
358358
let phrase = ConnectionStorage.shared.loadKeyPassphrase(for: connectionId)
359-
return (pwd, phrase)
359+
let totp = ConnectionStorage.shared.loadTOTPSecret(for: connectionId)
360+
return (pwd, phrase, totp)
360361
}.value
361362

362363
let sshPassword = sshPasswordOverride ?? storedSshPassword
@@ -373,7 +374,12 @@ final class DatabaseManager {
373374
agentSocketPath: connection.sshConfig.agentSocketPath,
374375
remoteHost: connection.host,
375376
remotePort: connection.port,
376-
jumpHosts: connection.sshConfig.jumpHosts
377+
jumpHosts: connection.sshConfig.jumpHosts,
378+
totpMode: connection.sshConfig.totpMode,
379+
totpSecret: totpSecret,
380+
totpAlgorithm: connection.sshConfig.totpAlgorithm,
381+
totpDigits: connection.sshConfig.totpDigits,
382+
totpPeriod: connection.sshConfig.totpPeriod
377383
)
378384

379385
// Adapt SSL config for tunnel: SSH already authenticates the server,
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
//
2+
// AgentAuthenticator.swift
3+
// TablePro
4+
//
5+
6+
import CLibSSH2
7+
import Foundation
8+
import os
9+
10+
struct AgentAuthenticator: SSHAuthenticator {
11+
private static let logger = Logger(subsystem: "com.TablePro", category: "AgentAuthenticator")
12+
13+
let socketPath: String?
14+
15+
func authenticate(session: OpaquePointer, username: String) throws {
16+
// Save original SSH_AUTH_SOCK so we can restore it
17+
let originalSocketPath = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"]
18+
19+
if let socketPath {
20+
Self.logger.debug("Using custom SSH agent socket: \(socketPath, privacy: .private)")
21+
setenv("SSH_AUTH_SOCK", socketPath, 1)
22+
}
23+
24+
defer {
25+
// Restore original SSH_AUTH_SOCK
26+
if let originalSocketPath {
27+
setenv("SSH_AUTH_SOCK", originalSocketPath, 1)
28+
} else if socketPath != nil {
29+
unsetenv("SSH_AUTH_SOCK")
30+
}
31+
}
32+
33+
guard let agent = libssh2_agent_init(session) else {
34+
throw SSHTunnelError.tunnelCreationFailed("Failed to initialize SSH agent")
35+
}
36+
37+
defer {
38+
libssh2_agent_disconnect(agent)
39+
libssh2_agent_free(agent)
40+
}
41+
42+
var rc = libssh2_agent_connect(agent)
43+
guard rc == 0 else {
44+
Self.logger.error("Failed to connect to SSH agent (rc=\(rc))")
45+
throw SSHTunnelError.tunnelCreationFailed("Failed to connect to SSH agent")
46+
}
47+
48+
rc = libssh2_agent_list_identities(agent)
49+
guard rc == 0 else {
50+
Self.logger.error("Failed to list SSH agent identities (rc=\(rc))")
51+
throw SSHTunnelError.tunnelCreationFailed("Failed to list SSH agent identities")
52+
}
53+
54+
// Iterate through available identities and try each
55+
var previousIdentity: UnsafeMutablePointer<libssh2_agent_publickey>?
56+
var currentIdentity: UnsafeMutablePointer<libssh2_agent_publickey>?
57+
58+
while true {
59+
rc = libssh2_agent_get_identity(agent, &currentIdentity, previousIdentity)
60+
61+
if rc == 1 {
62+
// End of identity list, none worked
63+
break
64+
}
65+
if rc < 0 {
66+
Self.logger.error("Failed to get SSH agent identity (rc=\(rc))")
67+
throw SSHTunnelError.tunnelCreationFailed("Failed to get SSH agent identity")
68+
}
69+
70+
guard let identity = currentIdentity else {
71+
break
72+
}
73+
74+
let authRc = libssh2_agent_userauth(agent, username, identity)
75+
if authRc == 0 {
76+
Self.logger.info("SSH agent authentication succeeded")
77+
return
78+
}
79+
80+
previousIdentity = identity
81+
}
82+
83+
Self.logger.error("SSH agent authentication failed: no identity accepted")
84+
throw SSHTunnelError.authenticationFailed
85+
}
86+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//
2+
// CompositeAuthenticator.swift
3+
// TablePro
4+
//
5+
6+
import CLibSSH2
7+
import Foundation
8+
import os
9+
10+
/// Authenticator that tries multiple auth methods in sequence.
11+
/// Used for servers requiring e.g. password + keyboard-interactive (TOTP).
12+
struct CompositeAuthenticator: SSHAuthenticator {
13+
private static let logger = Logger(subsystem: "com.TablePro", category: "CompositeAuthenticator")
14+
15+
let authenticators: [any SSHAuthenticator]
16+
17+
func authenticate(session: OpaquePointer, username: String) throws {
18+
for (index, authenticator) in authenticators.enumerated() {
19+
Self.logger.debug(
20+
"Trying authenticator \(index + 1)/\(authenticators.count): \(String(describing: type(of: authenticator)))"
21+
)
22+
23+
try authenticator.authenticate(session: session, username: username)
24+
25+
if libssh2_userauth_authenticated(session) != 0 {
26+
Self.logger.info("Authentication succeeded after \(index + 1) step(s)")
27+
return
28+
}
29+
}
30+
31+
if libssh2_userauth_authenticated(session) == 0 {
32+
throw SSHTunnelError.authenticationFailed
33+
}
34+
}
35+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
//
2+
// KeyboardInteractiveAuthenticator.swift
3+
// TablePro
4+
//
5+
6+
import CLibSSH2
7+
import Foundation
8+
import os
9+
10+
/// Prompt type classification for keyboard-interactive authentication
11+
enum KBDINTPromptType {
12+
case password
13+
case totp
14+
case unknown
15+
}
16+
17+
/// Context passed through the libssh2 session abstract pointer to the C callback
18+
final class KeyboardInteractiveContext {
19+
var password: String?
20+
var totpCode: String?
21+
22+
init(password: String?, totpCode: String?) {
23+
self.password = password
24+
self.totpCode = totpCode
25+
}
26+
}
27+
28+
/// C-compatible callback for libssh2 keyboard-interactive authentication.
29+
///
30+
/// libssh2 calls this for each authentication challenge. The context (password/TOTP code)
31+
/// is retrieved from the session abstract pointer. Responses are allocated with `strdup`
32+
/// because libssh2 will `free` them.
33+
private let kbdintCallback: @convention(c) (
34+
UnsafePointer<CChar>?, Int32,
35+
UnsafePointer<CChar>?, Int32,
36+
Int32,
37+
UnsafePointer<LIBSSH2_USERAUTH_KBDINT_PROMPT>?,
38+
UnsafeMutablePointer<LIBSSH2_USERAUTH_KBDINT_RESPONSE>?,
39+
UnsafeMutablePointer<UnsafeMutableRawPointer?>?
40+
) -> Void = { _, _, _, _, numPrompts, prompts, responses, abstract in
41+
guard numPrompts > 0,
42+
let prompts,
43+
let responses,
44+
let abstract,
45+
let contextPtr = abstract.pointee else {
46+
return
47+
}
48+
49+
let context = Unmanaged<KeyboardInteractiveContext>.fromOpaque(contextPtr)
50+
.takeUnretainedValue()
51+
52+
for i in 0..<Int(numPrompts) {
53+
let prompt = prompts[i]
54+
let promptText: String
55+
if let textPtr = prompt.text, prompt.length > 0 {
56+
promptText = String(
57+
bytesNoCopy: UnsafeMutableRawPointer(mutating: textPtr),
58+
length: Int(prompt.length),
59+
encoding: .utf8,
60+
freeWhenDone: false
61+
) ?? ""
62+
} else {
63+
promptText = ""
64+
}
65+
66+
let promptType = KeyboardInteractiveAuthenticator.classifyPrompt(promptText)
67+
68+
let responseText: String
69+
switch promptType {
70+
case .password:
71+
responseText = context.password ?? ""
72+
case .totp:
73+
responseText = context.totpCode ?? ""
74+
case .unknown:
75+
// Fall back to password for unrecognized prompts
76+
responseText = context.password ?? ""
77+
}
78+
79+
let duplicated = strdup(responseText)
80+
responses[i].text = duplicated
81+
responses[i].length = UInt32(strlen(duplicated!))
82+
}
83+
}
84+
85+
struct KeyboardInteractiveAuthenticator: SSHAuthenticator {
86+
private static let logger = Logger(
87+
subsystem: "com.TablePro",
88+
category: "KeyboardInteractiveAuthenticator"
89+
)
90+
91+
let password: String?
92+
let totpProvider: (any TOTPProvider)?
93+
94+
func authenticate(session: OpaquePointer, username: String) throws {
95+
// Generate TOTP code if a provider is available
96+
let totpCode: String?
97+
if let totpProvider {
98+
totpCode = try totpProvider.provideCode()
99+
} else {
100+
totpCode = nil
101+
}
102+
103+
// Create context and store in session abstract pointer
104+
let context = KeyboardInteractiveContext(password: password, totpCode: totpCode)
105+
let contextPtr = Unmanaged.passRetained(context).toOpaque()
106+
107+
defer {
108+
// Balance the passRetained call
109+
Unmanaged<KeyboardInteractiveContext>.fromOpaque(contextPtr).release()
110+
}
111+
112+
// Store context pointer in the session's abstract field
113+
let abstractPtr = libssh2_session_abstract(session)
114+
let previousAbstract = abstractPtr?.pointee
115+
abstractPtr?.pointee = contextPtr
116+
117+
defer {
118+
// Restore previous abstract value
119+
abstractPtr?.pointee = previousAbstract
120+
}
121+
122+
Self.logger.debug("Attempting keyboard-interactive authentication for \(username, privacy: .private)")
123+
124+
let rc = libssh2_userauth_keyboard_interactive_ex(
125+
session,
126+
username, UInt32(username.utf8.count),
127+
kbdintCallback
128+
)
129+
130+
guard rc == 0 else {
131+
Self.logger.error("Keyboard-interactive authentication failed (rc=\(rc))")
132+
throw SSHTunnelError.authenticationFailed
133+
}
134+
135+
Self.logger.info("Keyboard-interactive authentication succeeded")
136+
}
137+
138+
/// Classify a keyboard-interactive prompt to determine which credential to supply
139+
static func classifyPrompt(_ promptText: String) -> KBDINTPromptType {
140+
let lower = promptText.lowercased()
141+
142+
if lower.contains("password") {
143+
return .password
144+
}
145+
146+
if lower.contains("verification") || lower.contains("code") ||
147+
lower.contains("otp") || lower.contains("token") ||
148+
lower.contains("totp") || lower.contains("2fa") ||
149+
lower.contains("one-time") || lower.contains("factor") {
150+
return .totp
151+
}
152+
153+
return .unknown
154+
}
155+
}

0 commit comments

Comments
 (0)