Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 10 additions & 27 deletions TablePro/Core/SSH/Auth/AgentAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,6 @@ 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
private static let agentSocketLock = NSLock()

let socketPath: String?

/// Resolve SSH_AUTH_SOCK via launchctl for GUI apps that don't inherit shell env.
Expand All @@ -40,42 +37,19 @@ internal struct AgentAuthenticator: SSHAuthenticator {
}

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"]

// Resolve the effective socket path:
// - Custom path: use it directly
// - System default (nil): use process env, or fall back to launchctl
// (GUI apps launched from Finder may not inherit SSH_AUTH_SOCK)
let effectivePath: String?
if let customPath = socketPath {
effectivePath = SSHPathUtilities.expandTilde(customPath)
} else if originalSocketPath != nil {
} else if ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] != nil {
effectivePath = nil // already set in process env
} else {
effectivePath = Self.resolveSocketViaLaunchctl()
}

let needsSocketOverride = effectivePath != nil

if let overridePath = effectivePath, needsSocketOverride {
Self.agentSocketLock.lock()
Self.logger.debug("Setting SSH_AUTH_SOCK: \(overridePath, privacy: .private)")
setenv("SSH_AUTH_SOCK", overridePath, 1)
}

defer {
if needsSocketOverride {
// Restore original SSH_AUTH_SOCK
if let originalSocketPath {
setenv("SSH_AUTH_SOCK", originalSocketPath, 1)
} else {
unsetenv("SSH_AUTH_SOCK")
}
Self.agentSocketLock.unlock()
}
}

guard let agent = libssh2_agent_init(session) else {
throw SSHTunnelError.tunnelCreationFailed("Failed to initialize SSH agent")
}
Expand All @@ -85,6 +59,15 @@ internal struct AgentAuthenticator: SSHAuthenticator {
libssh2_agent_free(agent)
}

// Use libssh2's API to set the socket path directly — avoids mutating
// the process-global SSH_AUTH_SOCK environment variable.
if let path = effectivePath {
Self.logger.debug("Setting agent socket path: \(path, privacy: .private)")
path.withCString { cPath in
libssh2_agent_set_identity_path(agent, cPath)
}
}

var rc = libssh2_agent_connect(agent)
guard rc == 0 else {
Self.logger.error("Failed to connect to SSH agent (rc=\(rc))")
Expand Down
89 changes: 89 additions & 0 deletions TablePro/Core/SSH/Auth/PromptPassphraseProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//
// PromptPassphraseProvider.swift
// TablePro
//
// Prompts the user for an SSH key passphrase via a modal NSAlert dialog.
// Optionally offers to save the passphrase to the macOS Keychain,
// matching the native ssh-add --apple-use-keychain behavior.
//

import AppKit
import Foundation

internal struct PassphrasePromptResult: Sendable {
let passphrase: String
let saveToKeychain: Bool
}

internal final class PromptPassphraseProvider: @unchecked Sendable {
private let keyPath: String

init(keyPath: String) {
self.keyPath = keyPath
}

func providePassphrase() -> PassphrasePromptResult? {
if Thread.isMainThread {
return showAlert()
}

let semaphore = DispatchSemaphore(value: 0)
var result: PassphrasePromptResult?
DispatchQueue.main.async {
result = self.showAlert()
semaphore.signal()
}
let waitResult = semaphore.wait(timeout: .now() + 120)
guard waitResult == .success else { return nil }
return result
}

private func showAlert() -> PassphrasePromptResult? {
let alert = NSAlert()
alert.messageText = String(localized: "SSH Key Passphrase Required")
let keyName = (keyPath as NSString).lastPathComponent
alert.informativeText = String(
format: String(localized: "Enter the passphrase for SSH key \"%@\":"),
keyName
)
alert.alertStyle = .informational
alert.addButton(withTitle: String(localized: "Connect"))
alert.addButton(withTitle: String(localized: "Cancel"))

let width: CGFloat = 260
let fieldHeight: CGFloat = 22
let checkboxHeight: CGFloat = 18
let spacing: CGFloat = 8
let totalHeight = fieldHeight + spacing + checkboxHeight

let container = NSView(frame: NSRect(x: 0, y: 0, width: width, height: totalHeight))

let textField = NSSecureTextField(frame: NSRect(
x: 0, y: checkboxHeight + spacing,
width: width, height: fieldHeight
))
textField.placeholderString = String(localized: "Passphrase")
container.addSubview(textField)

let checkbox = NSButton(
checkboxWithTitle: String(localized: "Save passphrase in Keychain"),
target: nil,
action: nil
)
checkbox.frame = NSRect(x: 0, y: 0, width: width, height: checkboxHeight)
checkbox.state = .on
container.addSubview(checkbox)

alert.accessoryView = container
alert.window.initialFirstResponder = textField

let response = alert.runModal()
guard response == .alertFirstButtonReturn,
!textField.stringValue.isEmpty else { return nil }

return PassphrasePromptResult(
passphrase: textField.stringValue,
saveToKeychain: checkbox.state == .on
)
}
}
4 changes: 4 additions & 0 deletions TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
// PublicKeyAuthenticator.swift
// TablePro
//
// Pure libssh2 public key authenticator. Takes a path and passphrase,
// performs authentication. No UI, no Keychain, no prompts — those
// responsibilities belong to SSHPassphraseResolver at the factory level.
//

import Foundation

Expand Down
96 changes: 96 additions & 0 deletions TablePro/Core/SSH/Auth/SSHKeychainLookup.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//
// SSHKeychainLookup.swift
// TablePro
//
// Queries the user's login Keychain for SSH key passphrases stored by
// `ssh-add --apple-use-keychain`. Uses the same item format as the
// native OpenSSH tools (service="OpenSSH", label="SSH: /path/to/key").
//
// Confirmed via `strings /usr/bin/ssh-add`: "SSH: %@", "OpenSSH",
// "com.apple.ssh.passphrases".
//
// Uses kSecUseDataProtectionKeychain=false to query the legacy file-based
// keychain (login.keychain-db) where macOS SSH stores passphrases, without
// triggering the System keychain admin password prompt.
//

import Foundation
import os
import Security

internal enum SSHKeychainLookup {
private static let logger = Logger(subsystem: "com.TablePro", category: "SSHKeychainLookup")
private static let keychainService = "OpenSSH"

/// Look up a passphrase stored by `ssh-add --apple-use-keychain`.
static func loadPassphrase(forKeyAt absolutePath: String) -> String? {
let label = "SSH: \(absolutePath)"

let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrLabel as String: label,
kSecUseDataProtectionKeychain as String: false,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]

var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)

switch status {
case errSecSuccess:
guard let data = result as? Data,
let passphrase = String(data: data, encoding: .utf8) else {
return nil
}
logger.debug("Found SSH passphrase in macOS Keychain for \(absolutePath, privacy: .private)")
return passphrase

case errSecItemNotFound:
return nil

case errSecAuthFailed, errSecInteractionNotAllowed:
logger.warning("Keychain access denied for SSH passphrase lookup (status \(status))")
return nil

default:
logger.warning("Keychain query failed with status \(status)")
return nil
}
}

