Skip to content

Commit e406a97

Browse files
authored
feat: add SSH TOTP/two-factor authentication support (#315)
* feat: add SSH TOTP/two-factor authentication support (#312) * fix: address code review issues in SSH TOTP implementation * fix: address all PR review comments * fix: address latest PR review comments --------- Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
1 parent 8a15584 commit e406a97

38 files changed

Lines changed: 5216 additions & 529 deletions

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: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1777,9 +1777,19 @@
17771777
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
17781778
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
17791779
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
1780+
HEADER_SEARCH_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2/include";
17801781
MACOSX_DEPLOYMENT_TARGET = 14.0;
17811782
MARKETING_VERSION = 0.17.0;
1782-
OTHER_LDFLAGS = "-Wl,-w";
1783+
OTHER_LDFLAGS = (
1784+
"-Wl,-w",
1785+
"-force_load",
1786+
"$(PROJECT_DIR)/Libs/libssh2.a",
1787+
"-force_load",
1788+
"$(PROJECT_DIR)/Libs/libssl.a",
1789+
"-force_load",
1790+
"$(PROJECT_DIR)/Libs/libcrypto.a",
1791+
"-lz",
1792+
);
17831793
PRODUCT_BUNDLE_IDENTIFIER = com.TablePro;
17841794
PRODUCT_NAME = "$(TARGET_NAME)";
17851795
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1796,6 +1806,7 @@
17961806
SUPPORTS_MACCATALYST = NO;
17971807
SWIFT_APPROACHABLE_CONCURRENCY = YES;
17981808
SWIFT_EMIT_LOC_STRINGS = YES;
1809+
SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2";
17991810
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
18001811
SWIFT_VERSION = 5.9;
18011812
XROS_DEPLOYMENT_TARGET = 26.2;
@@ -1818,7 +1829,8 @@
18181829
CURRENT_PROJECT_VERSION = 31;
18191830
DEAD_CODE_STRIPPING = YES;
18201831
DEPLOYMENT_POSTPROCESSING = YES;
1821-
DEVELOPMENT_TEAM = "";
1832+
DEVELOPMENT_TEAM = D7HJ5TFYCU;
1833+
HEADER_SEARCH_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2/include";
18221834
ENABLE_APP_SANDBOX = NO;
18231835
ENABLE_HARDENED_RUNTIME = YES;
18241836
ENABLE_PREVIEWS = YES;
@@ -1841,7 +1853,16 @@
18411853
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
18421854
MACOSX_DEPLOYMENT_TARGET = 14.0;
18431855
MARKETING_VERSION = 0.17.0;
1844-
OTHER_LDFLAGS = "-Wl,-w";
1856+
OTHER_LDFLAGS = (
1857+
"-Wl,-w",
1858+
"-force_load",
1859+
"$(PROJECT_DIR)/Libs/libssh2.a",
1860+
"-force_load",
1861+
"$(PROJECT_DIR)/Libs/libssl.a",
1862+
"-force_load",
1863+
"$(PROJECT_DIR)/Libs/libcrypto.a",
1864+
"-lz",
1865+
);
18451866
PRODUCT_BUNDLE_IDENTIFIER = com.TablePro;
18461867
PRODUCT_NAME = "$(TARGET_NAME)";
18471868
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1858,6 +1879,7 @@
18581879
SUPPORTS_MACCATALYST = NO;
18591880
SWIFT_APPROACHABLE_CONCURRENCY = YES;
18601881
SWIFT_EMIT_LOC_STRINGS = YES;
1882+
SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2";
18611883
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
18621884
SWIFT_VERSION = 5.9;
18631885
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: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
//
2+
// AgentAuthenticator.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import os
8+
9+
import CLibSSH2
10+
11+
internal struct AgentAuthenticator: SSHAuthenticator {
12+
private static let logger = Logger(subsystem: "com.TablePro", category: "AgentAuthenticator")
13+
14+
/// Protects setenv/unsetenv of SSH_AUTH_SOCK across concurrent tunnel setups
15+
private static let agentSocketLock = NSLock()
16+
17+
let socketPath: String?
18+
19+
func authenticate(session: OpaquePointer, username: String) throws {
20+
// Save original SSH_AUTH_SOCK so we can restore it
21+
let originalSocketPath = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"]
22+
let needsSocketOverride = socketPath != nil
23+
24+
if let overridePath = socketPath, needsSocketOverride {
25+
Self.agentSocketLock.lock()
26+
Self.logger.debug("Using custom SSH agent socket: \(overridePath, privacy: .private)")
27+
setenv("SSH_AUTH_SOCK", overridePath, 1)
28+
}
29+
30+
defer {
31+
if needsSocketOverride {
32+
// Restore original SSH_AUTH_SOCK
33+
if let originalSocketPath {
34+
setenv("SSH_AUTH_SOCK", originalSocketPath, 1)
35+
} else {
36+
unsetenv("SSH_AUTH_SOCK")
37+
}
38+
Self.agentSocketLock.unlock()
39+
}
40+
}
41+
42+
guard let agent = libssh2_agent_init(session) else {
43+
throw SSHTunnelError.tunnelCreationFailed("Failed to initialize SSH agent")
44+
}
45+
46+
defer {
47+
libssh2_agent_disconnect(agent)
48+
libssh2_agent_free(agent)
49+
}
50+
51+
var rc = libssh2_agent_connect(agent)
52+
guard rc == 0 else {
53+
Self.logger.error("Failed to connect to SSH agent (rc=\(rc))")
54+
throw SSHTunnelError.tunnelCreationFailed("Failed to connect to SSH agent")
55+
}
56+
57+
rc = libssh2_agent_list_identities(agent)
58+
guard rc == 0 else {
59+
Self.logger.error("Failed to list SSH agent identities (rc=\(rc))")
60+
throw SSHTunnelError.tunnelCreationFailed("Failed to list SSH agent identities")
61+
}
62+
63+
// Iterate through available identities and try each
64+
var previousIdentity: UnsafeMutablePointer<libssh2_agent_publickey>?
65+
var currentIdentity: UnsafeMutablePointer<libssh2_agent_publickey>?
66+
67+
while true {
68+
rc = libssh2_agent_get_identity(agent, &currentIdentity, previousIdentity)
69+
70+
if rc == 1 {
71+
// End of identity list, none worked
72+
break
73+
}
74+
if rc < 0 {
75+
Self.logger.error("Failed to get SSH agent identity (rc=\(rc))")
76+
throw SSHTunnelError.tunnelCreationFailed("Failed to get SSH agent identity")
77+
}
78+
79+
guard let identity = currentIdentity else {
80+
break
81+
}
82+
83+
let authRc = libssh2_agent_userauth(agent, username, identity)
84+
if authRc == 0 {
85+
Self.logger.info("SSH agent authentication succeeded")
86+
return
87+
}
88+
89+
previousIdentity = identity
90+
}
91+
92+
Self.logger.error("SSH agent authentication failed: no identity accepted")
93+
throw SSHTunnelError.authenticationFailed
94+
}
95+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//
2+
// CompositeAuthenticator.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import os
8+
9+
import CLibSSH2
10+
11+
/// Authenticator that tries multiple auth methods in sequence.
12+
/// Used for servers requiring e.g. password + keyboard-interactive (TOTP).
13+
internal struct CompositeAuthenticator: SSHAuthenticator {
14+
private static let logger = Logger(subsystem: "com.TablePro", category: "CompositeAuthenticator")
15+
16+
let authenticators: [any SSHAuthenticator]
17+
18+
func authenticate(session: OpaquePointer, username: String) throws {
19+
var lastError: Error?
20+
for (index, authenticator) in authenticators.enumerated() {
21+
Self.logger.debug("Trying authenticator \(index + 1)/\(authenticators.count)")
22+
do {
23+
try authenticator.authenticate(session: session, username: username)
24+
} catch {
25+
Self.logger.debug("Authenticator \(index + 1) failed: \(error)")
26+
lastError = error
27+
}
28+
29+
if libssh2_userauth_authenticated(session) != 0 {
30+
Self.logger.info("Authentication succeeded after \(index + 1) step(s)")
31+
return
32+
}
33+
}
34+
35+
if libssh2_userauth_authenticated(session) == 0 {
36+
throw lastError ?? SSHTunnelError.authenticationFailed
37+
}
38+
}
39+
}

0 commit comments

Comments
 (0)