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