/// Save a passphrase in the same format as `ssh-add --apple-use-keychain`.
static func savePassphrase(_ passphrase: String, forKeyAt absolutePath: String) {
let label = "SSH: \(absolutePath)"
guard let data = passphrase.data(using: .utf8) else { return }

let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrLabel as String: label,
kSecAttrService as String: keychainService,
kSecUseDataProtectionKeychain as String: false,
kSecValueData as String: data
]

let status = SecItemAdd(query as CFDictionary, nil)

if status == errSecDuplicateItem {
let updateQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrLabel as String: label,
kSecUseDataProtectionKeychain as String: false
]
let updateAttrs: [String: Any] = [
kSecValueData as String: data
]
let updateStatus = SecItemUpdate(updateQuery as CFDictionary, updateAttrs as CFDictionary)
if updateStatus != errSecSuccess {
logger.warning("Failed to update SSH passphrase in Keychain (status \(updateStatus))")
}
} else if status != errSecSuccess {
logger.warning("Failed to save SSH passphrase to Keychain (status \(status))")
}
}
}
46 changes: 46 additions & 0 deletions TablePro/Core/SSH/Auth/SSHPassphraseResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// SSHPassphraseResolver.swift
// TablePro
//
// Resolves SSH key passphrases from non-interactive sources.
// Chain: provided (TablePro Keychain) → macOS SSH Keychain.
// Interactive prompting is handled by the caller (KeyFileAuthenticator)
// after a first authentication attempt fails.
//

import Foundation
import os

internal enum SSHPassphraseResolver {
private static let logger = Logger(subsystem: "com.TablePro", category: "SSHPassphraseResolver")

/// Resolve passphrase from non-interactive sources only.
///
/// 1. `provided` passphrase (from TablePro Keychain, passed by caller)
/// 2. macOS SSH Keychain (where `ssh-add --apple-use-keychain` stores passphrases)
///
/// Returns nil if no passphrase is found — the caller should try auth
/// with nil (for unencrypted keys) and prompt interactively if that fails.
static func resolve(
forKeyAt keyPath: String,
provided: String?,
useKeychain: Bool = true
) -> String? {
let expandedPath = SSHPathUtilities.expandTilde(keyPath)

// 1. Use provided passphrase from TablePro's own Keychain
if let provided, !provided.isEmpty {
logger.debug("Using provided passphrase for \(expandedPath, privacy: .private)")
return provided
}

// 2. Check macOS SSH Keychain (ssh-add --apple-use-keychain format)
if useKeychain,
let systemPassphrase = SSHKeychainLookup.loadPassphrase(forKeyAt: expandedPath) {
logger.debug("Found passphrase in macOS Keychain for \(expandedPath, privacy: .private)")
return systemPassphrase
}

return nil
}
}
Loading
Loading