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
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//
// KeyboardInteractivePromptKind.swift
// TableProSSHTransport
//

import Foundation

public enum KeyboardInteractivePromptKind: Equatable, Sendable {
case password
case totp
case unmatched

// The code words go first: "One-time password: " asks for a code, not the SSH password.
public init(promptText: String) {
let lower = promptText.lowercased()
if Self.codeKeywords.contains(where: { lower.contains($0) }) {
self = .totp
} else if lower.contains("password") {
self = .password
} else {
self = .unmatched
}
}

private static let codeKeywords = ["verification", "code", "otp", "token", "totp", "2fa", "one-time", "factor"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// SSHAuthMethods.swift
// TableProSSHTransport
//

import Foundation

public enum SSHAuthMethods {
public static func parse(_ list: String?) -> [String] {
guard let list else { return [] }
return list
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}

// libssh2 never reads the partial-success flag, so an accepted method that needs another step
// shows only as that method leaving the server's list.
public static func remainingAfterPartialSuccess(of method: String, before: [String], after: [String]) -> [String]? {
guard before.contains(method), !after.isEmpty, !after.contains(method) else { return nil }
return after
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//
// SSHChannelFreePump.swift
// TableProSSHTransport
//

import Foundation

public enum SSHChannelFreeAttempt: Equatable, Sendable {
case finished
case wouldBlock(RelayDirections)
}

public enum SSHChannelFreeOutcome: Equatable, Sendable {
case freed
case stopped
case timedOut
}

// libssh2 keeps a CLOSE it could not send as the session's next packet and turns every other send
// away until the same free sends it, so a free never stops after a fixed number of tries: it runs
// until it finishes, the tunnel stops, or the deadline says the session is stuck.
public struct SSHChannelFreePump {
public let attemptFree: () -> SSHChannelFreeAttempt
public let isActive: () -> Bool
public let deadline: Date
public let waitForTransport: (RelayDirections) -> Void
public var now: () -> Date

public init(
attemptFree: @escaping () -> SSHChannelFreeAttempt,
isActive: @escaping () -> Bool,
deadline: Date,
waitForTransport: @escaping (RelayDirections) -> Void,
now: @escaping () -> Date = Date.init
) {
self.attemptFree = attemptFree
self.isActive = isActive
self.deadline = deadline
self.waitForTransport = waitForTransport
self.now = now
}

public func run() -> SSHChannelFreeOutcome {
while true {
guard case .wouldBlock(let directions) = attemptFree() else { return .freed }
guard isActive() else { return .stopped }
guard now() < deadline else { return .timedOut }
waitForTransport(directions)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,15 @@ public enum ChannelReadResult: Equatable, Sendable {
case bytes(Int)
case wouldBlock
case closed
// The session is gone, not only this channel: the relay ends .transportHangup so the owner marks the tunnel dead now, not at the next keep-alive.
case transportFailed
}

public enum ChannelWriteResult: Equatable, Sendable {
case bytes(Int)
case wouldBlock
case closed
case transportFailed
}

public protocol SSHChannelIO {
Expand All @@ -41,13 +44,7 @@ public enum RelayTermination: Equatable, Sendable {
case cancelled
}

/// Bidirectional relay between a local socket fd and an SSH channel.
/// Polls the local fd and the SSH transport fd, draining buffered data before
/// tearing down on hangup so the last bytes are not dropped. Hangup and error
/// on either fd terminate the loop instead of spinning on a permanently
/// poll-ready, closed fd. Because libssh2 reports EAGAIN rather than a channel
/// close when only the transport dies, a transport that polls readable but is
/// at EOF is detected directly so a half-closed transport cannot spin.
// A hangup drains buffered data before the loop ends, so the last bytes reach the client.
public struct SSHChannelRelay {
public let localFD: Int32
public let transportFD: Int32
Expand Down Expand Up @@ -164,6 +161,8 @@ public struct SSHChannelRelay {
return .drained
case .closed:
return .channelClosed
case .transportFailed:
return .transportHangup
}
}
return .cappedWithMoreQueued
Expand All @@ -172,7 +171,7 @@ public struct SSHChannelRelay {
private func sendToLocal(_ buffer: UnsafeMutablePointer<CChar>, count: Int) -> Bool {
var totalSent = 0
while totalSent < count {
// Without the flag, a send to a peer that closed first raises SIGPIPE and kills the app.
// Per send, not SO_NOSIGPIPE: setsockopt fails with EINVAL once a client has already closed, and the send then raises SIGPIPE.
let sent = send(localFD, buffer.advanced(by: totalSent), count - totalSent, MSG_NOSIGNAL)
if sent <= 0 { return false }
totalSent += sent
Expand All @@ -197,6 +196,8 @@ public struct SSHChannelRelay {
}
case .closed:
return .channelClosed
case .transportFailed:
return .transportHangup
}
}
return .drained
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//
// SSHNegotiationFailure.swift
// TableProSSHTransport
//

import Foundation

public struct SSHNegotiationFailure: Equatable, Sendable {
public enum Stage: Equatable, Sendable {
case keyExchangeOrHostKey
case cipher
case mac
case undetermined
}

public let stage: Stage
public let serverSoftware: String?
public let supported: [String]
public let supportedHostKeys: [String]

public init(stage: Stage, serverSoftware: String?, supported: [String], supportedHostKeys: [String]) {
self.stage = stage
self.serverSoftware = serverSoftware
self.supported = supported
self.supportedHostKeys = supportedHostKeys
}

// libssh2 records a key exchange only once one with a usable host key agrees, so a nil one cannot tell the two apart.
public static func stage(kex: String?, cipher: String?, mac: String?) -> Stage {
if kex == nil { return .keyExchangeOrHostKey }
if cipher == nil { return .cipher }
if mac == nil { return .mac }
return .undetermined
}

public static func serverSoftware(banner: String?) -> String? {
guard let line = banner?.trimmingCharacters(in: .whitespacesAndNewlines), line.hasPrefix("SSH-") else {
return nil
}
let fields = line.split(separator: "-", maxSplits: 2, omittingEmptySubsequences: false)
guard fields.count == 3 else { return nil }
let software = fields[2].prefix { !$0.isWhitespace }
return software.isEmpty ? nil : String(software)
}

public static func clientMethods(_ names: [String]) -> [String] {
names.filter { !pseudoMethods.contains($0) }
}

// Extension negotiation and strict key exchange ride in the key exchange list but no server can pick them.
private static let pseudoMethods: Set<String> = ["ext-info-c", "kex-strict-c-v00@openssh.com"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//
// SSHPrivateKeyTraits.swift
// TableProSSHTransport
//

import Foundation

// openssh-key-v1 keeps the cipher name and the public key in the clear, so both read without the passphrase.
public struct SSHPrivateKeyTraits: Equatable, Sendable {
public let keyType: String?
public let isEncrypted: Bool

public var isSecurityKey: Bool {
keyType?.hasPrefix("sk-") ?? false
}

public init?(openSSH: String) {
guard let begin = openSSH.range(of: Self.beginMarker),
let end = openSSH.range(of: Self.endMarker, range: begin.upperBound..<openSSH.endIndex)
else { return nil }
let body = openSSH[begin.upperBound..<end.lowerBound].filter { !$0.isWhitespace }
guard let decoded = Data(base64Encoded: String(body)) else { return nil }

var reader = SSHWireReader(Array(decoded))
guard reader.bytes(Self.magic.count)?.elementsEqual(Self.magic) == true,
let cipher = reader.string(), !cipher.isEmpty
else { return nil }

isEncrypted = cipher != "none"
keyType = Self.firstKeyType(after: &reader)
}

private static let beginMarker = "-----BEGIN OPENSSH PRIVATE KEY-----"
private static let endMarker = "-----END OPENSSH PRIVATE KEY-----"
private static let magic = Array("openssh-key-v1\0".utf8)

// The KDF name and options sit between the cipher and the key count.
private static func firstKeyType(after reader: inout SSHWireReader) -> String? {
guard reader.field() != nil,
reader.field() != nil,
let keyCount = reader.uint32(), keyCount > 0,
let publicKey = reader.field()
else { return nil }
var blob = SSHWireReader(Array(publicKey))
return blob.string()
}
}

private struct SSHWireReader {
private let data: [UInt8]
private var offset = 0

init(_ data: [UInt8]) {
self.data = data
}

mutating func bytes(_ count: Int) -> ArraySlice<UInt8>? {
guard count >= 0, count <= data.count - offset else { return nil }
defer { offset += count }
return data[offset..<offset + count]
}

mutating func uint32() -> Int? {
bytes(4)?.reduce(0) { ($0 << 8) | Int($1) }
}

mutating func field() -> ArraySlice<UInt8>? {
guard let length = uint32() else { return nil }
return bytes(length)
}

mutating func string() -> String? {
field().flatMap { String(bytes: $0, encoding: .utf8) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// KeyboardInteractivePromptKindTests.swift
// TableProSSHTransportTests
//

import Foundation
import Testing

@testable import TableProSSHTransport

struct KeyboardInteractivePromptKindTests {
@Test("A password prompt is the SSH password", arguments: ["Password: ", "PASSWORD:", "(tablepro@127.0.0.1) Password: "])
func passwordPrompt(text: String) {
#expect(KeyboardInteractivePromptKind(promptText: text) == .password)
}

@Test(
"A prompt naming a code is a TOTP prompt",
arguments: [
"Verification code: ", "Enter OTP: ", "Token: ", "TOTP: ", "2FA: ", "One-time code: ", "Second factor: ",
]
)
func codePrompt(text: String) {
#expect(KeyboardInteractivePromptKind(promptText: text) == .totp)
}

@Test("One-time password asks for a code, not the SSH password")
func oneTimePasswordIsACode() {
#expect(KeyboardInteractivePromptKind(promptText: "One-time password: ") == .totp)
#expect(KeyboardInteractivePromptKind(promptText: "Password and verification code: ") == .totp)
}

@Test("A prompt with no known word is unmatched", arguments: ["Enter your PIN: ", "Response: ", ""])
func unmatchedPrompt(text: String) {
#expect(KeyboardInteractivePromptKind(promptText: text) == .unmatched)
}
}
Loading
Loading