diff --git a/apple/Packages/TableProCore/Sources/TableProSSHTransport/KeyboardInteractivePromptKind.swift b/apple/Packages/TableProCore/Sources/TableProSSHTransport/KeyboardInteractivePromptKind.swift new file mode 100644 index 0000000000..a4eb0f18ad --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProSSHTransport/KeyboardInteractivePromptKind.swift @@ -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"] +} diff --git a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHAuthMethods.swift b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHAuthMethods.swift new file mode 100644 index 0000000000..88abd6f4d1 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHAuthMethods.swift @@ -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 + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHChannelFreePump.swift b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHChannelFreePump.swift new file mode 100644 index 0000000000..90f224b426 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHChannelFreePump.swift @@ -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) + } + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHChannelRelay.swift b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHChannelRelay.swift index a18e3ce1a6..ea3e7cc078 100644 --- a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHChannelRelay.swift +++ b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHChannelRelay.swift @@ -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 { @@ -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 @@ -164,6 +161,8 @@ public struct SSHChannelRelay { return .drained case .closed: return .channelClosed + case .transportFailed: + return .transportHangup } } return .cappedWithMoreQueued @@ -172,7 +171,7 @@ public struct SSHChannelRelay { private func sendToLocal(_ buffer: UnsafeMutablePointer, 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 @@ -197,6 +196,8 @@ public struct SSHChannelRelay { } case .closed: return .channelClosed + case .transportFailed: + return .transportHangup } } return .drained diff --git a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHNegotiationFailure.swift b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHNegotiationFailure.swift new file mode 100644 index 0000000000..2e9fb8e9ac --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHNegotiationFailure.swift @@ -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 = ["ext-info-c", "kex-strict-c-v00@openssh.com"] +} diff --git a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHPrivateKeyTraits.swift b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHPrivateKeyTraits.swift new file mode 100644 index 0000000000..52d3d5e659 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHPrivateKeyTraits.swift @@ -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.. 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? { + guard count >= 0, count <= data.count - offset else { return nil } + defer { offset += count } + return data[offset.. Int? { + bytes(4)?.reduce(0) { ($0 << 8) | Int($1) } + } + + mutating func field() -> ArraySlice? { + guard let length = uint32() else { return nil } + return bytes(length) + } + + mutating func string() -> String? { + field().flatMap { String(bytes: $0, encoding: .utf8) } + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/KeyboardInteractivePromptKindTests.swift b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/KeyboardInteractivePromptKindTests.swift new file mode 100644 index 0000000000..93f0e5b84f --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/KeyboardInteractivePromptKindTests.swift @@ -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) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHAuthMethodsTests.swift b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHAuthMethodsTests.swift new file mode 100644 index 0000000000..6f16acfcf5 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHAuthMethodsTests.swift @@ -0,0 +1,79 @@ +// +// SSHAuthMethodsTests.swift +// TableProSSHTransportTests +// + +import Foundation +import Testing + +@testable import TableProSSHTransport + +struct SSHAuthMethodsTests { + @Test("The server's list splits on commas in its order") + func parsesTheList() { + #expect(SSHAuthMethods.parse("publickey,password,keyboard-interactive") == [ + "publickey", "password", "keyboard-interactive", + ]) + } + + @Test("No list, an empty one, or stray separators give no methods") + func parsesNothing() { + #expect(SSHAuthMethods.parse(nil).isEmpty) + #expect(SSHAuthMethods.parse("").isEmpty) + #expect(SSHAuthMethods.parse(",").isEmpty) + } + + @Test("Blank entries and spaces are dropped") + func dropsBlankEntries() { + #expect(SSHAuthMethods.parse("publickey,, password ") == ["publickey", "password"]) + } + + @Test("A password accepted with a second step leaves only the next method") + func passwordThenCode() { + let remaining = SSHAuthMethods.remainingAfterPartialSuccess( + of: "password", + before: ["password"], + after: ["keyboard-interactive"] + ) + + #expect(remaining == ["keyboard-interactive"]) + } + + @Test("A key accepted with a second step returns every method still on offer") + func keyThenOthers() { + let remaining = SSHAuthMethods.remainingAfterPartialSuccess( + of: "publickey", + before: ["publickey"], + after: ["password", "keyboard-interactive"] + ) + + #expect(remaining == ["password", "keyboard-interactive"]) + } + + @Test("A rejected method stays on the list, so it is not partial success") + func rejectedMethodStaysListed() { + let remaining = SSHAuthMethods.remainingAfterPartialSuccess( + of: "password", + before: ["publickey", "password"], + after: ["publickey", "password"] + ) + + #expect(remaining == nil) + } + + @Test("An unreadable list after the failure, as after a disconnect, is not partial success") + func emptyListAfter() { + #expect(SSHAuthMethods.remainingAfterPartialSuccess(of: "password", before: ["password"], after: []) == nil) + } + + @Test("A method the server never offered cannot have partly succeeded") + func methodNotOfferedBefore() { + let remaining = SSHAuthMethods.remainingAfterPartialSuccess( + of: "keyboard-interactive", + before: ["password"], + after: ["publickey"] + ) + + #expect(remaining == nil) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHChannelFreePumpTests.swift b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHChannelFreePumpTests.swift new file mode 100644 index 0000000000..7a09507085 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHChannelFreePumpTests.swift @@ -0,0 +1,106 @@ +// +// SSHChannelFreePumpTests.swift +// TableProSSHTransportTests +// + +import Foundation +import Testing + +@testable import TableProSSHTransport + +struct SSHChannelFreePumpTests { + @Test("A free that finishes at once does not wait") + func freesWithoutWaiting() { + let free = ScriptedFree([.finished]) + let waits = WaitLog() + + let outcome = SSHChannelFreePump( + attemptFree: free.next, + isActive: { true }, + deadline: Date().addingTimeInterval(60), + waitForTransport: waits.record + ).run() + + #expect(outcome == .freed) + #expect(free.attempts == 1) + #expect(waits.directions.isEmpty) + } + + @Test("A free that would block runs again after each wait, past any fixed number of tries") + func retriesUntilFreed() { + let free = ScriptedFree(Array(repeating: .wouldBlock(.outbound), count: 25) + [.wouldBlock(.inbound), .finished]) + let waits = WaitLog() + + let outcome = SSHChannelFreePump( + attemptFree: free.next, + isActive: { true }, + deadline: Date().addingTimeInterval(60), + waitForTransport: waits.record + ).run() + + #expect(outcome == .freed) + #expect(free.attempts == 27) + #expect(waits.directions.count == 26) + #expect(waits.directions.last == .inbound) + } + + @Test("A tunnel that stops ends the free, which the session free then finishes") + func stopsWithTheTunnel() { + let free = ScriptedFree([.wouldBlock(.outbound), .wouldBlock(.outbound)]) + var active = true + + let outcome = SSHChannelFreePump( + attemptFree: free.next, + isActive: { active }, + deadline: Date().addingTimeInterval(60), + waitForTransport: { _ in active = false } + ).run() + + #expect(outcome == .stopped) + #expect(free.attempts == 2) + } + + @Test("A free still blocked at the deadline times out") + func timesOutAtTheDeadline() { + let free = ScriptedFree([]) + let start = Date(timeIntervalSince1970: 0) + var reads = 0 + + let outcome = SSHChannelFreePump( + attemptFree: free.next, + isActive: { true }, + deadline: start.addingTimeInterval(5), + waitForTransport: { _ in }, + now: { + defer { reads += 1 } + return start.addingTimeInterval(Double(reads)) + } + ).run() + + #expect(outcome == .timedOut) + #expect(free.attempts == 6) + } +} + +private final class ScriptedFree { + private var script: [SSHChannelFreeAttempt] + private(set) var attempts = 0 + + // Once the script runs out, every attempt would block. + init(_ script: [SSHChannelFreeAttempt]) { + self.script = script + } + + func next() -> SSHChannelFreeAttempt { + attempts += 1 + return script.isEmpty ? .wouldBlock(.inbound) : script.removeFirst() + } +} + +private final class WaitLog { + private(set) var directions: [RelayDirections] = [] + + func record(_ directions: RelayDirections) { + self.directions.append(directions) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHChannelRelayTests.swift b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHChannelRelayTests.swift index 54b7306974..3077f8ca2d 100644 --- a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHChannelRelayTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHChannelRelayTests.swift @@ -96,8 +96,63 @@ struct SSHChannelRelayTests { #expect(result == .channelClosed) } - @Test("Channel data that arrives after the local peer closed ends the relay instead of raising SIGPIPE") - func channelDataAfterLocalPeerClosed() { + @Test("A transport failure on read ends as a transport hangup, not a channel close") + func readTransportFailureIsHangup() { + let local = SocketPair() + let transport = SocketPair() + defer { local.close(); transport.close() } + + var dummy: UInt8 = 1 + _ = Darwin.send(transport.b, &dummy, 1, 0) + + let result = runRelay( + localFD: local.a, + transportFD: transport.a, + io: FakeChannelIO(fallback: .transportFailed) + ) + + #expect(result == .transportHangup) + } + + @Test("A transport failure on read after the SSH server hung up ends as a transport hangup") + func readTransportFailureAfterHangupIsHangup() { + let local = SocketPair() + let transport = SocketPair() + defer { local.close(); transport.close() } + + transport.closeB() + + let result = runRelay( + localFD: local.a, + transportFD: transport.a, + io: FakeChannelIO(fallback: .transportFailed) + ) + + #expect(result == .transportHangup) + } + + @Test("A transport failure on write ends as a transport hangup, not a channel close") + func writeTransportFailureIsHangup() { + let local = SocketPair() + let transport = SocketPair() + defer { local.close(); transport.close() } + + let payload = Data("world".utf8) + payload.withUnsafeBytes { raw in + _ = Darwin.send(local.b, raw.baseAddress, raw.count, 0) + } + + let result = runRelay( + localFD: local.a, + transportFD: transport.a, + io: FakeChannelIO(fallback: .wouldBlock, writeResult: .transportFailed) + ) + + #expect(result == .transportHangup) + } + + @Test("Channel data for a client that already closed ends the relay as a local close") + func channelDataForClosedClientIsLocalClose() { let local = SocketPair() let transport = SocketPair() defer { local.close(); transport.close() } @@ -106,11 +161,12 @@ struct SSHChannelRelayTests { var dummy: UInt8 = 1 _ = Darwin.send(transport.b, &dummy, 1, 0) - let payload = Data("late".utf8) - let io = FakeChannelIO(actions: [.data(payload)], fallback: .closed) + let payload = Data("rows".utf8) + let io = FakeChannelIO(actions: [.data(payload)], fallback: .wouldBlock) let result = runRelay(localFD: local.a, transportFD: transport.a, io: io) #expect(result == .localClosed) + #expect(io.readCount == 1) #expect(io.deliveredCount == payload.count) } @@ -467,18 +523,21 @@ internal final class FakeChannelIO: SSHChannelIO, @unchecked Sendable { case data(Data) case wouldBlock case closed + case transportFailed } private let lock = NSLock() private var actions: [Action] private let fallback: Action + private let writeResult: ChannelWriteResult? private var writtenBuffer = Data() private var reads = 0 private var delivered = 0 - init(actions: [Action] = [], fallback: Action = .wouldBlock) { + init(actions: [Action] = [], fallback: Action = .wouldBlock, writeResult: ChannelWriteResult? = nil) { self.actions = actions self.fallback = fallback + self.writeResult = writeResult } var written: Data { @@ -520,12 +579,15 @@ internal final class FakeChannelIO: SSHChannelIO, @unchecked Sendable { return .wouldBlock case .closed: return .closed + case .transportFailed: + return .transportFailed } } func write(_ buffer: UnsafePointer, count: Int) -> ChannelWriteResult { lock.lock() defer { lock.unlock() } + if let writeResult { return writeResult } buffer.withMemoryRebound(to: UInt8.self, capacity: count) { source in writtenBuffer.append(contentsOf: UnsafeBufferPointer(start: source, count: count)) } diff --git a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHNegotiationFailureTests.swift b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHNegotiationFailureTests.swift new file mode 100644 index 0000000000..2fb4109aa0 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHNegotiationFailureTests.swift @@ -0,0 +1,86 @@ +// +// SSHNegotiationFailureTests.swift +// TableProSSHTransportTests +// + +import Foundation +import Testing + +@testable import TableProSSHTransport + +struct SSHNegotiationFailureTests { + @Test("No agreed key exchange is the key exchange or host key stage, whatever else is missing") + func noKeyExchange() { + #expect(SSHNegotiationFailure.stage(kex: nil, cipher: nil, mac: nil) == .keyExchangeOrHostKey) + #expect(SSHNegotiationFailure.stage(kex: nil, cipher: "aes128-ctr", mac: "hmac-sha2-256") == .keyExchangeOrHostKey) + } + + @Test("An agreed key exchange with no cipher is the cipher stage") + func noCipher() { + #expect(SSHNegotiationFailure.stage(kex: "curve25519-sha256", cipher: nil, mac: nil) == .cipher) + } + + @Test("A MAC-only server agrees key exchange and cipher, then fails the MAC") + func noMac() { + let stage = SSHNegotiationFailure.stage(kex: "curve25519-sha256", cipher: "aes128-ctr", mac: nil) + #expect(stage == .mac) + } + + @Test("Every method agreed leaves the stage undetermined") + func everythingAgreed() { + let stage = SSHNegotiationFailure.stage(kex: "curve25519-sha256", cipher: "aes128-ctr", mac: "hmac-sha2-256") + #expect(stage == .undetermined) + } + + @Test( + "The server software is the banner's software version", + arguments: [ + ("SSH-2.0-OpenSSH_10.3", "OpenSSH_10.3"), + ("SSH-2.0-OpenSSH_9.9p2 Debian-1", "OpenSSH_9.9p2"), + ("SSH-2.0-OpenSSH_9.6p1-hpn\r\n", "OpenSSH_9.6p1-hpn"), + ("SSH-1.99-dropbear_2024.86", "dropbear_2024.86"), + ] + ) + func serverSoftware(banner: String, software: String) { + #expect(SSHNegotiationFailure.serverSoftware(banner: banner) == software) + } + + @Test("A missing or malformed banner has no server software", arguments: [nil, "", "SSH-2.0-", "SSH-2.0", "HTTP/1.1 400 Bad Request"]) + func noServerSoftware(banner: String?) { + #expect(SSHNegotiationFailure.serverSoftware(banner: banner) == nil) + } + + @Test("TablePro's list drops the pseudo-methods and keeps libssh2's order") + func clientMethodsDropPseudoMethods() { + let libssh2 = [ + "curve25519-sha256", "curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", + "diffie-hellman-group14-sha256", "ext-info-c", "kex-strict-c-v00@openssh.com", + ] + + #expect(SSHNegotiationFailure.clientMethods(libssh2) == [ + "curve25519-sha256", "curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", "diffie-hellman-group14-sha256", + ]) + } + + @Test("A host key or cipher list passes through unchanged") + func clientMethodsKeepRealMethods() { + let hostKeys = ["ssh-ed25519", "ecdsa-sha2-nistp256", "rsa-sha2-512", "rsa-sha2-256"] + + #expect(SSHNegotiationFailure.clientMethods(hostKeys) == hostKeys) + } + + @Test("The failure carries what the app shows") + func carriesItsFields() { + let failure = SSHNegotiationFailure( + stage: .keyExchangeOrHostKey, + serverSoftware: "OpenSSH_10.3", + supported: ["curve25519-sha256"], + supportedHostKeys: ["ssh-ed25519"] + ) + + #expect(failure.stage == .keyExchangeOrHostKey) + #expect(failure.serverSoftware == "OpenSSH_10.3") + #expect(failure.supported == ["curve25519-sha256"]) + #expect(failure.supportedHostKeys == ["ssh-ed25519"]) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHPrivateKeyTraitsTests.swift b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHPrivateKeyTraitsTests.swift new file mode 100644 index 0000000000..5f78327bb2 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHPrivateKeyTraitsTests.swift @@ -0,0 +1,136 @@ +// +// SSHPrivateKeyTraitsTests.swift +// TableProSSHTransportTests +// + +import Foundation +import Testing + +@testable import TableProSSHTransport + +struct SSHPrivateKeyTraitsTests { + @Test("A plain key reads its type and is not encrypted", arguments: ["ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256"]) + func plainKey(keyType: String) throws { + let traits = try #require(SSHPrivateKeyTraits(openSSH: OpenSSHKeyFile.armored(cipher: "none", keyType: keyType))) + + #expect(traits.keyType == keyType) + #expect(!traits.isEncrypted) + #expect(!traits.isSecurityKey) + } + + @Test("An encrypted key still reads its type", arguments: ["aes256-ctr", "aes256-gcm@openssh.com", "chacha20-poly1305@openssh.com"]) + func encryptedKey(cipher: String) throws { + let traits = try #require(SSHPrivateKeyTraits(openSSH: OpenSSHKeyFile.armored(cipher: cipher, keyType: "ssh-ed25519"))) + + #expect(traits.keyType == "ssh-ed25519") + #expect(traits.isEncrypted) + } + + @Test("A FIDO key is a security key", arguments: ["sk-ssh-ed25519@openssh.com", "sk-ecdsa-sha2-nistp256@openssh.com"]) + func securityKey(keyType: String) throws { + let traits = try #require(SSHPrivateKeyTraits(openSSH: OpenSSHKeyFile.armored(cipher: "none", keyType: keyType))) + + #expect(traits.isSecurityKey) + #expect(traits.keyType == keyType) + } + + @Test("CRLF line endings and text around the block still parse") + func toleratesLayout() throws { + let armored = OpenSSHKeyFile.armored(cipher: "aes256-ctr", keyType: "ssh-rsa") + .replacingOccurrences(of: "\n", with: "\r\n") + let traits = try #require(SSHPrivateKeyTraits(openSSH: " \nComment above\n" + armored + "\n\n")) + + #expect(traits.keyType == "ssh-rsa") + #expect(traits.isEncrypted) + } + + @Test("A key whose public section is cut short keeps its cipher but has no type") + func truncatedAfterCipher() throws { + let cut = Array(OpenSSHKeyFile.container(cipher: "aes256-ctr", keyType: "ssh-ed25519").prefix(40)) + let traits = try #require(SSHPrivateKeyTraits(openSSH: OpenSSHKeyFile.armor(cut))) + + #expect(traits.isEncrypted) + #expect(traits.keyType == nil) + #expect(!traits.isSecurityKey) + } + + @Test("A container that lists no keys has no type") + func noKeys() throws { + let empty = OpenSSHKeyFile.container(cipher: "none", keyType: "ssh-ed25519", keyCount: 0) + let traits = try #require(SSHPrivateKeyTraits(openSSH: OpenSSHKeyFile.armor(empty))) + + #expect(traits.keyType == nil) + } + + @Test("A length past the end of the data is not read") + func oversizedLength() throws { + var bytes = OpenSSHKeyFile.magic + bytes += OpenSSHKeyFile.uint32(10) + bytes += Array("none".utf8) + + #expect(SSHPrivateKeyTraits(openSSH: OpenSSHKeyFile.armor(bytes)) == nil) + } + + @Test( + "Text that is not an openssh-key-v1 key gives no traits", + arguments: [ + "", + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA user@host", + "-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAK\n-----END RSA PRIVATE KEY-----\n", + "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjE\n", + "-----BEGIN OPENSSH PRIVATE KEY-----\n!!!not base64!!!\n-----END OPENSSH PRIVATE KEY-----\n", + ] + ) + func notAnOpenSSHKey(text: String) { + #expect(SSHPrivateKeyTraits(openSSH: text) == nil) + } + + @Test("A body without the openssh-key-v1 magic gives no traits") + func wrongMagic() { + let bytes = Array("openssh-key-v2\0".utf8) + OpenSSHKeyFile.field(Array("none".utf8)) + + #expect(SSHPrivateKeyTraits(openSSH: OpenSSHKeyFile.armor(bytes)) == nil) + } + + @Test("An empty cipher name gives no traits") + func emptyCipher() { + let bytes = OpenSSHKeyFile.magic + OpenSSHKeyFile.field([]) + + #expect(SSHPrivateKeyTraits(openSSH: OpenSSHKeyFile.armor(bytes)) == nil) + } +} + +private enum OpenSSHKeyFile { + static let magic = Array("openssh-key-v1\0".utf8) + + static func armored(cipher: String, keyType: String) -> String { + armor(container(cipher: cipher, keyType: keyType)) + } + + static func container(cipher: String, keyType: String, keyCount: UInt32 = 1) -> [UInt8] { + let encrypted = cipher != "none" + let kdfOptions = encrypted ? field([UInt8](repeating: 7, count: 16)) + uint32(16) : [] + let publicKey = field(Array(keyType.utf8)) + field([UInt8](repeating: 1, count: 32)) + var bytes = magic + bytes += field(Array(cipher.utf8)) + bytes += field(Array((encrypted ? "bcrypt" : "none").utf8)) + bytes += field(kdfOptions) + bytes += uint32(keyCount) + bytes += field(publicKey) + bytes += field([UInt8](repeating: 9, count: 64)) + return bytes + } + + static func armor(_ bytes: [UInt8]) -> String { + let body = Data(bytes).base64EncodedString(options: [.lineLength64Characters, .endLineWithLineFeed]) + return "-----BEGIN OPENSSH PRIVATE KEY-----\n\(body)\n-----END OPENSSH PRIVATE KEY-----\n" + } + + static func field(_ bytes: [UInt8]) -> [UInt8] { + uint32(UInt32(bytes.count)) + bytes + } + + static func uint32(_ value: UInt32) -> [UInt8] { + [UInt8((value >> 24) & 0xFF), UInt8((value >> 16) & 0xFF), UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF)] + } +} diff --git a/apple/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift b/apple/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift index 5ff2c4f63c..779859b6f3 100644 --- a/apple/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift +++ b/apple/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift @@ -8,25 +8,10 @@ import os import CLibSSH2 import TableProSSHConfig +import TableProSSHTransport -/// How a keyboard-interactive prompt should be answered without asking the user. -/// -/// The classification is a fast-path hint only: it decides when a pre-known credential (the SSH -/// password, an auto-generated TOTP code) can answer a prompt. Anything not claimed here defers to -/// the interactive prompt, so a weak keyword match is a missed shortcut, not a wrong answer. -internal enum KBDINTPromptType: Equatable { - case password - case totp - case unmatched -} - -/// Context reached from the C callback through the libssh2 session abstract pointer. -/// -/// TOTP codes and user prompts are resolved lazily inside the callback (not upfront) so that a -/// generated code is still valid when PAM checks it, and so the server can re-prompt within one -/// session (PAM defaults to 3 attempts) with each retry driving a fresh code or dialog. The C -/// callback can't throw across the libssh2 boundary, so failures and cancellation are recorded here -/// and surface after `libssh2_userauth_keyboard_interactive_ex` returns. +// Codes and prompts resolve inside the callback, so a code is still valid when PAM checks it and each re-prompt gets a fresh one. +// The C callback cannot throw, so errors and cancellation wait here until libssh2 returns. internal final class KeyboardInteractiveContext { let password: String? let totpProvider: (any TOTPProvider)? @@ -47,9 +32,7 @@ internal final class KeyboardInteractiveContext { self.promptProvider = promptProvider } - /// What the rejection was about, named by whichever answer actually went to the server. A - /// server that issued no prompt at all never took a credential from this method, so the - /// failure says nothing about the user's own: it says keyboard-interactive was not on offer. + // A server that sent no prompt never took a credential, so the method was not on offer. var failureReason: AuthFailureReason { if interactiveAttemptCount > 0 { return .keyboardInteractive } if totpAttemptCount > 0 { return .verificationCode } @@ -68,9 +51,7 @@ internal final class KeyboardInteractiveContext { } } - /// Resolve one response per prompt. Known prompts are answered from the password / TOTP - /// fast path; the rest go to the interactive prompt in a single dialog. Every index is filled - /// (empty string on cancel) so libssh2 never frees an unset response. + // Every slot gets a string, empty on cancel, since libssh2 frees each response. func responses(name: String, instruction: String, prompts: [KeyboardInteractivePrompt]) -> [String] { var results = [String?](repeating: nil, count: prompts.count) var pendingIndices: [Int] = [] @@ -117,10 +98,7 @@ internal final class KeyboardInteractiveContext { } } -/// C-compatible callback for libssh2 keyboard-interactive authentication. -/// -/// libssh2 invokes this synchronously for each challenge. Responses are allocated with `strdup` -/// because libssh2 `free`s them, and every slot is filled even on cancel. +// strdup because libssh2 frees each response. private let kbdintCallback: @convention(c) ( UnsafePointer?, Int32, UnsafePointer?, Int32, @@ -225,20 +203,7 @@ internal struct KeyboardInteractiveAuthenticator: SSHAuthenticator { Self.logger.info("Keyboard-interactive authentication succeeded") } - static func classify(_ promptText: String) -> KBDINTPromptType { - let lower = promptText.lowercased() - - if lower.contains("verification") || lower.contains("code") || - lower.contains("otp") || lower.contains("token") || - lower.contains("totp") || lower.contains("2fa") || - lower.contains("one-time") || lower.contains("factor") { - return .totp - } - - if lower.contains("password") { - return .password - } - - return .unmatched + static func classify(_ promptText: String) -> KeyboardInteractivePromptKind { + KeyboardInteractivePromptKind(promptText: promptText) } } diff --git a/apple/TablePro/Core/SSH/LibSSH2ChannelIO.swift b/apple/TablePro/Core/SSH/LibSSH2ChannelIO.swift index 3282fa973e..5d6111c364 100644 --- a/apple/TablePro/Core/SSH/LibSSH2ChannelIO.swift +++ b/apple/TablePro/Core/SSH/LibSSH2ChannelIO.swift @@ -12,28 +12,49 @@ import Foundation import CLibSSH2 import TableProSSHTransport -/// Routes channel reads/writes through the serial `sessionQueue` because libssh2 -/// is not thread-safe per session. Maps libssh2 return codes to the transport -/// agnostic results consumed by `SSHChannelRelay`. +// Every call goes through the serial sessionQueue: libssh2 is not thread-safe per session. nonisolated internal struct LibSSH2ChannelIO: SSHChannelIO { let channel: OpaquePointer let session: OpaquePointer let sessionQueue: DispatchQueue + // libssh2 hands a transport error (-43 on EOF, -13 on DISCONNECT, -12, a failed re-key) to every channel call; only these end just one channel. + private static let channelScopedErrors: Set = [ + Int(LIBSSH2_ERROR_CHANNEL_OUTOFORDER), + Int(LIBSSH2_ERROR_CHANNEL_FAILURE), + Int(LIBSSH2_ERROR_CHANNEL_REQUEST_DENIED), + Int(LIBSSH2_ERROR_CHANNEL_UNKNOWN), + Int(LIBSSH2_ERROR_CHANNEL_WINDOW_EXCEEDED), + Int(LIBSSH2_ERROR_CHANNEL_PACKET_EXCEEDED), + Int(LIBSSH2_ERROR_CHANNEL_CLOSED), + Int(LIBSSH2_ERROR_CHANNEL_EOF_SENT), + Int(LIBSSH2_ERROR_CHANNEL_WINDOW_FULL), + ] + + static func readResult(_ returnCode: Int) -> ChannelReadResult { + if returnCode > 0 { return .bytes(returnCode) } + if returnCode == 0 { return .closed } + if returnCode == Int(LIBSSH2_ERROR_EAGAIN) { return .wouldBlock } + return channelScopedErrors.contains(returnCode) ? .closed : .transportFailed + } + + static func writeResult(_ returnCode: Int) -> ChannelWriteResult { + if returnCode > 0 { return .bytes(returnCode) } + if returnCode == 0 { return .closed } + if returnCode == Int(LIBSSH2_ERROR_EAGAIN) { return .wouldBlock } + return channelScopedErrors.contains(returnCode) ? .closed : .transportFailed + } + func read(into buffer: UnsafeMutablePointer, count: Int) -> ChannelReadResult { let result = sessionQueue.sync { Int(tablepro_libssh2_channel_read(channel, buffer, count)) } - if result > 0 { return .bytes(result) } - if result == 0 { return .closed } - if sessionQueue.sync(execute: { libssh2_channel_eof(channel) }) != 0 { return .closed } - if result == Int(LIBSSH2_ERROR_EAGAIN) { return .wouldBlock } - return .closed + if result == Int(LIBSSH2_ERROR_EAGAIN), sessionQueue.sync(execute: { libssh2_channel_eof(channel) }) != 0 { + return .closed + } + return Self.readResult(result) } func write(_ buffer: UnsafePointer, count: Int) -> ChannelWriteResult { - let result = sessionQueue.sync { Int(tablepro_libssh2_channel_write(channel, buffer, count)) } - if result > 0 { return .bytes(result) } - if result == Int(LIBSSH2_ERROR_EAGAIN) { return .wouldBlock } - return .closed + Self.writeResult(sessionQueue.sync { Int(tablepro_libssh2_channel_write(channel, buffer, count)) }) } func blockDirections() -> RelayDirections { diff --git a/apple/TableProMobile/TableProMobile/Helpers/AppError.swift b/apple/TableProMobile/TableProMobile/Helpers/AppError.swift index 9e2262cfb0..a266c84950 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/AppError.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/AppError.swift @@ -126,7 +126,7 @@ nonisolated enum ErrorClassifier { return connectionFailure(connectionError) } - if let tunnelError = error as? SSHTunnelError, let classified = jumpHostFailure(tunnelError) { + if let tunnelError = error as? SSHTunnelError, let classified = sshTunnelFailure(tunnelError) { return classified } @@ -249,31 +249,6 @@ nonisolated enum ErrorClassifier { } } - // By case, because a translated message may hold none of the words the substring rules look for. - private static func jumpHostFailure(_ error: SSHTunnelError) -> AppError? { - let recovery: String - switch error { - case .usernameMissing: - recovery = String(localized: "Edit the connection and enter the username under SSH Server or Jump Hosts.") - case .jumpHostNeedsKey, .jumpHostSharedKeyMissing: - recovery = String(localized: "Edit the connection and set a key for this jump host under Jump Hosts.") - case .jumpHostFailed: - recovery = String(localized: "Check this jump host's address, port, username and key under Jump Hosts.") - case .jumpHostKeysUnreadable: - recovery = String(localized: "Try again. If it keeps failing, update TablePro on this device.") - case .connectionFailed, .handshakeFailed, .authenticationFailed, .noAvailablePort, .channelOpenFailed, - .hostKeyRejected, .hostKeyUnverified, .tunnelClosed: - return nil - } - return AppError( - category: .ssh, - title: String(localized: "SSH Tunnel Failed"), - message: error.localizedDescription, - recovery: recovery, - underlying: error - ) - } - /// A Redis reply is read by its error class, never by its words, because the server echoes the /// user's own arguments back: `FOO password` answers `ERR unknown command 'FOO', with args /// beginning with: 'password'`. A transport or setup failure returns nil and is read as text. diff --git a/apple/TableProMobile/TableProMobile/Helpers/ErrorClassifier+SSHTunnel.swift b/apple/TableProMobile/TableProMobile/Helpers/ErrorClassifier+SSHTunnel.swift new file mode 100644 index 0000000000..3569b78a52 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Helpers/ErrorClassifier+SSHTunnel.swift @@ -0,0 +1,93 @@ +import Foundation + +// By case, because a translated message may hold none of the words the substring rules look for. +// nil sends the error on to the text rules, which still give timeouts the Local Network recovery. +nonisolated extension ErrorClassifier { + static func sshTunnelFailure(_ error: SSHTunnelError) -> AppError? { + switch error { + case .negotiationFailed: + return tunnelFailed( + error, + recovery: String(localized: "Ask the SSH server's administrator to turn on a method TablePro supports.") + ) + case .usernameMissing: + return tunnelFailed( + error, + recovery: String(localized: "Edit the connection and enter the username under SSH Server or Jump Hosts.") + ) + case .jumpHostNeedsKey, .jumpHostSharedKeyMissing: + return tunnelFailed( + error, + recovery: String(localized: "Edit the connection and set a key for this jump host under Jump Hosts.") + ) + case .jumpHostFailed: + return tunnelFailed( + error, + recovery: String(localized: "Check this jump host's address, port, username and key under Jump Hosts.") + ) + case .jumpHostKeysUnreadable: + return tunnelFailed( + error, + recovery: String(localized: "Try again. If it keeps failing, update TablePro on this device.") + ) + case .authMethodUnsupported: + return signInUnavailable( + error, + recovery: String(localized: "Open this connection in TablePro on the Mac, or edit it and choose Password or Private Key.") + ) + case .securityKeyUnsupported: + return signInUnavailable( + error, + recovery: String(localized: "Open this connection in TablePro on the Mac, or edit it and choose a private key that is not on a security key.") + ) + case .additionalFactorRequired, .promptUnanswerable: + return signInUnavailable( + error, + recovery: String(localized: "Open this connection in TablePro on the Mac, which can answer this sign-in step.") + ) + case .passphraseRequired: + return passphraseFailure( + error, + recovery: String(localized: "Edit the connection and enter the key's passphrase under Private Key.") + ) + case .passphraseIncorrect: + return passphraseFailure( + error, + recovery: String(localized: "Edit the connection and check the key's passphrase under Private Key.") + ) + case .connectionFailed, .handshakeFailed, .authenticationFailed, .noAvailablePort, .channelOpenFailed, + .hostKeyRejected, .hostKeyUnverified, .tunnelClosed: + return nil + } + } + + private static func tunnelFailed(_ error: SSHTunnelError, recovery: String) -> AppError { + AppError( + category: .ssh, + title: String(localized: "SSH Tunnel Failed"), + message: error.localizedDescription, + recovery: recovery, + underlying: error + ) + } + + private static func signInUnavailable(_ error: SSHTunnelError, recovery: String) -> AppError { + AppError( + category: .config, + title: String(localized: "SSH Sign-In Unavailable"), + message: error.localizedDescription, + recovery: recovery, + underlying: error + ) + } + + private static func passphraseFailure(_ error: SSHTunnelError, recovery: String) -> AppError { + AppError( + category: .auth, + title: String(localized: "SSH Authentication Failed"), + message: error.localizedDescription, + recovery: recovery, + underlying: error + ) + } +} diff --git a/apple/TableProMobile/TableProMobile/Localizable.xcstrings b/apple/TableProMobile/TableProMobile/Localizable.xcstrings index 3822c237b3..275e97f59b 100644 --- a/apple/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/apple/TableProMobile/TableProMobile/Localizable.xcstrings @@ -2471,6 +2471,9 @@ "Asia and Australia" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Ask the SSH server's administrator to turn on a method TablePro supports." : { + }, "Atom" : { "extractionState" : "manual", @@ -6659,6 +6662,9 @@ } } } + }, + "Edit the connection and check the key's passphrase under Private Key." : { + }, "Edit the connection and choose the database file again." : { "localizations" : { @@ -6687,6 +6693,9 @@ } } } + }, + "Edit the connection and enter the key's passphrase under Private Key." : { + }, "Edit the connection and enter the username under SSH Server or Jump Hosts." : { @@ -9881,6 +9890,9 @@ } } } + }, + "It supports these host key types: %@." : { + }, "Its connection is not imported." : { @@ -13017,6 +13029,15 @@ }, "Open Table" : { + }, + "Open this connection in TablePro on the Mac, or edit it and choose a private key that is not on a security key." : { + + }, + "Open this connection in TablePro on the Mac, or edit it and choose Password or Private Key." : { + + }, + "Open this connection in TablePro on the Mac, which can answer this sign-in step." : { + }, "Open this connection in TablePro to check the server's SSH host key first." : { "localizations" : { @@ -17129,6 +17150,12 @@ "Square" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "SSH Agent" : { + + }, + "SSH Authentication Failed" : { + }, "SSH Host" : { "localizations" : { @@ -17299,6 +17326,9 @@ } } } + }, + "SSH Sign-In Unavailable" : { + }, "SSH Tunnel" : { "localizations" : { @@ -18274,6 +18304,18 @@ } } } + }, + "TablePro and the SSH server could not agree on how to encrypt the connection." : { + + }, + "TablePro and the SSH server have no cipher in common." : { + + }, + "TablePro and the SSH server have no key exchange or host key method in common." : { + + }, + "TablePro and the SSH server have no MAC algorithm in common." : { + }, "TablePro can't open “%@”." : { "localizations" : { @@ -18581,6 +18623,18 @@ "TablePro on iPhone and iPad can't open %@ connections." : { "extractionState" : "manual", "generatesSymbol" : false + }, + "TablePro on iPhone and iPad cannot use the SSH authentication method “%@”." : { + + }, + "TablePro supports these ciphers: %@." : { + + }, + "TablePro supports these key exchange methods: %@." : { + + }, + "TablePro supports these MAC algorithms: %@." : { + }, "TablePro will re-download every connection, group, and tag from your iCloud account. Local data on this device is not deleted." : { "extractionState" : "stale", @@ -19984,6 +20038,9 @@ "The Oracle server stopped answering during the %@ step of the login." : { "extractionState" : "manual", "generatesSymbol" : false + }, + "The passphrase for this SSH private key is incorrect." : { + }, "The Password field is empty. Fill it in, or clear Username to sign in as the default user." : { "extractionState" : "manual", @@ -20231,6 +20288,9 @@ } } } + }, + "The server runs %@." : { + }, "The server sent an unexpected message and the connection was reset. Run the query again." : { "extractionState" : "manual", @@ -20364,6 +20424,21 @@ }, "The socket path must start with /." : { + }, + "The SSH server accepted the first sign-in step and asks for another (%@), which TablePro on iPhone and iPad cannot complete." : { + + }, + "The SSH server asked “%@”, which TablePro on iPhone and iPad cannot answer." : { + + }, + "The SSH server did not answer in time." : { + + }, + "The SSH server does not accept a password. It accepts %@." : { + + }, + "The SSH server does not accept a private key. It accepts %@." : { + }, "The SSH server may be unreachable or running a different protocol." : { "localizations" : { @@ -20392,6 +20467,9 @@ } } } + }, + "The SSH server offers no sign-in method." : { + }, "The SSH tunnel connected but could not forward to the database port." : { "localizations" : { @@ -21360,6 +21438,12 @@ } } } + }, + "This SSH private key belongs to a security key (FIDO), which TablePro on iPhone and iPad cannot sign with." : { + + }, + "This SSH private key is encrypted, and no passphrase is saved for it." : { + }, "This table has no foreign key relationships." : { "localizations" : { diff --git a/apple/TableProMobile/TableProMobile/SSH/LibSSH2Authentication.swift b/apple/TableProMobile/TableProMobile/SSH/LibSSH2Authentication.swift new file mode 100644 index 0000000000..cde06da2aa --- /dev/null +++ b/apple/TableProMobile/TableProMobile/SSH/LibSSH2Authentication.swift @@ -0,0 +1,300 @@ +import CLibSSH2 +import Foundation +import os +import TableProSSHTransport + +nonisolated protocol SSHUserAuthSession { + func methodList() -> String? + func isAuthenticated() -> Bool + func lastError() -> String? + func password(_ password: String) -> Int32 + func keyboardInteractive(_ answers: KeyboardInteractiveAnswers) -> Int32 + func publicKeyFromFile(privateKeyPath: String, publicKeyPath: String?, passphrase: String?) -> Int32 + func publicKeyFromMemory(_ privateKey: String, passphrase: String?) -> Int32 +} + +nonisolated final class KeyboardInteractiveAnswers { + private let password: String + private(set) var unansweredPrompt: String? + + init(password: String) { + self.password = password + } + + // A code or an unknown question gets an empty answer, never the password. + func answer(_ prompt: String) -> String { + if KeyboardInteractivePromptKind(promptText: prompt) == .password { + return password + } + let question = prompt.trimmingCharacters(in: .whitespacesAndNewlines) + if unansweredPrompt == nil, !question.isEmpty { + unansweredPrompt = question + } + return "" + } +} + +nonisolated struct LibSSH2Authentication { + nonisolated enum Key: Equatable, Sendable { + case file(path: String, publicKeyPath: String?, contents: String?, publicKeyIsCertificate: Bool = false) + case memory(String) + + static func readFile(at path: String) -> Key { + let publicKeyPath = path + ".pub" + let publicKey = try? String(contentsOfFile: publicKeyPath, encoding: .utf8) + return .file( + path: path, + publicKeyPath: FileManager.default.fileExists(atPath: publicKeyPath) ? publicKeyPath : nil, + contents: try? String(contentsOfFile: path, encoding: .utf8), + publicKeyIsCertificate: publicKey.map(isCertificate) ?? false + ) + } + + static func isCertificate(_ publicKeyLine: String) -> Bool { + let keyType = publicKeyLine.split(whereSeparator: \.isWhitespace).first ?? "" + return keyType.hasSuffix("-cert-v01@openssh.com") + } + + var contents: String? { + switch self { + case .file(_, _, let contents, _): return contents + case .memory(let contents): return contents + } + } + } + + nonisolated private enum Offer { + case authenticated + case methods([String]) + case unavailable + } + + nonisolated private struct Failure { + let detail: String + let methodsAfter: [String] + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "SSHAuthentication") + private static let passwordMethod = "password" + private static let keyboardInteractiveMethod = "keyboard-interactive" + private static let publicKeyMethod = "publickey" + private static let localKeyFailures: Set = [LIBSSH2_ERROR_FILE, LIBSSH2_ERROR_KEYFILE_AUTH_FAILED] + + let session: any SSHUserAuthSession + + init(session: any SSHUserAuthSession) { + self.session = session + } + + init(session: OpaquePointer, username: String) { + self.init(session: LibSSH2UserAuthSession(session: session, username: username)) + } + + func password(_ password: String) throws { + guard let offered = try methodsOnOffer() else { return } + var available = offered + var passwordFailure: String? + + if offered.contains(Self.passwordMethod) { + let rc = session.password(password) + if rc == 0 { return } + guard let failure = try readFailure(of: Self.passwordMethod, rc: rc, offered: offered) else { return } + passwordFailure = failure.detail + available = failure.methodsAfter + } + + // A PAM server can take the password over keyboard-interactive instead. + guard available.contains(Self.keyboardInteractiveMethod) else { + throw SSHTunnelError.authenticationFailed(passwordFailure ?? Self.notOffered( + String(localized: "The SSH server does not accept a password. It accepts %@."), + offered: offered + )) + } + + let answers = KeyboardInteractiveAnswers(password: password) + let rc = session.keyboardInteractive(answers) + if rc == 0 { return } + guard let failure = try readFailure(of: Self.keyboardInteractiveMethod, rc: rc, offered: available) else { return } + if let prompt = answers.unansweredPrompt { + throw SSHTunnelError.promptUnanswerable(prompt) + } + throw SSHTunnelError.authenticationFailed(passwordFailure ?? failure.detail) + } + + func publicKey(_ key: Key, passphrase: String?) throws { + let passphrase = passphrase.flatMap { $0.isEmpty ? nil : $0 } + let traits = Self.traits(of: key.contents) + if traits.isSecurityKey { throw SSHTunnelError.securityKeyUnsupported } + if traits.isEncrypted, passphrase == nil { throw SSHTunnelError.passphraseRequired } + + guard let offered = try methodsOnOffer() else { return } + guard offered.contains(Self.publicKeyMethod) else { + throw SSHTunnelError.authenticationFailed(Self.notOffered( + String(localized: "The SSH server does not accept a private key. It accepts %@."), + offered: offered + )) + } + + let rc: Int32 + switch key { + case .file(let path, let publicKeyPath, _, let publicKeyIsCertificate): + // Without the .pub, libssh2 decrypts the key before it sends anything, so a wrong + // passphrase fails locally and -19 is only ever the server's refusal. A certificate + // still goes: the server may trust its CA and not the bare key. + rc = session.publicKeyFromFile( + privateKeyPath: path, + publicKeyPath: traits.isEncrypted && !publicKeyIsCertificate ? nil : publicKeyPath, + passphrase: passphrase + ) + case .memory(let contents): + rc = session.publicKeyFromMemory(contents, passphrase: passphrase) + } + if rc == 0 { return } + + // These come from reading the key before anything reaches the server, so the list is + // unchanged and reading it again would only cost a try, since OpenSSH counts a second "none". + if Self.localKeyFailures.contains(rc) { + if traits.isEncrypted { throw SSHTunnelError.passphraseIncorrect } + throw SSHTunnelError.authenticationFailed(session.lastError() ?? "publickey authentication failed (error \(rc))") + } + guard let failure = try readFailure(of: Self.publicKeyMethod, rc: rc, offered: offered) else { return } + throw SSHTunnelError.authenticationFailed(failure.detail) + } + + // nil when the server accepted "none" and the session is already signed in. + private func methodsOnOffer() throws -> [String]? { + switch offer() { + case .authenticated: + return nil + case .methods(let methods): + Self.logger.debug("SSH server offers \(methods.joined(separator: ","), privacy: .public)") + return methods + case .unavailable: + throw SSHTunnelError.authenticationFailed(session.lastError() ?? "No authentication methods received") + } + } + + private func offer() -> Offer { + if let list = session.methodList() { + return .methods(SSHAuthMethods.parse(list)) + } + return session.isAuthenticated() ? .authenticated : .unavailable + } + + // The last error goes first: re-reading the list overwrites it. libssh2 never reads the + // partial-success flag, so an accepted first step shows only as its method leaving the list. + // nil when the session turned out to be signed in. + private func readFailure(of method: String, rc: Int32, offered: [String]) throws -> Failure? { + let detail = session.lastError() ?? "\(method) authentication failed (error \(rc))" + Self.logger.debug("SSH \(method, privacy: .public) authentication failed (\(rc)): \(detail, privacy: .public)") + + let after: [String] + switch offer() { + case .authenticated: return nil + case .methods(let methods): after = methods + case .unavailable: after = [] + } + + if let remaining = SSHAuthMethods.remainingAfterPartialSuccess(of: method, before: offered, after: after) { + throw SSHTunnelError.additionalFactorRequired(remaining) + } + return Failure(detail: detail, methodsAfter: after) + } + + private static func traits(of contents: String?) -> (isEncrypted: Bool, isSecurityKey: Bool) { + guard let contents else { return (false, false) } + if let traits = SSHPrivateKeyTraits(openSSH: contents) { + return (traits.isEncrypted, traits.isSecurityKey) + } + return (PEMDocument.inspect(contents).usesPassphrase, false) + } + + private static func notOffered(_ format: String, offered: [String]) -> String { + guard !offered.isEmpty else { + return String(localized: "The SSH server offers no sign-in method.") + } + return String(format: format, offered.formatted(.list(type: .or))) + } +} + +nonisolated struct LibSSH2UserAuthSession: SSHUserAuthSession { + let session: OpaquePointer + let username: String + + func methodList() -> String? { + guard let list = libssh2_userauth_list(session, username, UInt32(username.utf8.count)) else { return nil } + return String(cString: list) + } + + func isAuthenticated() -> Bool { + libssh2_userauth_authenticated(session) != 0 + } + + func lastError() -> String? { + var message: UnsafeMutablePointer? + var length: Int32 = 0 + libssh2_session_last_error(session, &message, &length, 0) + guard let message, length > 0 else { return nil } + return String(cString: message) + } + + func password(_ password: String) -> Int32 { + libssh2_userauth_password_ex( + session, + username, + UInt32(username.utf8.count), + password, + UInt32(password.utf8.count), + nil + ) + } + + func keyboardInteractive(_ answers: KeyboardInteractiveAnswers) -> Int32 { + let abstract: UnsafeMutablePointer = libssh2_session_abstract(session) + let previous = abstract.pointee + abstract.pointee = Unmanaged.passUnretained(answers).toOpaque() + defer { abstract.pointee = previous } + + return withExtendedLifetime(answers) { + libssh2_userauth_keyboard_interactive_ex(session, username, UInt32(username.utf8.count)) { _, _, _, _, count, prompts, responses, slot in + guard count > 0, let prompts, let responses, let context = slot?.pointee else { return } + let answers = Unmanaged.fromOpaque(context).takeUnretainedValue() + for index in 0 ..< Int(count) { + // libssh2 frees each response, so it has to come from the C heap. + let response = strdup(answers.answer(LibSSH2UserAuthSession.text(of: prompts[index]))) + responses[index].text = response + responses[index].length = response.map { UInt32(strlen($0)) } ?? 0 + } + } + } + } + + func publicKeyFromFile(privateKeyPath: String, publicKeyPath: String?, passphrase: String?) -> Int32 { + libssh2_userauth_publickey_fromfile_ex( + session, + username, + UInt32(username.utf8.count), + publicKeyPath, + privateKeyPath, + passphrase + ) + } + + func publicKeyFromMemory(_ privateKey: String, passphrase: String?) -> Int32 { + privateKey.withCString { keyPointer in + libssh2_userauth_publickey_frommemory( + session, + username, + username.utf8.count, + nil, 0, + keyPointer, privateKey.utf8.count, + passphrase + ) + } + } + + private static func text(of prompt: LIBSSH2_USERAUTH_KBDINT_PROMPT) -> String { + guard let text = prompt.text, prompt.length > 0 else { return "" } + return String(bytes: UnsafeBufferPointer(start: text, count: prompt.length), encoding: .utf8) ?? "" + } +} diff --git a/apple/TableProMobile/TableProMobile/SSH/LibSSH2HandshakeFailure.swift b/apple/TableProMobile/TableProMobile/SSH/LibSSH2HandshakeFailure.swift new file mode 100644 index 0000000000..723789426f --- /dev/null +++ b/apple/TableProMobile/TableProMobile/SSH/LibSSH2HandshakeFailure.swift @@ -0,0 +1,85 @@ +import CLibSSH2 +import Foundation +import TableProSSHTransport + +// Runs on the failed session before libssh2_session_free, which drops what the negotiation agreed. +nonisolated enum LibSSH2HandshakeFailure { + static func error(session: OpaquePointer, rc: Int32) -> SSHTunnelError { + // libssh2_session_methods overwrites the last error when a method is missing, so it is read first. + let detail = lastError(session) + + switch rc { + case LIBSSH2_ERROR_KEX_FAILURE: + return .negotiationFailed(negotiation(session)) + case LIBSSH2_ERROR_TIMEOUT: + // libssh2 says "Timed out waiting on socket", which reads as blocked Local Network access, + // but the TCP connect already succeeded. + return .handshakeFailed(String(localized: "The SSH server did not answer in time.")) + default: + return .handshakeFailed(detail ?? "Handshake failed (error \(rc))") + } + } + + private static func negotiation(_ session: OpaquePointer) -> SSHNegotiationFailure { + let stage = SSHNegotiationFailure.stage( + kex: method(session, LIBSSH2_METHOD_KEX), + cipher: agreed(session, LIBSSH2_METHOD_CRYPT_CS, LIBSSH2_METHOD_CRYPT_SC), + mac: agreed(session, LIBSSH2_METHOD_MAC_CS, LIBSSH2_METHOD_MAC_SC) + ) + + var supported: [String] = [] + var supportedHostKeys: [String] = [] + switch stage { + case .keyExchangeOrHostKey: + supported = SSHNegotiationFailure.clientMethods(algorithms(session, LIBSSH2_METHOD_KEX)) + supportedHostKeys = algorithms(session, LIBSSH2_METHOD_HOSTKEY) + case .cipher: + supported = algorithms(session, LIBSSH2_METHOD_CRYPT_CS) + case .mac: + supported = algorithms(session, LIBSSH2_METHOD_MAC_CS) + case .undetermined: + break + } + + return SSHNegotiationFailure( + stage: stage, + serverSoftware: SSHNegotiationFailure.serverSoftware(banner: banner(session)), + supported: supported, + supportedHostKeys: supportedHostKeys + ) + } + + // libssh2 agrees each direction on its own, so one side alone is not an agreement. + private static func agreed(_ session: OpaquePointer, _ clientToServer: Int32, _ serverToClient: Int32) -> String? { + guard let outgoing = method(session, clientToServer), method(session, serverToClient) != nil else { + return nil + } + return outgoing + } + + private static func method(_ session: OpaquePointer, _ type: Int32) -> String? { + guard let name = libssh2_session_methods(session, type) else { return nil } + return String(cString: name) + } + + private static func algorithms(_ session: OpaquePointer, _ type: Int32) -> [String] { + var names: UnsafeMutablePointer?>? + let count = libssh2_session_supported_algs(session, type, &names) + guard count > 0, let names else { return [] } + defer { libssh2_free(session, names) } + return (0 ..< Int(count)).compactMap { index in names[index].map { String(cString: $0) } } + } + + private static func banner(_ session: OpaquePointer) -> String? { + guard let banner = libssh2_session_banner_get(session) else { return nil } + return String(cString: banner) + } + + private static func lastError(_ session: OpaquePointer) -> String? { + var message: UnsafeMutablePointer? + var length: Int32 = 0 + libssh2_session_last_error(session, &message, &length, 0) + guard let message, length > 0 else { return nil } + return String(cString: message) + } +} diff --git a/apple/TableProMobile/TableProMobile/SSH/SSHTunnel.swift b/apple/TableProMobile/TableProMobile/SSH/SSHTunnel.swift index 35d0f9b649..ef3c71b152 100644 --- a/apple/TableProMobile/TableProMobile/SSH/SSHTunnel.swift +++ b/apple/TableProMobile/TableProMobile/SSH/SSHTunnel.swift @@ -54,7 +54,11 @@ nonisolated final class SSHTunnel: @unchecked Sendable { /// Held strictly below every iOS driver's connect timeout so the reason reaches the log /// before the driver reports its own timeout, which names no cause. static let channelOpenDeadlineSeconds: TimeInterval = 6 - private static let channelOpenPollTimeoutMs: Int32 = 5_000 + private static let channelOpenPollTimeoutMs: Int32 = 100 + + private static let channelFreePollTimeoutMs: Int32 = 100 + // The keep-alive's own limit: a CLOSE still unsent by then holds every other send on the session. + private static let channelFreeDeadlineSeconds: TimeInterval = 30 /// How long the accept loop waits per poll before rechecking the latch. Small enough that /// noticing a client the kernel already accepted costs a slim part of the margin above. @@ -156,10 +160,10 @@ nonisolated final class SSHTunnel: @unchecked Sendable { performTeardown() } - // `shutdown` wakes every poll without giving the descriptor number back; nothing is freed or - // closed until whatever could poll it has exited. Darwin's `shutdown` does not wake a listening - // socket, so the accept loop ends on the latch. It is the only thing that starts a relay, so the - // relays are waited on after it. The hops go last, in the Mac's order. + // shutdown wakes every poll on the transport but keeps the number, so no other socket can take it mid-poll. + // Darwin's shutdown does not wake a poll on a listening socket, so the accept loop ends on the latch instead. + // The relays are awaited after the accept loop, the only thing that starts one, so the group can only empty. + // The hops go last, in the Mac's order. private func performTeardown() { forwardingTask?.cancel() keepAliveTask?.cancel() @@ -183,12 +187,13 @@ nonisolated final class SSHTunnel: @unchecked Sendable { clientRelays.notify(queue: relayQueue) { continuation.resume() } } + // Free before close: a closed number can go to the next tunnel's socket, and this session's DISCONNECT would land in it. sessionQueue.sync { - Darwin.close(listenFD) - Darwin.close(socketFD) libssh2_session_set_blocking(session, 1) tablepro_libssh2_session_disconnect(session, "Closing tunnel") libssh2_session_free(session) + Darwin.close(socketFD) + Darwin.close(listenFD) } await SSHJumpChain.tearDown(jumpHops) @@ -248,11 +253,14 @@ nonisolated final class SSHTunnel: @unchecked Sendable { deadline: acceptedAt.addingTimeInterval(Self.channelOpenDeadlineSeconds), pollForReadiness: { [weak self] directions in guard let self else { return false } - return pollReady( + // Another open or relay can read this channel's reply into libssh2's queue and leave the socket quiet, + // so a quiet wait is a retry, and only the deadline ends the open. + _ = pollReady( fd: self.socketFD, directions: directions, timeoutMs: Self.channelOpenPollTimeoutMs ) + return true } ) @@ -302,14 +310,38 @@ nonisolated final class SSHTunnel: @unchecked Sendable { Darwin.close(clientFD) guard isRunning else { return } - sessionQueue.sync { - libssh2_channel_close(channel) - libssh2_channel_free(channel) - } + freeChannel(channel) if termination == .transportHangup { Self.logger.info("SSH transport hung up, marking tunnel dead on port \(self.localPort)") markDead() } } + + // Another relay can read the CLOSE reply off the shared socket and leave it quiet, so free runs again after every wait. + private func freeChannel(_ channel: OpaquePointer) { + let session = self.session + let sessionQueue = self.sessionQueue + let socketFD = self.socketFD + let outcome = SSHChannelFreePump( + attemptFree: { + sessionQueue.sync { + guard libssh2_channel_free(channel) == LIBSSH2_ERROR_EAGAIN else { return .finished } + return .wouldBlock(RelayDirections(libssh2BlockDirections: libssh2_session_block_directions(session))) + } + }, + isActive: { [weak self] in self?.isRunning ?? false }, + deadline: Date().addingTimeInterval(Self.channelFreeDeadlineSeconds), + waitForTransport: { directions in + _ = pollReady(fd: socketFD, directions: directions, timeoutMs: Self.channelFreePollTimeoutMs) + } + ).run() + + if outcome == .timedOut { + Self.logger.warning( + "Channel on port \(self.localPort) did not close within \(Int(Self.channelFreeDeadlineSeconds))s, marking tunnel dead" + ) + markDead() + } + } } diff --git a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift index e2311993f5..2c1b13568e 100644 --- a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift +++ b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift @@ -1,4 +1,6 @@ import Foundation +import TableProModels +import TableProSSHTransport nonisolated enum SSHTunnelError: Error, LocalizedError, Equatable, Sendable { case connectionFailed(String) @@ -14,6 +16,13 @@ nonisolated enum SSHTunnelError: Error, LocalizedError, Equatable, Sendable { case jumpHostFailed(host: String, reason: String) case jumpHostKeysUnreadable(String) case tunnelClosed + case negotiationFailed(SSHNegotiationFailure) + case authMethodUnsupported(SSHConfiguration.SSHAuthMethod) + case securityKeyUnsupported + case passphraseRequired + case passphraseIncorrect + case additionalFactorRequired([String]) + case promptUnanswerable(String) var errorDescription: String? { switch self { @@ -38,6 +47,85 @@ nonisolated enum SSHTunnelError: Error, LocalizedError, Equatable, Sendable { case .jumpHostKeysUnreadable(let reason): return String(format: String(localized: "The jump host keys on this device could not be read: %@"), reason) case .tunnelClosed: return "SSH tunnel is closed" + case .negotiationFailed(let failure): + return Self.describe(failure) + case .authMethodUnsupported(let method): + return String( + format: String(localized: "TablePro on iPhone and iPad cannot use the SSH authentication method “%@”."), + Self.name(of: method) + ) + case .securityKeyUnsupported: + return String( + localized: "This SSH private key belongs to a security key (FIDO), which TablePro on iPhone and iPad cannot sign with." + ) + case .passphraseRequired: + return String(localized: "This SSH private key is encrypted, and no passphrase is saved for it.") + case .passphraseIncorrect: + return String(localized: "The passphrase for this SSH private key is incorrect.") + case .additionalFactorRequired(let methods): + return String( + format: String( + localized: "The SSH server accepted the first sign-in step and asks for another (%@), which TablePro on iPhone and iPad cannot complete." + ), + methods.formatted(.list(type: .or)) + ) + case .promptUnanswerable(let prompt): + return String( + format: String(localized: "The SSH server asked “%@”, which TablePro on iPhone and iPad cannot answer."), + prompt + ) + } + } + + private static func name(of method: SSHConfiguration.SSHAuthMethod) -> String { + switch method { + case .password: return String(localized: "Password") + case .privateKey: return String(localized: "Private Key") + case .sshAgent: return String(localized: "SSH Agent") + case .keyboardInteractive: return String(localized: "Keyboard Interactive") + case .none: return String(localized: "None") } } + + private static func describe(_ failure: SSHNegotiationFailure) -> String { + var sentences: [String] = [] + + switch failure.stage { + case .keyExchangeOrHostKey: + sentences.append(String(localized: "TablePro and the SSH server have no key exchange or host key method in common.")) + case .cipher: + sentences.append(String(localized: "TablePro and the SSH server have no cipher in common.")) + case .mac: + sentences.append(String(localized: "TablePro and the SSH server have no MAC algorithm in common.")) + case .undetermined: + sentences.append(String(localized: "TablePro and the SSH server could not agree on how to encrypt the connection.")) + } + + if let software = failure.serverSoftware { + sentences.append(String(format: String(localized: "The server runs %@."), software)) + } + + if !failure.supported.isEmpty { + let list = failure.supported.formatted(.list(type: .and)) + switch failure.stage { + case .keyExchangeOrHostKey: + sentences.append(String(format: String(localized: "TablePro supports these key exchange methods: %@."), list)) + case .cipher: + sentences.append(String(format: String(localized: "TablePro supports these ciphers: %@."), list)) + case .mac: + sentences.append(String(format: String(localized: "TablePro supports these MAC algorithms: %@."), list)) + case .undetermined: + break + } + } + + if !failure.supportedHostKeys.isEmpty { + sentences.append(String( + format: String(localized: "It supports these host key types: %@."), + failure.supportedHostKeys.formatted(.list(type: .and)) + )) + } + + return sentences.joined(separator: " ") + } } diff --git a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift index b9e50561fb..13ddf6472a 100644 --- a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift +++ b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift @@ -192,7 +192,9 @@ nonisolated enum SSHTunnelFactory { } catch let error as SSHTunnelError { switch error { case .connectionFailed, .handshakeFailed, .authenticationFailed, .noAvailablePort, - .channelOpenFailed, .tunnelClosed: + .channelOpenFailed, .tunnelClosed, .negotiationFailed, .authMethodUnsupported, + .securityKeyUnsupported, .passphraseRequired, .passphraseIncorrect, + .additionalFactorRequired, .promptUnanswerable: throw SSHTunnelError.jumpHostFailed(host: target.host, reason: error.localizedDescription) case .hostKeyRejected, .hostKeyUnverified, .usernameMissing, .jumpHostNeedsKey, .jumpHostSharedKeyMissing, .jumpHostFailed, .jumpHostKeysUnreadable: @@ -225,9 +227,7 @@ nonisolated enum SSHTunnelFactory { try await stages.authenticateNone(username: config.username) case .sshAgent, .keyboardInteractive: - throw SSHTunnelError.authenticationFailed( - "Auth method \(config.authMethod.rawValue) not supported on iOS" - ) + throw SSHTunnelError.authMethodUnsupported(config.authMethod) } } diff --git a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelStages.swift b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelStages.swift index 281e231e5a..dc54036f64 100644 --- a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelStages.swift +++ b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelStages.swift @@ -164,55 +164,24 @@ nonisolated final class LibSSH2TunnelStages: SSHTunnelStages, @unchecked Sendabl } func authenticatePassword(username: String, password: String) async throws { - let current = try currentSession() - try await authenticate(as: username, label: "Password", on: current.queue) { - libssh2_userauth_password_ex( - current.handle.pointer, - username, - UInt32(username.utf8.count), - password, - UInt32(password.utf8.count), - nil - ) - } + try await authenticate(as: username, label: "Password") { try $0.password(password) } } func authenticatePublicKey(username: String, keyPath: String, passphrase: String?) async throws { - let current = try currentSession() let expandedPath = (keyPath as NSString).expandingTildeInPath guard FileManager.default.fileExists(atPath: expandedPath) else { throw SSHTunnelError.authenticationFailed("Private key not found at \(keyPath)") } - let publicKeyPath = expandedPath + ".pub" - let publicKeyPathOrNil: String? = FileManager.default.fileExists(atPath: publicKeyPath) ? publicKeyPath : nil - - try await authenticate(as: username, label: "Public key", on: current.queue) { - libssh2_userauth_publickey_fromfile_ex( - current.handle.pointer, - username, - UInt32(username.utf8.count), - publicKeyPathOrNil, - expandedPath, - passphrase - ) + try await authenticate(as: username, label: "Public key") { + try $0.publicKey(.readFile(at: expandedPath), passphrase: passphrase) } } func authenticatePublicKeyFromMemory(username: String, keyContent: String, passphrase: String?) async throws { - let current = try currentSession() - try await authenticate(as: username, label: "In-memory key", on: current.queue) { - keyContent.withCString { keyPointer in - libssh2_userauth_publickey_frommemory( - current.handle.pointer, - username, - username.utf8.count, - nil, 0, - keyPointer, keyContent.utf8.count, - passphrase - ) - } + try await authenticate(as: username, label: "In-memory key") { + try $0.publicKey(.memory(keyContent), passphrase: passphrase) } } @@ -306,17 +275,12 @@ nonisolated final class LibSSH2TunnelStages: SSHTunnelStages, @unchecked Sendabl private func authenticate( as username: String, label: String, - on queue: DispatchQueue, - _ attempt: @escaping @Sendable () -> Int32 + _ attempt: @escaping @Sendable (LibSSH2Authentication) throws -> Void ) async throws { + let current = try currentSession() try await runCancellableBlocking( - on: queue, - work: { - let rc = attempt() - guard rc == 0 else { - throw SSHTunnelError.authenticationFailed("\(label) authentication failed (error \(rc))") - } - } + on: current.queue, + work: { try attempt(LibSSH2Authentication(session: current.handle.pointer, username: username)) } ) Self.logger.debug("\(label, privacy: .public) authentication successful for \(username, privacy: .private)") } @@ -349,8 +313,9 @@ nonisolated final class LibSSH2TunnelStages: SSHTunnelStages, @unchecked Sendabl let rc = libssh2_session_handshake(session, socketFD) guard rc == 0 else { + let failure = LibSSH2HandshakeFailure.error(session: session, rc: rc) libssh2_session_free(session) - throw SSHTunnelError.handshakeFailed("Handshake failed (error \(rc))") + throw failure } return session diff --git a/apple/TableProMobile/TableProMobileTests/SSH/LibSSH2AuthenticationTests.swift b/apple/TableProMobile/TableProMobileTests/SSH/LibSSH2AuthenticationTests.swift new file mode 100644 index 0000000000..565c3a08a9 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/SSH/LibSSH2AuthenticationTests.swift @@ -0,0 +1,457 @@ +import Foundation +import TableProSSHTransport +import Testing + +@testable import TableProMobile + +struct LibSSH2AuthenticationTests { + // libssh2's return codes, named here because the test target does not import CLibSSH2. + static let rcFile: Int32 = -16 + static let rcAuthenticationFailed: Int32 = -18 + static let rcPublicKeyUnverified: Int32 = -19 + static let rcKeyFileAuthFailed: Int32 = -48 + + static let serverText = "Username/PublicKey combination invalid" + static let securityKey = openSSHKey(type: "sk-ssh-ed25519@openssh.com", encrypted: false) + static let encryptedPEM = """ + -----BEGIN RSA PRIVATE KEY----- + Proc-Type: 4,ENCRYPTED + DEK-Info: AES-128-CBC,00000000000000000000000000000000 + + AAAA + -----END RSA PRIVATE KEY----- + """ + + static let securityKeys: [LibSSH2Authentication.Key] = [ + .memory(securityKey), + .file(path: "/keys/id_ed25519_sk", publicKeyPath: "/keys/id_ed25519_sk.pub", contents: securityKey), + ] + + static let encryptedKeys = [openSSHKey(encrypted: true), encryptedPEM] + + static let keyFailures: [KeyFailureRow] = [ + KeyFailureRow(encrypted: true, rc: rcFile, expected: .passphraseIncorrect), + KeyFailureRow(encrypted: true, rc: rcKeyFileAuthFailed, expected: .passphraseIncorrect), + KeyFailureRow(encrypted: true, rc: rcPublicKeyUnverified, expected: .authenticationFailed(serverText)), + KeyFailureRow(encrypted: true, rc: rcAuthenticationFailed, expected: .authenticationFailed(serverText)), + KeyFailureRow(encrypted: false, rc: rcFile, expected: .authenticationFailed(serverText)), + KeyFailureRow(encrypted: false, rc: rcPublicKeyUnverified, expected: .authenticationFailed(serverText)), + ] + + static let localKeyFailures: [KeyFailureRow] = [ + KeyFailureRow(encrypted: true, rc: rcFile, expected: .passphraseIncorrect), + KeyFailureRow(encrypted: true, rc: rcKeyFileAuthFailed, expected: .passphraseIncorrect), + KeyFailureRow(encrypted: false, rc: rcFile, expected: .authenticationFailed(serverText)), + ] + + @Test("A NULL method list on a signed-in session is success", arguments: [true, false]) + func nullListWhileSignedIn(withKey: Bool) throws { + let session = ScriptedAuthSession(lists: [nil], authenticated: true) + let authentication = LibSSH2Authentication(session: session) + + if withKey { + try authentication.publicKey(.memory(Self.openSSHKey(encrypted: false)), passphrase: nil) + } else { + try authentication.password("secret") + } + + #expect(session.calls == [.methodList, .isAuthenticated]) + } + + @Test("A NULL method list on a session that is not signed in fails with libssh2's text") + func nullListWithoutSignIn() { + let session = ScriptedAuthSession(lists: [nil], listError: "Failed getting response") + + #expect(throws: SSHTunnelError.authenticationFailed("Failed getting response")) { + try LibSSH2Authentication(session: session).password("secret") + } + } + + @Test("A password the server accepts signs in") + func passwordAccepted() throws { + let session = ScriptedAuthSession(lists: ["publickey,password"]) + + try LibSSH2Authentication(session: session).password("secret") + + #expect(session.calls == [.methodList, .password]) + } + + @Test("The password's failure text is read before the method list is read again") + func lastErrorBeforeTheListAgain() { + let session = ScriptedAuthSession( + lists: ["password", nil], + listError: "Failed getting response", + password: .failing(Self.rcAuthenticationFailed, "Authentication failed (username/password)") + ) + + #expect(throws: SSHTunnelError.authenticationFailed("Authentication failed (username/password)")) { + try LibSSH2Authentication(session: session).password("secret") + } + #expect(session.calls == [.methodList, .password, .lastError, .methodList, .isAuthenticated]) + } + + @Test("A password accepted as the first of two steps names the next step") + func passwordPartialSuccess() { + let session = ScriptedAuthSession( + lists: ["password", "keyboard-interactive"], + password: .failing(Self.rcAuthenticationFailed, "Authentication failed (username/password)") + ) + + #expect(throws: SSHTunnelError.additionalFactorRequired(["keyboard-interactive"])) { + try LibSSH2Authentication(session: session).password("secret") + } + #expect(!session.calls.contains(.keyboardInteractive)) + } + + @Test("A refused password is tried again over keyboard-interactive") + func passwordRetriedOverKeyboardInteractive() throws { + let methods = "publickey,password,keyboard-interactive" + let session = ScriptedAuthSession( + lists: [methods, methods], + password: .failing(Self.rcAuthenticationFailed, "Authentication failed (username/password)"), + prompts: ["Password: "] + ) + + try LibSSH2Authentication(session: session).password("secret") + + #expect(session.sentAnswers == ["secret"]) + } + + @Test("A server without the password method gets the password over keyboard-interactive") + func passwordOverKeyboardInteractiveOnly() throws { + let session = ScriptedAuthSession(lists: ["publickey,keyboard-interactive"], prompts: ["Password: "]) + + try LibSSH2Authentication(session: session).password("secret") + + #expect(session.calls == [.methodList, .keyboardInteractive]) + #expect(session.sentAnswers == ["secret"]) + } + + @Test("A code prompt never gets the password and is named") + func codePromptIsUnanswerable() { + let session = ScriptedAuthSession( + lists: ["keyboard-interactive", "keyboard-interactive"], + prompts: ["Verification code: "], + keyboardInteractive: .failing(Self.rcAuthenticationFailed, "Authentication failed (keyboard-interactive)") + ) + + #expect(throws: SSHTunnelError.promptUnanswerable("Verification code:")) { + try LibSSH2Authentication(session: session).password("secret") + } + #expect(session.sentAnswers == [""]) + } + + @Test("A refused password keeps its own text when keyboard-interactive asks nothing") + func passwordTextOutlivesAnEmptyKeyboardInteractive() { + let methods = "password,keyboard-interactive" + let session = ScriptedAuthSession( + lists: [methods, methods, methods], + password: .failing(Self.rcAuthenticationFailed, "Authentication failed (username/password)"), + keyboardInteractive: .failing(Self.rcAuthenticationFailed, "Authentication failed (keyboard-interactive)") + ) + + #expect(throws: SSHTunnelError.authenticationFailed("Authentication failed (username/password)")) { + try LibSSH2Authentication(session: session).password("secret") + } + } + + @Test("A server that takes neither a password nor keyboard-interactive names what it takes") + func passwordNotOffered() { + let session = ScriptedAuthSession(lists: ["publickey"]) + + let error = #expect(throws: SSHTunnelError.self) { + try LibSSH2Authentication(session: session).password("secret") + } + + guard case .authenticationFailed(let message)? = error else { + Issue.record("expected authenticationFailed, got \(String(describing: error))") + return + } + #expect(message.contains("publickey")) + #expect(session.calls == [.methodList]) + } + + @Test("A password prompt gets the password", arguments: ["Password: ", "Password for deploy@db.internal: "]) + func passwordPromptGetsThePassword(prompt: String) { + let answers = KeyboardInteractiveAnswers(password: "secret") + + #expect(answers.answer(prompt) == "secret") + #expect(answers.unansweredPrompt == nil) + } + + @Test("A code or unknown prompt gets an empty answer", arguments: ["Verification code: ", "One-time password: ", "Enter PIN: "]) + func otherPromptsGetNothing(prompt: String) { + let answers = KeyboardInteractiveAnswers(password: "secret") + + #expect(answers.answer(prompt).isEmpty) + #expect(answers.unansweredPrompt == prompt.trimmingCharacters(in: .whitespaces)) + } + + @Test("A security key is named before anything reaches the server", arguments: LibSSH2AuthenticationTests.securityKeys) + func securityKeyIsNamed(key: LibSSH2Authentication.Key) { + let session = ScriptedAuthSession(lists: ["publickey"]) + + #expect(throws: SSHTunnelError.securityKeyUnsupported) { + try LibSSH2Authentication(session: session).publicKey(key, passphrase: "hunter2") + } + #expect(session.calls.isEmpty) + } + + @Test("An encrypted key with no passphrase is named before anything reaches the server", arguments: LibSSH2AuthenticationTests.encryptedKeys) + func encryptedKeyNeedsAPassphrase(text: String) { + let session = ScriptedAuthSession(lists: ["publickey"]) + + #expect(throws: SSHTunnelError.passphraseRequired) { + try LibSSH2Authentication(session: session).publicKey(.memory(text), passphrase: "") + } + #expect(session.calls.isEmpty) + } + + @Test("A key failure is named by the key and libssh2's return code", arguments: LibSSH2AuthenticationTests.keyFailures) + func keyFailure(row: KeyFailureRow) { + let session = ScriptedAuthSession(lists: ["publickey", "publickey"], key: .failing(row.rc, Self.serverText)) + + #expect(throws: row.expected) { + try LibSSH2Authentication(session: session).publicKey( + .memory(Self.openSSHKey(encrypted: row.encrypted)), + passphrase: "hunter2" + ) + } + } + + @Test("An encrypted key accepted as the first of two steps names the next step, not the passphrase") + func keyPartialSuccessComesFirst() { + let session = ScriptedAuthSession( + lists: ["publickey", "keyboard-interactive"], + key: .failing(Self.rcPublicKeyUnverified, Self.serverText) + ) + + #expect(throws: SSHTunnelError.additionalFactorRequired(["keyboard-interactive"])) { + try LibSSH2Authentication(session: session).publicKey( + .memory(Self.openSSHKey(encrypted: true)), + passphrase: "hunter2" + ) + } + #expect(session.calls == [.methodList, .keyMemory, .lastError, .methodList]) + } + + @Test("A key that fails to read never asks the server for its methods again", arguments: LibSSH2AuthenticationTests.localKeyFailures) + func localKeyFailureSkipsTheList(row: KeyFailureRow) { + let session = ScriptedAuthSession(lists: ["publickey", "keyboard-interactive"], key: .failing(row.rc, Self.serverText)) + + #expect(throws: row.expected) { + try LibSSH2Authentication(session: session).publicKey( + .memory(Self.openSSHKey(encrypted: row.encrypted)), + passphrase: "hunter2" + ) + } + #expect(!session.calls.dropFirst().contains(.methodList)) + } + + @Test("An encrypted key file goes without its .pub and a plain one keeps it", arguments: [true, false]) + func encryptedKeyFileDropsThePublicKey(encrypted: Bool) throws { + let session = ScriptedAuthSession(lists: ["publickey"]) + let key = LibSSH2Authentication.Key.file( + path: "/keys/id_ed25519", + publicKeyPath: "/keys/id_ed25519.pub", + contents: Self.openSSHKey(encrypted: encrypted) + ) + + try LibSSH2Authentication(session: session).publicKey(key, passphrase: "hunter2") + + #expect(session.calls.last == .keyFile(publicKeyPath: encrypted ? nil : "/keys/id_ed25519.pub")) + } + + @Test("An encrypted key file keeps a .pub that holds its certificate") + func encryptedKeyFileKeepsItsCertificate() throws { + let session = ScriptedAuthSession(lists: ["publickey"]) + let key = LibSSH2Authentication.Key.file( + path: "/keys/id_ed25519", + publicKeyPath: "/keys/id_ed25519.pub", + contents: Self.openSSHKey(encrypted: true), + publicKeyIsCertificate: true + ) + + try LibSSH2Authentication(session: session).publicKey(key, passphrase: "hunter2") + + #expect(session.calls.last == .keyFile(publicKeyPath: "/keys/id_ed25519.pub")) + } + + @Test("A key file reads whether its .pub holds a certificate", arguments: [ + ("ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQ= user\n", true), + ("rsa-sha2-512-cert-v01@openssh.com AAAA", true), + ("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 user\n", false), + ]) + func keyFileReadsItsCertificate(publicKeyLine: String, isCertificate: Bool) throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let path = directory.appendingPathComponent("id_ed25519").path + try Self.openSSHKey(encrypted: true).write(toFile: path, atomically: true, encoding: .utf8) + try publicKeyLine.write(toFile: path + ".pub", atomically: true, encoding: .utf8) + + let key = LibSSH2Authentication.Key.readFile(at: path) + + #expect(key == .file( + path: path, + publicKeyPath: path + ".pub", + contents: Self.openSSHKey(encrypted: true), + publicKeyIsCertificate: isCertificate + )) + } + + @Test("A server that takes no key names what it takes") + func keyNotOffered() { + let session = ScriptedAuthSession(lists: ["password,keyboard-interactive"]) + + let error = #expect(throws: SSHTunnelError.self) { + try LibSSH2Authentication(session: session).publicKey(.memory(Self.openSSHKey(encrypted: false)), passphrase: nil) + } + + guard case .authenticationFailed(let message)? = error else { + Issue.record("expected authenticationFailed, got \(String(describing: error))") + return + } + #expect(message.contains("keyboard-interactive")) + #expect(session.calls == [.methodList]) + } + + // An openssh-key-v1 header: cipher, KDF name, KDF options, key count, then the public key blob. + static func openSSHKey(type: String = "ssh-ed25519", encrypted: Bool) -> String { + let publicKey = Data([field(Data(type.utf8)), field(Data(repeating: 7, count: 32))].joined()) + let body = Data([ + Data("openssh-key-v1\0".utf8), + field(Data((encrypted ? "aes256-ctr" : "none").utf8)), + field(Data((encrypted ? "bcrypt" : "none").utf8)), + field(Data()), + uint32(1), + field(publicKey), + field(Data(repeating: 0, count: 64)), + ].joined()) + return """ + -----BEGIN OPENSSH PRIVATE KEY----- + \(body.base64EncodedString(options: .lineLength64Characters)) + -----END OPENSSH PRIVATE KEY----- + """ + } + + private static func field(_ bytes: Data) -> Data { + uint32(UInt32(bytes.count)) + bytes + } + + private static func uint32(_ value: UInt32) -> Data { + withUnsafeBytes(of: value.bigEndian) { Data($0) } + } +} + +nonisolated struct KeyFailureRow: Sendable, CustomTestStringConvertible { + let encrypted: Bool + let rc: Int32 + let expected: SSHTunnelError + + var testDescription: String { + "\(encrypted ? "encrypted" : "plain") key, rc \(rc)" + } +} + +// Stands in for a libssh2 session. libssh2 keeps one last error per session, and a failed +// list read overwrites it, so this one does too. +nonisolated final class ScriptedAuthSession: SSHUserAuthSession { + nonisolated struct Reply: Sendable { + static let accepted = Reply(rc: 0, error: nil) + + let rc: Int32 + let error: String? + + static func failing(_ rc: Int32, _ error: String) -> Reply { + Reply(rc: rc, error: error) + } + } + + nonisolated enum Call: Equatable { + case methodList + case isAuthenticated + case lastError + case password + case keyboardInteractive + case keyFile(publicKeyPath: String?) + case keyMemory + } + + private var lists: [String?] + private let authenticated: Bool + private let listError: String? + private let passwordReply: Reply + private let prompts: [String] + private let keyboardInteractiveReply: Reply + private let keyReply: Reply + private var currentError: String? + + private(set) var calls: [Call] = [] + private(set) var sentAnswers: [String] = [] + + init( + lists: [String?], + authenticated: Bool = false, + listError: String? = nil, + password: Reply = .accepted, + prompts: [String] = [], + keyboardInteractive: Reply = .accepted, + key: Reply = .accepted + ) { + self.lists = lists + self.authenticated = authenticated + self.listError = listError + self.passwordReply = password + self.prompts = prompts + self.keyboardInteractiveReply = keyboardInteractive + self.keyReply = key + } + + func methodList() -> String? { + calls.append(.methodList) + let list = lists.isEmpty ? nil : lists.removeFirst() + if list == nil, let listError { + currentError = listError + } + return list + } + + func isAuthenticated() -> Bool { + calls.append(.isAuthenticated) + return authenticated + } + + func lastError() -> String? { + calls.append(.lastError) + return currentError + } + + func password(_ password: String) -> Int32 { + calls.append(.password) + return reply(passwordReply) + } + + func keyboardInteractive(_ answers: KeyboardInteractiveAnswers) -> Int32 { + calls.append(.keyboardInteractive) + sentAnswers += prompts.map { answers.answer($0) } + return reply(keyboardInteractiveReply) + } + + func publicKeyFromFile(privateKeyPath: String, publicKeyPath: String?, passphrase: String?) -> Int32 { + calls.append(.keyFile(publicKeyPath: publicKeyPath)) + return reply(keyReply) + } + + func publicKeyFromMemory(_ privateKey: String, passphrase: String?) -> Int32 { + calls.append(.keyMemory) + return reply(keyReply) + } + + private func reply(_ reply: Reply) -> Int32 { + if reply.rc != 0 { + currentError = reply.error + } + return reply.rc + } +} diff --git a/apple/TableProMobile/TableProMobileTests/SSH/LibSSH2ChannelIOTests.swift b/apple/TableProMobile/TableProMobileTests/SSH/LibSSH2ChannelIOTests.swift new file mode 100644 index 0000000000..ea1e1ccfbc --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/SSH/LibSSH2ChannelIOTests.swift @@ -0,0 +1,56 @@ +// +// LibSSH2ChannelIOTests.swift +// TableProMobileTests +// +// Measured: a stopped sshd makes libssh2_channel_read return -43 at once; read as a channel close, the tunnel lived on until the keep-alive failed, about 37 s later. +// + +import TableProSSHTransport +import Testing + +@testable import TableProMobile + +struct LibSSH2ChannelIOTests { + // OUTOFORDER, FAILURE, REQUEST_DENIED, UNKNOWN, WINDOW_EXCEEDED, PACKET_EXCEEDED, CLOSED, EOF_SENT, WINDOW_FULL + static let channelScopedCodes = [-20, -21, -22, -23, -24, -25, -26, -27, -47] + + // SOCKET_RECV, SOCKET_SEND, SOCKET_DISCONNECT, DECRYPT, OUT_OF_BOUNDARY, KEX_FAILURE, KEY_EXCHANGE_FAILURE, ALLOC, PROTO, SOCKET_NONE, INVALID_MAC + static let transportCodes = [-43, -7, -13, -12, -41, -5, -8, -6, -14, -1, -4] + + @Test("A channel-scoped read error closes only the channel", arguments: LibSSH2ChannelIOTests.channelScopedCodes) + func channelScopedReadErrorCloses(code: Int) { + #expect(LibSSH2ChannelIO.readResult(code) == .closed) + } + + @Test("A channel-scoped write error closes only the channel", arguments: LibSSH2ChannelIOTests.channelScopedCodes) + func channelScopedWriteErrorCloses(code: Int) { + #expect(LibSSH2ChannelIO.writeResult(code) == .closed) + } + + @Test("A transport read error fails the transport", arguments: LibSSH2ChannelIOTests.transportCodes) + func transportReadErrorFails(code: Int) { + #expect(LibSSH2ChannelIO.readResult(code) == .transportFailed) + } + + @Test("A transport write error fails the transport", arguments: LibSSH2ChannelIOTests.transportCodes) + func transportWriteErrorFails(code: Int) { + #expect(LibSSH2ChannelIO.writeResult(code) == .transportFailed) + } + + @Test("EAGAIN would block on read and write") + func eagainWouldBlock() { + #expect(LibSSH2ChannelIO.readResult(-37) == .wouldBlock) + #expect(LibSSH2ChannelIO.writeResult(-37) == .wouldBlock) + } + + @Test("A positive count is the bytes moved") + func positiveCountIsBytes() { + #expect(LibSSH2ChannelIO.readResult(512) == .bytes(512)) + #expect(LibSSH2ChannelIO.writeResult(1) == .bytes(1)) + } + + @Test("A zero-byte read is a closed channel") + func zeroReadIsClosed() { + #expect(LibSSH2ChannelIO.readResult(0) == .closed) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/SSH/SSHTunnelFactoryTests.swift b/apple/TableProMobile/TableProMobileTests/SSH/SSHTunnelFactoryTests.swift index 10843689a1..bca38a3250 100644 --- a/apple/TableProMobile/TableProMobileTests/SSH/SSHTunnelFactoryTests.swift +++ b/apple/TableProMobile/TableProMobileTests/SSH/SSHTunnelFactoryTests.swift @@ -78,7 +78,7 @@ struct SSHTunnelFactoryTests { func unsupportedAuthMethodDiscards() async { let stages = SpyTunnelStages() - await #expect(throws: SSHTunnelError.self) { + await #expect(throws: SSHTunnelError.authMethodUnsupported(.keyboardInteractive)) { try await Self.create(stages: stages, authMethod: .keyboardInteractive) } @@ -350,6 +350,38 @@ struct SSHTunnelFactoryTests { #expect(stages.discardCount == 1) } + @Test( + "A named failure at a hop names the hop and keeps its words", + arguments: zip( + [SpyTunnelStages.Step.handshake, .authenticatePrivateKey, .authenticatePrivateKey, .authenticatePrivateKey], + [ + SSHTunnelError.negotiationFailed(SSHNegotiationFailure( + stage: .keyExchangeOrHostKey, + serverSoftware: "OpenSSH_10.3", + supported: ["curve25519-sha256"], + supportedHostKeys: ["ssh-ed25519"] + )), + .passphraseIncorrect, + .securityKeyUnsupported, + .additionalFactorRequired(["keyboard-interactive"]), + ] + ) + ) + func namedHopFailureNamesTheHop(step: SpyTunnelStages.Step, failure: SSHTunnelError) async { + let stages = SpyTunnelStages(failingAt: step, with: failure) + + await #expect(throws: SSHTunnelError.jumpHostFailed(host: Self.bastion.host, reason: failure.localizedDescription)) { + try await Self.create( + stages: stages, + authMethod: .password, + jumpHosts: [Self.bastion], + jumpHostCredentials: [Self.bastion.id: .privateKey(key: "HOP KEY", passphrase: nil)] + ) + } + + #expect(stages.discardCount == 1) + } + @Test("A channel the hop could not open names that hop") func jumpFailureNamesTheHopThatOpenedIt() async { let refusal = SSHTunnelError.channelOpenFailed("127.0.0.1:22: Channel open failure (administratively prohibited)") diff --git a/apple/TableProMobile/TableProMobileTests/SSHTunnelErrorClassifierTests.swift b/apple/TableProMobile/TableProMobileTests/SSHTunnelErrorClassifierTests.swift new file mode 100644 index 0000000000..8b6d24d9f7 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/SSHTunnelErrorClassifierTests.swift @@ -0,0 +1,119 @@ +import Foundation +import TableProModels +import TableProSSHTransport +import Testing + +@testable import TableProMobile + +struct SSHTunnelErrorClassifierTests { + private static let context = ErrorContext(operation: "connect", host: "db.example.com", sshEnabled: true) + + static let fallThrough: [SSHTunnelError] = [ + .connectionFailed("Connection timed out"), + .handshakeFailed("Failed getting banner"), + .authenticationFailed("Authentication failed (username/password)"), + .noAvailablePort, + .channelOpenFailed("Channel open failure"), + .hostKeyRejected("The server's host key was not trusted."), + .hostKeyUnverified("The server's host key could not be checked."), + .tunnelClosed, + ] + + @Test("A key exchange failure gets the negotiation recovery, not the credentials one") + func keyExchangeFailureIsNotACredentialsProblem() { + let failure = SSHNegotiationFailure( + stage: .keyExchangeOrHostKey, + serverSoftware: "OpenSSH_10.3", + supported: ["curve25519-sha256", "ecdh-sha2-nistp256"], + supportedHostKeys: ["ssh-ed25519", "rsa-sha2-512"] + ) + + let error = ErrorClassifier.classify(SSHTunnelError.negotiationFailed(failure), context: Self.context) + + #expect(error.category == .ssh) + #expect(error.recovery == String(localized: "Ask the SSH server's administrator to turn on a method TablePro supports.")) + #expect(error.recovery != String(localized: "Check your SSH username, password, or private key.")) + #expect(error.message.contains("OpenSSH_10.3")) + #expect(error.message.contains("curve25519-sha256")) + #expect(error.message.contains("rsa-sha2-512")) + } + + @Test("An SSH connect that timed out keeps the Local Network recovery") + func connectTimeoutKeepsTheLocalNetworkRecovery() { + let error = ErrorClassifier.classify(SSHTunnelError.connectionFailed("Connection timed out"), context: Self.context) + + #expect(error.category == .network) + #expect(error.recovery == String( + localized: "Local Network access may be blocked. Open Settings > Privacy & Security > Local Network and turn TablePro on." + )) + } + + @Test("A rejected host key keeps its title") + func rejectedHostKeyKeepsItsTitle() { + let error = ErrorClassifier.classify( + SSHTunnelError.hostKeyRejected(String(localized: "The server's host key was not trusted.")), + context: Self.context + ) + + #expect(error.category == .system) + #expect(error.title == String(localized: "Error")) + } + + @Test("Only the named cases are classified by case", arguments: SSHTunnelErrorClassifierTests.fallThrough) + func olderCasesFallThrough(error: SSHTunnelError) { + #expect(ErrorClassifier.sshTunnelFailure(error) == nil) + } + + @Test("An auth method iOS cannot use is named and points to the Mac") + func unsupportedMethodPointsToTheMac() { + let error = ErrorClassifier.classify( + SSHTunnelError.authMethodUnsupported(.keyboardInteractive), + context: Self.context + ) + + #expect(error.category == .config) + #expect(error.title == String(localized: "SSH Sign-In Unavailable")) + #expect(error.message.contains(String(localized: "Keyboard Interactive"))) + #expect(error.recovery == String( + localized: "Open this connection in TablePro on the Mac, or edit it and choose Password or Private Key." + )) + } + + @Test("A security key points to the Mac") + func securityKeyPointsToTheMac() { + let error = ErrorClassifier.classify(SSHTunnelError.securityKeyUnsupported, context: Self.context) + + #expect(error.category == .config) + #expect(error.recovery == String( + localized: "Open this connection in TablePro on the Mac, or edit it and choose a private key that is not on a security key." + )) + } + + @Test( + "A second sign-in step is not read as a wrong credential", + arguments: [SSHTunnelError.additionalFactorRequired(["keyboard-interactive"]), .promptUnanswerable("Verification code:")] + ) + func secondStepPointsToTheMac(error tunnelError: SSHTunnelError) { + let error = ErrorClassifier.classify(tunnelError, context: Self.context) + + #expect(error.category == .config) + #expect(error.title == String(localized: "SSH Sign-In Unavailable")) + #expect(error.recovery == String(localized: "Open this connection in TablePro on the Mac, which can answer this sign-in step.")) + } + + @Test("A missing passphrase names the key's passphrase") + func missingPassphrase() { + let error = ErrorClassifier.classify(SSHTunnelError.passphraseRequired, context: Self.context) + + #expect(error.category == .auth) + #expect(error.recovery == String(localized: "Edit the connection and enter the key's passphrase under Private Key.")) + } + + @Test("A wrong passphrase names the key's passphrase") + func wrongPassphrase() { + let error = ErrorClassifier.classify(SSHTunnelError.passphraseIncorrect, context: Self.context) + + #expect(error.category == .auth) + #expect(error.recovery == String(localized: "Edit the connection and check the key's passphrase under Private Key.")) + } +} diff --git a/apple/TableProTests/Core/SSH/Auth/KeyboardInteractiveContextTests.swift b/apple/TableProTests/Core/SSH/Auth/KeyboardInteractiveContextTests.swift index bbfc52d10a..082ba81b74 100644 --- a/apple/TableProTests/Core/SSH/Auth/KeyboardInteractiveContextTests.swift +++ b/apple/TableProTests/Core/SSH/Auth/KeyboardInteractiveContextTests.swift @@ -11,6 +11,7 @@ import Foundation import TableProPluginKit @testable import TableProSSHConfig +import TableProSSHTransport import Testing @testable import TablePro diff --git a/apple/changelog.d/3687.fixed.1.md b/apple/changelog.d/3687.fixed.1.md new file mode 100644 index 0000000000..23741e185b --- /dev/null +++ b/apple/changelog.d/3687.fixed.1.md @@ -0,0 +1 @@ +Password sign-in on iPhone and iPad failed on SSH servers that ask for the password through keyboard-interactive. diff --git a/apple/changelog.d/3687.fixed.2.md b/apple/changelog.d/3687.fixed.2.md new file mode 100644 index 0000000000..1aee53e565 --- /dev/null +++ b/apple/changelog.d/3687.fixed.2.md @@ -0,0 +1 @@ +A missing or wrong SSH key passphrase, or a security key, showed only an error number on iPhone and iPad. diff --git a/apple/changelog.d/3687.fixed.3.md b/apple/changelog.d/3687.fixed.3.md new file mode 100644 index 0000000000..9560fbb21f --- /dev/null +++ b/apple/changelog.d/3687.fixed.3.md @@ -0,0 +1 @@ +An SSH server that asks for a second sign-in step looked like a wrong password on iPhone and iPad. diff --git a/apple/changelog.d/3687.fixed.4.md b/apple/changelog.d/3687.fixed.4.md new file mode 100644 index 0000000000..c3d4cee1ac --- /dev/null +++ b/apple/changelog.d/3687.fixed.4.md @@ -0,0 +1 @@ +An SSH connection noticed that its server had stopped only after the second missed keep-alive. diff --git a/apple/changelog.d/3687.fixed.5.md b/apple/changelog.d/3687.fixed.5.md new file mode 100644 index 0000000000..d3d884eb2d --- /dev/null +++ b/apple/changelog.d/3687.fixed.5.md @@ -0,0 +1 @@ +TablePro could quit when a connection through an SSH tunnel closed while data was still arriving. diff --git a/apple/changelog.d/3687.fixed.md b/apple/changelog.d/3687.fixed.md new file mode 100644 index 0000000000..4f633ec4dd --- /dev/null +++ b/apple/changelog.d/3687.fixed.md @@ -0,0 +1 @@ +An SSH server with no key exchange, cipher or MAC in common showed only an error number on iPhone and iPad. diff --git a/apple/scripts/lib/openssh-fixture.sh b/apple/scripts/lib/openssh-fixture.sh new file mode 100644 index 0000000000..d601cd81c8 --- /dev/null +++ b/apple/scripts/lib/openssh-fixture.sh @@ -0,0 +1,42 @@ +# The pinned OpenSSH server images the SSH probes run against, and the readiness check they share. +# +# Sourced, never executed. A script in apple/scripts picks this up with: +# +# source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/openssh-fixture.sh" +# +# Every function here starts with openssh_, because scripts/ci/check-lib-sourcing.py claims a +# library's function names for every script under apple/scripts. +# +# shellcheck shell=bash +# shellcheck disable=SC2034 # the image names are read by the scripts that source this + +[ -n "${TABLEPRO_LIB_OPENSSH_FIXTURE_SOURCED:-}" ] && return 0 +TABLEPRO_LIB_OPENSSH_FIXTURE_SOURCED=1 + +# Tag and digest, so a re-pushed tag cannot change the server under a probe. The 10.3 tag is the +# one linux/crates/test-fixtures/src/fixture_image.rs runs. Both images run sshd as the login user +# and only that user can sign in. +OPENSSH_FIXTURE_IMAGE_10="linuxserver/openssh-server:version-10.3_p1-r1@sha256:0f535b0faa7448511b23528165e9fbd4f1fe563b861b3dcaa5ddb0ddbfe2254c" +OPENSSH_FIXTURE_IMAGE_9="linuxserver/openssh-server:version-9.9_p2-r0@sha256:edbbd662675be4f6a06c76c24f785adf68d7c69156152280842788e85d152a44" +OPENSSH_FIXTURE_REDIS_IMAGE="redis:8.8.3-alpine@sha256:0b2b77d3ea5078274795e3177cdbdada8b96316684a38911d528534ed679b5ec" + +# Prints the server's identification line. An open port proves nothing: Docker's port proxy +# accepts a connection before sshd listens and then closes it, and libssh2 fails that connection +# with -13, "Failed getting banner". +openssh_banner() { + local host="$1" port="$2" line + line="$( (exec 3<> "/dev/tcp/$host/$port" && IFS= read -r -t 3 reply <&3 && printf '%s' "$reply") 2> /dev/null)" || return 1 + line="${line%$'\r'}" + [[ "$line" == SSH-2.0-* ]] || return 1 + printf '%s\n' "$line" +} + +openssh_wait_for_banner() { + local host="$1" port="$2" seconds="${3:-60}" deadline + deadline=$((SECONDS + seconds)) + while ((SECONDS < deadline)); do + openssh_banner "$host" "$port" > /dev/null && return 0 + sleep 0.25 + done + return 1 +} diff --git a/apple/scripts/probes/check-ssh-tunnel-matrix.sh b/apple/scripts/probes/check-ssh-tunnel-matrix.sh new file mode 100755 index 0000000000..f5fd48b3cd --- /dev/null +++ b/apple/scripts/probes/check-ssh-tunnel-matrix.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +# +# Opens the iOS app's SSH tunnel against real OpenSSH servers and sends a Redis PING through it, +# once per cell: server version, key type and format, key source, auth method, key exchange, many +# clients and many tunnels, and a server that dies mid-session. A cell passes when it connects or +# ends with the error it names; anything else fails the run. +# +# The tunnel is the app's own code (TableProMobile/TableProMobile/SSH) built for the simulator +# against the libssh2 the app ships (ssh-tunnel-matrix/build-ios-harness.sh), and every cell runs in +# the simulator through `xcrun simctl spawn -s`, which needs no booted device. The servers come from +# scripts/ssh-test-servers.sh. It is a probe, not a test, because no CI job has Docker. +# +# Usage: +# scripts/probes/check-ssh-tunnel-matrix.sh # every cell, then remove the servers +# scripts/probes/check-ssh-tunnel-matrix.sh --keep # leave the servers running afterwards +# scripts/probes/check-ssh-tunnel-matrix.sh [--keep] pq # only the cells whose name holds "pq" +# +# TABLEPRO_SIMULATOR_UDID picks the simulator (default: a shut-down iPhone on the newest iOS), and +# TABLEPRO_SSH_MATRIX_BUILD the build directory, which later runs reuse. +# +# Exits 1 when a cell misses, 2 when the harness, the simulator or the servers cannot start. + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +HERE="$ROOT/scripts/probes/ssh-tunnel-matrix" +SERVERS="$ROOT/scripts/ssh-test-servers.sh" +BUILD="${TABLEPRO_SSH_MATRIX_BUILD:-${TMPDIR:-/tmp}/tablepro-ssh-matrix-$(printf '%s' "$ROOT" | shasum | cut -c1-8)}" +KEEP=0 +FILTER="" +for argument in "$@"; do + case "$argument" in + --keep) KEEP=1 ;; + -*) + awk 'NR > 2 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}" + exit 2 + ;; + *) FILTER="$argument" ;; + esac +done + +WORK="$(mktemp -d)" +cleanup() { + rm -rf "$WORK" + [ "$KEEP" = 1 ] || "$SERVERS" down > /dev/null 2>&1 || true +} +trap cleanup EXIT + +setup_failure() { + echo "SETUP $*" >&2 + exit 2 +} + +pick_simulator() { + xcrun simctl list devices available --json 2> /dev/null | python3 -I -c ' +import json +import sys + +candidates = [] +for runtime, devices in json.load(sys.stdin)["devices"].items(): + name = runtime.rsplit(".", 1)[-1].split("-") + if name[0] != "iOS": + continue + version = [int(part) for part in name[1:] if part.isdigit()] + for device in devices: + if device.get("isAvailable") and device["name"].startswith("iPhone"): + candidates.append((device["state"] != "Shutdown", [-part for part in version], device["udid"])) +candidates.sort() +print(candidates[0][2] if candidates else "") +' +} + +HARNESS="$("$HERE/build-ios-harness.sh" "$BUILD")" || exit 2 +UDID="${TABLEPRO_SIMULATOR_UDID:-$(pick_simulator)}" +[ -n "$UDID" ] || setup_failure "no iPhone simulator; add an iOS runtime in Xcode, Settings, Components" +SELFTEST="$(xcrun simctl spawn -s "$UDID" "$HARNESS" --mode selftest 2>&1)" +[[ "$SELFTEST" == *"HARNESS ok"* ]] || setup_failure "the simulator did not run the harness: $SELFTEST" + +"$SERVERS" up || setup_failure "the SSH test servers did not start" +eval "$("$SERVERS" env)" || setup_failure "could not read the SSH test servers' settings" +echo "Harness $HARNESS on simulator $UDID" +echo "" + +RAN=0 +PASSED=0 +MISSED=0 + +selected() { + [ -z "$FILTER" ] || [[ "$1" == *"$FILTER"* ]] +} + +report() { + local name="$1" expected="$2" result="$3" verdict="$4" + RAN=$((RAN + 1)) + if [ "$verdict" = "pass" ]; then + PASSED=$((PASSED + 1)) + printf 'PASS %-38s %s\n' "$name" "$result" + else + MISSED=$((MISSED + 1)) + printf 'MISS %-38s expected %s\n %-38s got %s\n' "$name" "$expected" "" "$result" + fi +} + +cell() { + local name="$1" expected="$2" port="$3" output result verdict="miss" + shift 3 + selected "$name" || return 0 + output="$(xcrun simctl spawn -s "$UDID" "$HARNESS" --known-hosts "$WORK" --port "$port" \ + --user "$SSH_TEST_USER" --target "$SSH_TEST_TARGET" "$@" 2>&1)" + result="$(printf '%s\n' "$output" | grep -E '^(CONNECTED|ERROR)' | tail -1)" + [ -n "$result" ] || result="no result line: $(printf '%s' "$output" | tail -3 | tr '\n' ' ')" + # shellcheck disable=SC2053 # matched as a glob on purpose + [[ "$result" == $expected ]] && verdict="pass" + report "$name" "$expected" "$result" "$verdict" +} + +PASSWORD=(--auth password --password "$SSH_TEST_PASSWORD") +CONNECTED="CONNECTED" +KEX="ERROR negotiationFailed(*Stage.keyExchangeOrHostKey*" +MAC="ERROR negotiationFailed(*Stage.mac,*" +SECOND_FACTOR="ERROR additionalFactorRequired(*keyboard-interactive*" + +# A key pasted into the app arrives in memory; a key file may or may not have its .pub beside it. +key_cells() { + local server="$1" port="$2" key + for key in ed25519 ed25519-pass rsa rsa-pass ecdsa ecdsa-pass rsa-pem rsa-pem-pass ecdsa-pem ecdsa-pem-pass; do + local passphrase=() + [[ "$key" == *-pass ]] && passphrase=(--passphrase "$SSH_TEST_KEY_PASSPHRASE") + cell "$server/$key/file" "$CONNECTED" "$port" --auth key --key-file "$SSH_TEST_KEYS/$key" \ + ${passphrase[@]+"${passphrase[@]}"} + cell "$server/$key/file-without-pub" "$CONNECTED" "$port" --auth key --key-file "$SSH_TEST_KEYS_NOPUB/$key" \ + ${passphrase[@]+"${passphrase[@]}"} + cell "$server/$key/memory" "$CONNECTED" "$port" --auth key --key-memory "$SSH_TEST_KEYS/$key" \ + ${passphrase[@]+"${passphrase[@]}"} + done +} + +cell v10/password "$CONNECTED" "$SSH_TEST_PORT_V10" "${PASSWORD[@]}" +cell v9/password "$CONNECTED" "$SSH_TEST_PORT_V9" "${PASSWORD[@]}" +key_cells v10 "$SSH_TEST_PORT_V10" +key_cells v9 "$SSH_TEST_PORT_V9" +for server in rsa256 rsa512; do + port_variable="SSH_TEST_PORT_$(echo "$server" | tr '[:lower:]' '[:upper:]')" + cell "$server/rsa/file" "$CONNECTED" "${!port_variable}" --auth key --key-file "$SSH_TEST_KEYS/rsa" + cell "$server/rsa/memory" "$CONNECTED" "${!port_variable}" --auth key --key-memory "$SSH_TEST_KEYS/rsa" +done +cell kbdpass/password "$CONNECTED" "$SSH_TEST_PORT_KBDPASS" "${PASSWORD[@]}" + +cell pq10/password "$KEX" "$SSH_TEST_PORT_PQ10" "${PASSWORD[@]}" +cell pq9/password "$KEX" "$SSH_TEST_PORT_PQ9" "${PASSWORD[@]}" +cell mac10/password "$MAC" "$SSH_TEST_PORT_MAC10" "${PASSWORD[@]}" + +for source in file memory; do + flag="--key-$source" + cell "v10/sk-ed25519/$source" "ERROR securityKeyUnsupported |*" "$SSH_TEST_PORT_V10" \ + --auth key "$flag" "$SSH_TEST_KEYS/sk-ed25519" + for key in ed25519-pass rsa-pem-pass; do + cell "v10/$key/no-passphrase/$source" "ERROR passphraseRequired |*" "$SSH_TEST_PORT_V10" \ + --auth key "$flag" "$SSH_TEST_KEYS/$key" + cell "v10/$key/wrong-passphrase/$source" "ERROR passphraseIncorrect |*" "$SSH_TEST_PORT_V10" \ + --auth key "$flag" "$SSH_TEST_KEYS/$key" --passphrase "not-$SSH_TEST_KEY_PASSPHRASE" + done + cell "v10/unlisted-key/$source" "ERROR authenticationFailed(*" "$SSH_TEST_PORT_V10" \ + --auth key "$flag" "$SSH_TEST_KEYS/stray-pass" --passphrase "$SSH_TEST_KEY_PASSPHRASE" + cell "keytotp/ed25519-pass/$source" "$SECOND_FACTOR" "$SSH_TEST_PORT_KEYTOTP" \ + --auth key "$flag" "$SSH_TEST_KEYS/ed25519-pass" --passphrase "$SSH_TEST_KEY_PASSPHRASE" +done +cell keytotp/ed25519/file "$SECOND_FACTOR" "$SSH_TEST_PORT_KEYTOTP" --auth key --key-file "$SSH_TEST_KEYS/ed25519" +cell passtotp/password "$SECOND_FACTOR" "$SSH_TEST_PORT_PASSTOTP" "${PASSWORD[@]}" +cell totp/password "ERROR promptUnanswerable(*" "$SSH_TEST_PORT_TOTP" "${PASSWORD[@]}" +# iOS has no agent, so an agent connection signs in with the key on the device. +cell v10/agent/memory "$CONNECTED" "$SSH_TEST_PORT_V10" --auth agent --key-memory "$SSH_TEST_KEYS/ed25519" +cell totp/keyboard-interactive "ERROR authMethodUnsupported(*keyboardInteractive)*" "$SSH_TEST_PORT_TOTP" \ + --auth kbdint --password "$SSH_TEST_PASSWORD" +# hop2 is reachable only from the other servers, under its network alias. +HOP2=(--host "${SSH_TEST_HOP2%:*}") +HOP2_PORT="${SSH_TEST_HOP2##*:}" +HOP1=(--jump "$SSH_TEST_HOST:$SSH_TEST_PORT_HOP1") +cell two-hops/password "$CONNECTED" "$HOP2_PORT" "${HOP2[@]}" "${HOP1[@]}" --jump-key "$SSH_TEST_KEYS/ed25519" \ + "${PASSWORD[@]}" +cell two-hops/server-key "$CONNECTED" "$HOP2_PORT" "${HOP2[@]}" "${HOP1[@]}" --jump-key server \ + --auth key --key-memory "$SSH_TEST_KEYS/ed25519-pass" --passphrase "$SSH_TEST_KEY_PASSPHRASE" +cell two-hops/hop-wrong-passphrase "ERROR jumpHostFailed(*passphrase for this SSH private key is incorrect*" \ + "$HOP2_PORT" "${HOP2[@]}" "${HOP1[@]}" --jump-key "$SSH_TEST_KEYS/ed25519-pass" \ + --jump-passphrase "not-$SSH_TEST_KEY_PASSPHRASE" "${PASSWORD[@]}" +cell two-hops/pq-hop "ERROR jumpHostFailed(*no key exchange or host key method in common*" "$HOP2_PORT" \ + "${HOP2[@]}" --jump "$SSH_TEST_HOST:$SSH_TEST_PORT_PQ10" --jump-key "$SSH_TEST_KEYS/ed25519" "${PASSWORD[@]}" +cell two-hops/30-clients-one-tunnel "CONNECTED clients=30" "$HOP2_PORT" "${HOP2[@]}" "${HOP1[@]}" \ + --jump-key "$SSH_TEST_KEYS/ed25519" --mode clients --count 30 "${PASSWORD[@]}" + +cell v10/30-clients-one-tunnel "CONNECTED clients=30" "$SSH_TEST_PORT_V10" --mode clients --count 30 "${PASSWORD[@]}" +cell v10/10-tunnels-one-process "CONNECTED tunnels=10" "$SSH_TEST_PORT_V10" --mode tunnels --count 10 "${PASSWORD[@]}" + +# docker kill drops the server mid-session. A tunnel relaying a client should notice at once from the +# transport; an idle one only through keep-alive (sent every 10s, failing on the second miss after +# 30s). Neither may spin while it waits. With a hop killed, its relay ends and the next session's +# transport closes under it. +loss_cell() { + local name="$1" server="$2" port="$3" expected="LOSS relaying<=3 idle<=75 cpu<1" + local marker="$WORK/loss-marker" output="$WORK/loss.out" pid result verdict + shift 3 + selected "$name" || return 0 + rm -f "$marker" + xcrun simctl spawn -s "$UDID" "$HARNESS" --mode loss --marker "$marker" --wait 75 --deadline 180 \ + --known-hosts "$WORK" --port "$port" --user "$SSH_TEST_USER" \ + --target "$SSH_TEST_TARGET" "$@" > "$output" 2>&1 & + pid=$! + while kill -0 "$pid" 2> /dev/null && ! grep -q '^READY' "$output"; do + sleep 0.2 + done + if grep -q '^READY' "$output"; then + "$SERVERS" kill "$server" > /dev/null + touch "$marker" + fi + wait "$pid" + result="$(grep -E '^(LOSS|ERROR)' "$output" | tail -1)" + [ -n "$result" ] || result="no result line: $(tail -3 "$output" | tr '\n' ' ')" + verdict="$(printf '%s\n' "$result" | awk ' + /^LOSS / { + for (i = 2; i <= NF; i++) { split($i, pair, "="); value[pair[1]] = pair[2] } + fast = value["relaying"] != "none" && value["relaying"] + 0 <= 3 + noticed = value["idle"] != "none" && value["idle"] + 0 <= 75 + print (fast && noticed && value["cpu"] + 0 < 1) ? "pass" : "miss" + exit + } + { print "miss"; exit } + ')" + "$SERVERS" start "$server" > /dev/null || echo "warning: $server did not come back" >&2 + report "$name" "$expected" "$result" "$verdict" +} +loss_cell loss/docker-kill loss "$SSH_TEST_PORT_LOSS" "${PASSWORD[@]}" +loss_cell loss/hop-docker-kill hop1 "$HOP2_PORT" "${HOP2[@]}" "${HOP1[@]}" --jump-key "$SSH_TEST_KEYS/ed25519" \ + "${PASSWORD[@]}" + +echo "" +[ "$RAN" -gt 0 ] || setup_failure "no cell name holds \"$FILTER\"" +echo "$PASSED of $RAN cells as expected." +[ "$MISSED" -eq 0 ] || exit 1 diff --git a/apple/scripts/probes/ssh-tunnel-matrix/build-ios-harness.sh b/apple/scripts/probes/ssh-tunnel-matrix/build-ios-harness.sh new file mode 100755 index 0000000000..70602e0a0c --- /dev/null +++ b/apple/scripts/probes/ssh-tunnel-matrix/build-ios-harness.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# +# Builds the harness scripts/probes/check-ssh-tunnel-matrix.sh runs in the iOS simulator: +# ios-harness.swift compiled into one module with the iOS app's SSH tunnel sources, linked against +# the simulator slices of libssh2 and OpenSSL the app ships. +# +# The TableProCore modules those sources import come first, with the dependency order and source +# lists `swift package describe` reads from the manifest, so a new file or dependency there needs no +# edit here. A module whose sources have not changed since the last build is reused. +# +# Usage: scripts/probes/ssh-tunnel-matrix/build-ios-harness.sh +# +# Prints the harness path. Exits 2 when it cannot build, with the first compiler error. + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../../.." && pwd)" +MOBILE="$ROOT/TableProMobile" +APP="$MOBILE/TableProMobile" +SLICE="ios-arm64-simulator" +LIBSSH2="$ROOT/Libs/ios/LibSSH2.xcframework/$SLICE" +LIBSSL="$ROOT/Libs/ios/OpenSSL-SSL.xcframework/$SLICE/libssl.a" +LIBCRYPTO="$ROOT/Libs/ios/OpenSSL-Crypto.xcframework/$SLICE/libcrypto.a" + +setup_failure() { + echo "build-ios-harness: $*" >&2 + exit 2 +} + +compile_failure() { + echo "build-ios-harness: $(grep -m1 'error:' "$1" || tail -1 "$1")" >&2 + echo "build-ios-harness: full log in $1" >&2 + exit 2 +} + +[ $# -eq 1 ] || setup_failure "usage: build-ios-harness.sh " +BUILD="${1%/}" +OUT="$BUILD/modules" +mkdir -p "$OUT" + +for required in "$LIBSSH2/libssh2.a" "$LIBSSH2/Headers/libssh2.h" "$LIBSSL" "$LIBCRYPTO"; do + [ -e "$required" ] || setup_failure "missing $required; run scripts/download-libs.sh" +done + +IOS_VERSION="$(awk ' + /^ deploymentTarget:/ { inTarget = 1; next } + inTarget && /^ iOS:/ { gsub(/[" ]/, "", $2); print $2; exit } + inTarget && /^[^ ]/ { exit } +' "$MOBILE/project.yml")" +[ -n "$IOS_VERSION" ] || setup_failure "no deploymentTarget iOS in TableProMobile/project.yml" +TARGET="arm64-apple-ios$IOS_VERSION-simulator" +SDK="$(xcrun --sdk iphonesimulator --show-sdk-path 2> /dev/null)" || setup_failure "no iphonesimulator SDK" +COMPILER="$(xcrun --sdk iphonesimulator swiftc --version 2> /dev/null)" + +# IOSSSHProvider is left out: it needs AppContainerPaths, which the tunnel does not. +APP_SOURCES=() +for source in "$APP"/SSH/*.swift; do + [ "$(basename "$source")" = "IOSSSHProvider.swift" ] || APP_SOURCES+=("$source") +done +APP_SOURCES+=( + "$APP/Platform/LocalNetworkPermission.swift" + "$APP/Platform/ConnectionSecretKind.swift" + "$APP/Security/PEMDocument.swift" +) +SHARED=0 +while IFS= read -r shared; do + APP_SOURCES+=("$MOBILE/$shared") + SHARED=$((SHARED + 1)) +done < <(sed -n 's|^[[:space:]]*- \(\.\./TablePro/Core/SSH/[^[:space:]]*\.swift\)[[:space:]]*$|\1|p' "$MOBILE/project.yml") +[ "$SHARED" -gt 0 ] || setup_failure "TableProMobile/project.yml lists no file from TablePro/Core/SSH" +APP_SOURCES+=("$HERE/ios-harness.swift") +for source in "${APP_SOURCES[@]}"; do + [ -f "$source" ] || setup_failure "missing $source" +done + +IMPORTS=() +while IFS= read -r module; do + IMPORTS+=("$module") +done < <(sed -n -E 's/^(@testable |@_exported |public |internal |package )?import ([A-Za-z0-9_]+).*/\2/p' "${APP_SOURCES[@]}" | sort -u) + +swift package --package-path "$ROOT/Packages/TableProCore" --scratch-path "$BUILD/describe" \ + describe --type json > "$BUILD/describe.json" 2> "$BUILD/describe.log" || + setup_failure "swift package describe failed: $(grep -m1 'error:' "$BUILD/describe.log" || tail -1 "$BUILD/describe.log")" + +# Writes .sources and prints the modules in build order, dependencies first. +IFS= read -r -d '' CLOSURE << 'PY' || true +import json +import pathlib +import sys + +describe = json.loads(pathlib.Path(sys.argv[1]).read_text()) +out = pathlib.Path(sys.argv[2]) +package = pathlib.Path(describe["path"]) +targets = {target["name"]: target for target in describe["targets"]} +order = [] +seen = set() + + +def visit(name): + if name in seen: + return + seen.add(name) + target = targets[name] + for dependency in target.get("target_dependencies", []): + visit(dependency) + if target.get("product_dependencies"): + sys.exit(f"{name} depends on another package, which this build cannot compile") + if target.get("module_type") != "SwiftTarget" or target.get("resources"): + sys.exit(f"{name} is not a plain Swift target, which this build cannot compile") + base = package / target["path"] + sources = "".join(f"{base / source}\n" for source in sorted(target["sources"])) + (out / f"{name}.sources").write_text(sources) + order.append(name) + + +for name in sys.argv[3:]: + if name in targets: + visit(name) +print("\n".join(order)) +PY +MODULES="$(python3 -I -c "$CLOSURE" "$BUILD/describe.json" "$OUT" TableProDatabase TableProSSHTransport "${IMPORTS[@]}")" || + setup_failure "could not read the TableProCore targets from $BUILD/describe.json" + +# Each stamp folds in the one before it, so a changed module rebuilds every module after it. +LIBRARIES=() +STAMP="" +while IFS= read -r module; do + [ -n "$module" ] || continue + SOURCES=() + while IFS= read -r source; do + SOURCES+=("$source") + done < "$OUT/$module.sources" + STAMP="$( + printf '%s\n%s\n%s\n' "$COMPILER" "$TARGET" "$STAMP" + stat -f '%N %m %z' "${SOURCES[@]}" + )" + STAMP="$(printf '%s' "$STAMP" | shasum -a 256 | cut -c1-16)" + LIBRARIES+=("$OUT/lib$module.a") + if [ -f "$OUT/lib$module.a" ] && [ "$(cat "$OUT/$module.stamp" 2> /dev/null)" = "$STAMP" ]; then + continue + fi + echo "compiling $module (${#SOURCES[@]} files)" >&2 + rm -f "$OUT/$module.stamp" + xcrun --sdk iphonesimulator swiftc -sdk "$SDK" -target "$TARGET" -swift-version 6 -Onone \ + -parse-as-library -module-name "$module" -package-name TableProCore -DSWIFT_PACKAGE -I "$OUT" \ + -emit-module -emit-module-path "$OUT/$module.swiftmodule" \ + -emit-library -static -o "$OUT/lib$module.a" \ + "${SOURCES[@]}" > "$OUT/$module.log" 2>&1 || compile_failure "$OUT/$module.log" + echo "$STAMP" > "$OUT/$module.stamp" +done <<< "$MODULES" + +# The app target's own settings in TableProMobile/project.yml: SWIFT_DEFAULT_ACTOR_ISOLATION, +# SWIFT_APPROACHABLE_CONCURRENCY (the two concurrency features) and MemberImportVisibility. +echo "compiling the harness (${#APP_SOURCES[@]} files)" >&2 +xcrun --sdk iphonesimulator swiftc -sdk "$SDK" -target "$TARGET" -swift-version 6 -Onone \ + -parse-as-library -module-name TableProMobile -default-isolation MainActor \ + -enable-upcoming-feature NonisolatedNonsendingByDefault \ + -enable-upcoming-feature InferIsolatedConformances \ + -enable-upcoming-feature MemberImportVisibility \ + -I "$OUT" -I "$APP/CBridges/CLibSSH2" -Xcc "-I$LIBSSH2/Headers" \ + "${APP_SOURCES[@]}" "${LIBRARIES[@]}" "$LIBSSH2/libssh2.a" "$LIBSSL" "$LIBCRYPTO" -lz \ + -o "$BUILD/ios-harness" > "$BUILD/harness.log" 2>&1 || compile_failure "$BUILD/harness.log" + +echo "$BUILD/ios-harness" diff --git a/apple/scripts/probes/ssh-tunnel-matrix/ios-harness.swift b/apple/scripts/probes/ssh-tunnel-matrix/ios-harness.swift new file mode 100644 index 0000000000..1cf4e46cb2 --- /dev/null +++ b/apple/scripts/probes/ssh-tunnel-matrix/ios-harness.swift @@ -0,0 +1,386 @@ +import Darwin +import Foundation +import TableProDatabase +import TableProModels + +// Compiled into one module with the app's tunnel sources (build-ios-harness.sh) to reach the internal +// SSHTunnelFactory. It ends through _exit: exit() runs OPENSSL_cleanup while a tunnel's teardown can +// still be inside libssh2, which crashed about one run in 40. + +nonisolated struct HarnessFailure: Error, CustomStringConvertible { + let description: String +} + +nonisolated struct HarnessOptions: Sendable { + var mode = "connect" + var host = "127.0.0.1" + var port = 22 + var user = "tablepro" + var auth = "password" + var password: String? + var keyFile: String? + var keyMemory: String? + var passphrase: String? + var jumps: [String] = [] + var jumpKey: String? + var jumpPassphrase: String? + var target = "redis:6379" + var count = 1 + var marker: String? + var waitSeconds = 75 + var deadlineSeconds = 60 + var knownHostsDirectory = NSTemporaryDirectory() + + init(arguments: [String]) throws { + var index = 0 + while index < arguments.count { + let flag = arguments[index] + guard index + 1 < arguments.count else { throw HarnessFailure(description: "\(flag) needs a value") } + let value = arguments[index + 1] + index += 2 + switch flag { + case "--mode": mode = value + case "--host": host = value + case "--port": port = try Self.number(value, flag) + case "--user": user = value + case "--auth": auth = value + case "--password": password = value + case "--key-file": keyFile = value + case "--key-memory": keyMemory = value + case "--passphrase": passphrase = value + case "--jump": jumps.append(value) + case "--jump-key": jumpKey = value + case "--jump-passphrase": jumpPassphrase = value + case "--target": target = value + case "--count": count = try Self.number(value, flag) + case "--marker": marker = value + case "--wait": waitSeconds = try Self.number(value, flag) + case "--deadline": deadlineSeconds = try Self.number(value, flag) + case "--known-hosts": knownHostsDirectory = value + default: throw HarnessFailure(description: "unknown option \(flag)") + } + } + } + + static func endpoint(_ text: String) throws -> (host: String, port: Int) { + guard let colon = text.lastIndex(of: ":"), let port = Int(text[text.index(after: colon)...]) else { + throw HarnessFailure(description: "expected host:port, got \(text)") + } + return (String(text[.. Int { + guard let value = Int(text), value > 0 else { throw HarnessFailure(description: "\(flag) takes a positive number") } + return value + } +} + +@MainActor +struct AcceptEveryHostKey: ConnectionPrompter { + func confirm(_ question: ConnectionQuestion) async -> Bool { true } +} + +nonisolated enum Output { + static func emit(_ line: String) { + let flat = line.replacingOccurrences(of: "\r", with: " ").replacingOccurrences(of: "\n", with: " ") + FileHandle.standardOutput.write(Data((flat + "\n").utf8)) + } + + static func finish(_ line: String, code: Int32) -> Never { + emit(line) + _exit(code) + } + + static func errorLine(_ error: any Error) -> String { + let shown = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + return "ERROR \(String(describing: error)) | \(shown)" + } + + static func armWatchdog(seconds: Int) { + DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(seconds)) { + Output.finish("ERROR harness: no result within \(seconds)s", code: 3) + } + } +} + +nonisolated enum LocalClient { + static let ioTimeoutSeconds = 10 + + static func open(port: Int) throws -> Int32 { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw HarnessFailure(description: "socket: \(lastError())") } + var on: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout.size)) + var timeout = timeval(tv_sec: ioTimeoutSeconds, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = UInt16(port).bigEndian + address.sin_addr.s_addr = inet_addr("127.0.0.1") + let rc = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard rc == 0 else { + let reason = lastError() + Darwin.close(fd) + throw HarnessFailure(description: "connect to 127.0.0.1:\(port): \(reason)") + } + return fd + } + + static func sendPing(_ fd: Int32) throws { + let request = Array("PING\r\n".utf8) + let sent = request.withUnsafeBytes { Darwin.send(fd, $0.baseAddress, $0.count, 0) } + guard sent == request.count else { throw HarnessFailure(description: "send PING: \(lastError())") } + } + + static func expectPong(_ fd: Int32) throws { + var reply: [UInt8] = [] + var buffer = [UInt8](repeating: 0, count: 64) + while !reply.contains(UInt8(ascii: "\n")) { + let count = buffer.withUnsafeMutableBytes { Darwin.recv(fd, $0.baseAddress, $0.count, 0) } + if count > 0 { + reply.append(contentsOf: buffer[0.. String { + String(cString: strerror(errno)) + } +} + +nonisolated enum TunnelOpener { + static func open(_ options: HarnessOptions, prompter: any ConnectionPrompter) async throws -> SSHTunnel { + let (config, credentials) = try request(options) + let destination = try HarnessOptions.endpoint(options.target) + let store = HostKeyStore( + filePath: (options.knownHostsDirectory as NSString).appendingPathComponent("known_hosts_\(UUID().uuidString)") + ) + return try await SSHTunnelFactory.create( + config: config, + remoteHost: destination.host, + remotePort: destination.port, + credentials: credentials, + jumpHostCredentials: try hopCredentials(options, hops: config.jumpHosts), + prompter: prompter, + hostKeyStore: store + ) + } + + // "server" is Same Key as SSH Server; any other value is a key file, read in as the app keeps a hop key. + private static func hopCredentials(_ options: HarnessOptions, hops: [SSHJumpHost]) throws -> [UUID: JumpHostCredential] { + guard let jumpKey = options.jumpKey else { return [:] } + let credential: JumpHostCredential = jumpKey == "server" + ? .sshServerKey + : .privateKey(key: try String(contentsOfFile: jumpKey, encoding: .utf8), passphrase: options.jumpPassphrase) + return Dictionary(uniqueKeysWithValues: hops.map { ($0.id, credential) }) + } + + private static func request(_ options: HarnessOptions) throws -> (SSHConfiguration, SSHTunnelCredentials) { + var config = SSHConfiguration(host: options.host, port: options.port, username: options.user) + config.jumpHosts = try options.jumps.map { hop in + let endpoint = try HarnessOptions.endpoint(hop) + return SSHJumpHost(host: endpoint.host, port: endpoint.port, username: options.user) + } + switch options.auth { + case "password": + config.authMethod = .password + case "key", "agent": + config.authMethod = options.auth == "agent" ? .sshAgent : .privateKey + if let path = options.keyMemory { + let key = try String(contentsOfFile: path, encoding: .utf8) + return (config, SSHTunnelCredentials(keyPassphrase: options.passphrase, privateKey: key)) + } + config.privateKeyPath = options.keyFile + return (config, SSHTunnelCredentials(keyPassphrase: options.passphrase)) + case "kbdint": + config.authMethod = .keyboardInteractive + case "none": + config.authMethod = .none + default: + throw HarnessFailure(description: "unknown --auth \(options.auth)") + } + return (config, SSHTunnelCredentials(password: options.password)) + } +} + +@main +enum IOSTunnelHarness { + static func main() async { + let options: HarnessOptions + do { + options = try HarnessOptions(arguments: Array(CommandLine.arguments.dropFirst())) + } catch { + Output.finish("ERROR harness: \(error)", code: 3) + } + Output.armWatchdog(seconds: options.deadlineSeconds) + let prompter = AcceptEveryHostKey() + + switch options.mode { + case "selftest": + Output.finish("HARNESS ok", code: 0) + case "connect": + await connect(options, prompter: prompter) + case "clients": + await clients(options, prompter: prompter) + case "tunnels": + await tunnels(options, prompter: prompter) + case "loss": + await loss(options, prompter: prompter) + default: + Output.finish("ERROR harness: unknown --mode \(options.mode)", code: 3) + } + } + + static func connect(_ options: HarnessOptions, prompter: any ConnectionPrompter) async -> Never { + let tunnel = await openOrFinish(options, prompter: prompter) + do { + try LocalClient.roundTrip(port: tunnel.localPort) + } catch { + Output.finish("ERROR relay: \(error)", code: 1) + } + tunnel.close() + Output.finish("CONNECTED", code: 0) + } + + // Every client is connected before any sends, so all of them hold a channel at the same time. + static func clients(_ options: HarnessOptions, prompter: any ConnectionPrompter) async -> Never { + let tunnel = await openOrFinish(options, prompter: prompter) + var sockets: [Int32] = [] + do { + for _ in 0.. Never { + let opened: [SSHTunnel] + do { + opened = try await withThrowingTaskGroup(of: SSHTunnel.self) { group in + for _ in 0.. Never { + guard let marker = options.marker else { Output.finish("ERROR harness: loss needs --marker", code: 3) } + let relaying = await openOrFinish(options, prompter: prompter) + let held: Int32 + do { + held = try LocalClient.open(port: relaying.localPort) + try LocalClient.sendPing(held) + try LocalClient.expectPong(held) + } catch { + Output.finish("ERROR relay: \(error)", code: 1) + } + let idle = await openOrFinish(options, prompter: prompter) + do { + try LocalClient.roundTrip(port: idle.localPort) + } catch { + Output.finish("ERROR relay: \(error)", code: 1) + } + Output.emit("READY") + + while !FileManager.default.fileExists(atPath: marker) { + try? await Task.sleep(for: .milliseconds(50)) + } + let clock = ContinuousClock() + let start = clock.now + let cpuAtStart = cpuSeconds() + var relayingStopped: Duration? + var idleStopped: Duration? + while relayingStopped == nil || idleStopped == nil, start.duration(to: clock.now) < .seconds(options.waitSeconds) { + if relayingStopped == nil, !relaying.isRunning { relayingStopped = start.duration(to: clock.now) } + if idleStopped == nil, !idle.isRunning { idleStopped = start.duration(to: clock.now) } + try? await Task.sleep(for: .milliseconds(100)) + } + let cpu = cpuSeconds() - cpuAtStart + Darwin.close(held) + Output.finish( + "LOSS relaying=\(seconds(relayingStopped)) idle=\(seconds(idleStopped)) cpu=\(String(format: "%.2f", cpu))", + code: 0 + ) + } + + private static func openOrFinish(_ options: HarnessOptions, prompter: any ConnectionPrompter) async -> SSHTunnel { + do { + return try await TunnelOpener.open(options, prompter: prompter) + } catch { + Output.finish(Output.errorLine(error), code: 1) + } + } + + private static func seconds(_ duration: Duration?) -> String { + guard let duration else { return "none" } + let value = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 + return String(format: "%.1f", value) + } + + private static func cpuSeconds() -> Double { + var usage = rusage() + getrusage(RUSAGE_SELF, &usage) + let user = Double(usage.ru_utime.tv_sec) + Double(usage.ru_utime.tv_usec) / 1e6 + let system = Double(usage.ru_stime.tv_sec) + Double(usage.ru_stime.tv_usec) / 1e6 + return user + system + } +} diff --git a/apple/scripts/sftp-test-server.sh b/apple/scripts/sftp-test-server.sh index 2f21825562..2ca51d9864 100755 --- a/apple/scripts/sftp-test-server.sh +++ b/apple/scripts/sftp-test-server.sh @@ -9,20 +9,23 @@ # against this container, and the probe keeps them measured. # # Usage: -# sftp-test-server.sh up # start it, wait until it answers +# sftp-test-server.sh up # start it, wait until sshd sends its banner # sftp-test-server.sh down # remove it -# sftp-test-server.sh status # report whether it is listening +# sftp-test-server.sh status # report whether sshd answers # # The probe connects to 127.0.0.1:22022 and reads no environment variable, so it needs the # default TABLEPRO_SFTP_TEST_PORT. set -euo pipefail +# shellcheck source=lib/openssh-fixture.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/openssh-fixture.sh" + CONTAINER="${TABLEPRO_SFTP_TEST_CONTAINER:-tp-sftp-it}" PORT="${TABLEPRO_SFTP_TEST_PORT:-22022}" USERNAME="tp" PASSWORD="tppass" -IMAGE="lscr.io/linuxserver/openssh-server:latest" +IMAGE="$OPENSSH_FIXTURE_IMAGE_10" usage() { awk 'NR > 2 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}" @@ -30,7 +33,7 @@ usage() { } listening() { - nc -z -w 2 127.0.0.1 "$PORT" > /dev/null 2>&1 + openssh_banner 127.0.0.1 "$PORT" > /dev/null } case "${1:-}" in @@ -39,28 +42,25 @@ case "${1:-}" in echo "sftp test server already listening on 127.0.0.1:$PORT" exit 0 fi - docker rm -f "$CONTAINER" > /dev/null 2>&1 || true + docker rm -f -v "$CONTAINER" > /dev/null 2>&1 || true docker run -d --name "$CONTAINER" \ - -p "$PORT:2222" \ + -p "127.0.0.1:$PORT:2222" \ -e USER_NAME="$USERNAME" \ -e USER_PASSWORD="$PASSWORD" \ -e PASSWORD_ACCESS=true \ -e SUDO_ACCESS=false \ "$IMAGE" > /dev/null - for _ in $(seq 1 60); do - if listening; then - echo "sftp test server listening on 127.0.0.1:$PORT as $USERNAME" - exit 0 - fi - sleep 1 - done - echo "sftp test server did not start within 60s" >&2 + if openssh_wait_for_banner 127.0.0.1 "$PORT" 60; then + echo "sftp test server listening on 127.0.0.1:$PORT as $USERNAME" + exit 0 + fi + echo "sftp test server sent no SSH banner within 60s" >&2 docker logs "$CONTAINER" 2>&1 | tail -20 >&2 exit 1 ;; down) - docker rm -f "$CONTAINER" > /dev/null 2>&1 || true + docker rm -f -v "$CONTAINER" > /dev/null 2>&1 || true echo "removed $CONTAINER" ;; status) diff --git a/apple/scripts/ssh-test-servers.sh b/apple/scripts/ssh-test-servers.sh new file mode 100755 index 0000000000..6fb652e881 --- /dev/null +++ b/apple/scripts/ssh-test-servers.sh @@ -0,0 +1,353 @@ +#!/usr/bin/env bash +# +# Runs the OpenSSH servers scripts/probes/check-ssh-tunnel-matrix.sh opens tunnels through, each +# published on 127.0.0.1 only. +# +# One server per sshd configuration a cell needs, from the pinned images in +# scripts/lib/openssh-fixture.sh. Before a server's first start its /config gets a drop-in, the +# authorized keys and, on the TOTP servers, the code secret: the image writes its own sshd_config +# at init and includes the drop-in near the top, and sshd keeps the first value it reads. A drop-in +# is ssh-test-servers/.conf followed by common.conf. The TOTP servers run an image built +# from ssh-test-servers/totp.Dockerfile. Every server shares one Docker network with redis, the +# query target behind each tunnel, and with hop2, which only the other servers reach. Neither is published. +# +# Usage: +# ssh-test-servers.sh up [server...] # start the servers, every one by default, and wait for each +# ssh-test-servers.sh down # remove the servers, their network and their keys +# ssh-test-servers.sh status # each server's banner, or why it has none +# ssh-test-servers.sh kill # docker kill, which is how the connection-loss cell drops one +# ssh-test-servers.sh start # start a killed server again and wait for its banner +# ssh-test-servers.sh env # NAME=value lines for a runner to eval +# +# Servers: v10 v9 pq10 pq9 mac10 rsa256 rsa512 kbdpass passtotp keytotp totp maxauth hop1 hop2 loss. +# A bare up removes what an earlier up left and makes new keys; up with names restarts only those. +# Ports run from TABLEPRO_SSH_TEST_PORT_BASE + 1 (default 22601). Keys live in TABLEPRO_SSH_TEST_DIR +# (default $TMPDIR/tablepro-ssh-test-servers). decoy-1 to decoy-4 are keys no server accepts, for an +# agent that offers more keys than maxauth allows tries. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/openssh-fixture.sh +source "$SCRIPT_DIR/lib/openssh-fixture.sh" + +FIXTURE_DIR="$SCRIPT_DIR/ssh-test-servers" +STATE_DIR="${TABLEPRO_SSH_TEST_DIR:-${TMPDIR:-/tmp}/tablepro-ssh-test-servers}" +STATE_DIR="${STATE_DIR%/}" +STATE_MARKER="$STATE_DIR/.tablepro-ssh-test-servers" +PORT_BASE="${TABLEPRO_SSH_TEST_PORT_BASE:-22600}" +NETWORK="tp-ssh-matrix" +PREFIX="tp-ssh-" +LOGIN="tablepro" +PASSWORD="tppass" +KEY_PASSPHRASE="tp-key-pass" +# The usual documentation example: it signs in to these throwaway servers and nowhere else. +TOTP_SECRET="JBSWY3DPEHPK3PXP" +SERVERS="v10 v9 pq10 pq9 mac10 rsa256 rsa512 kbdpass passtotp keytotp totp maxauth hop1 hop2 loss" +AUTHORIZED_KEYS="ed25519 ed25519-pass rsa rsa-pass ecdsa ecdsa-pass rsa-pem rsa-pem-pass ecdsa-pem ecdsa-pem-pass sk-ed25519" +DECOY_KEYS="decoy-1 decoy-2 decoy-3 decoy-4" + +usage() { + awk 'NR > 2 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}" + exit 3 +} + +die() { + echo "$*" >&2 + exit 1 +} + +# image, port offset (- for unpublished), drop-in variant (- for none), PASSWORD_ACCESS. +# PASSWORD_ACCESS=false keeps /etc/shadow from sshd, so on totp and keytotp only the code signs in. +server_spec() { + case "$1" in + v10) echo "10 1 - true" ;; + v9) echo "9 2 - true" ;; + pq10) echo "10 3 pq-only true" ;; + pq9) echo "9 4 pq-only true" ;; + mac10) echo "10 5 mac-only true" ;; + rsa256) echo "10 6 rsa-sha2-256 true" ;; + rsa512) echo "10 7 rsa-sha2-512 true" ;; + kbdpass) echo "10 8 kbdint-password true" ;; + passtotp) echo "totp 9 password-totp true" ;; + keytotp) echo "totp 10 publickey-totp false" ;; + totp) echo "totp 11 totp false" ;; + maxauth) echo "10 12 max-auth-tries true" ;; + hop1) echo "10 13 - true" ;; + hop2) echo "10 - - true" ;; + loss) echo "10 14 - true" ;; + *) return 1 ;; + esac +} + +server_port() { + local image offset conf access + read -r image offset conf access <<< "$(server_spec "$1")" + [ "$offset" = "-" ] && return 1 + echo $((PORT_BASE + offset)) +} + +require_docker() { + command -v docker > /dev/null 2>&1 || die "docker not found" + docker info > /dev/null 2>&1 || die "Docker is not running" +} + +require_server() { + [ -n "${1:-}" ] || usage + server_spec "$1" > /dev/null || die "unknown server: $1 (servers: $SERVERS)" +} + +ensure_image() { + docker image inspect "$1" > /dev/null 2>&1 || docker pull --quiet "$1" > /dev/null || die "could not pull $1" +} + +# Tagged by what goes into it, so an edit to the Dockerfile or the PAM stack builds a new image. +totp_image() { + local tag + tag="tablepro-ssh-totp:$( + { + cat "$FIXTURE_DIR/totp.Dockerfile" "$FIXTURE_DIR/pam-sshd-totp" + printf '%s' "$OPENSSH_FIXTURE_IMAGE_10" + } | shasum -a 256 | cut -c1-12 + )" + if ! docker image inspect "$tag" > /dev/null 2>&1; then + echo "building $tag" >&2 + docker build --quiet --build-arg "OPENSSH_IMAGE=$OPENSSH_FIXTURE_IMAGE_10" \ + -t "$tag" -f "$FIXTURE_DIR/totp.Dockerfile" "$FIXTURE_DIR" > /dev/null || die "could not build $tag" + fi + echo "$tag" +} + +keygen() { + local name="$1" type="$2" passphrase="$3" + shift 3 + ssh-keygen -q -t "$type" -N "$passphrase" -C "tablepro-$name" -f "$STATE_DIR/keys/$name" "$@" < /dev/null +} + +# keys-nopub/ holds the same private keys without their .pub: the app reads a .pub next to a key +# file when there is one and derives the public key otherwise, and the two paths fail differently. +make_keys() { + local name + rm -rf "$STATE_DIR/keys" "$STATE_DIR/keys-nopub" + mkdir -p "$STATE_DIR/keys" "$STATE_DIR/keys-nopub" + keygen ed25519 ed25519 "" + keygen ed25519-pass ed25519 "$KEY_PASSPHRASE" + keygen rsa rsa "" -b 3072 + keygen rsa-pass rsa "$KEY_PASSPHRASE" -b 3072 + keygen ecdsa ecdsa "" -b 256 + keygen ecdsa-pass ecdsa "$KEY_PASSPHRASE" -b 256 + keygen rsa-pem rsa "" -b 3072 -m PEM + keygen rsa-pem-pass rsa "$KEY_PASSPHRASE" -b 3072 -m PEM + keygen ecdsa-pem ecdsa "" -b 256 -m PEM + keygen ecdsa-pem-pass ecdsa "$KEY_PASSPHRASE" -b 256 -m PEM + keygen stray-pass ed25519 "$KEY_PASSPHRASE" + for name in $DECOY_KEYS; do + keygen "$name" ed25519 "" + done + python3 -I "$FIXTURE_DIR/make-sk-key.py" "$STATE_DIR/keys/sk-ed25519" + for name in $AUTHORIZED_KEYS stray-pass $DECOY_KEYS; do + cp -p "$STATE_DIR/keys/$name" "$STATE_DIR/keys-nopub/$name" + done +} + +stage_server() { + local name="$1" image="$2" conf="$3" stage="$STATE_DIR/stage/$1" key + rm -rf "$stage" + mkdir -p "$stage/sshd/sshd_config.d" "$stage/.ssh" + { + [ "$conf" = "-" ] || cat "$FIXTURE_DIR/$conf.conf" + cat "$FIXTURE_DIR/common.conf" + } > "$stage/sshd/sshd_config.d/tablepro.conf" + for key in $AUTHORIZED_KEYS; do + cat "$STATE_DIR/keys/$key.pub" + done > "$stage/.ssh/authorized_keys" + if [ "$image" = "totp" ]; then + printf '%s\n" TOTP_AUTH\n' "$TOTP_SECRET" > "$stage/.google_authenticator" + chmod 600 "$stage/.google_authenticator" + fi +} + +image_ref() { + case "$1" in + 10) echo "$OPENSSH_FIXTURE_IMAGE_10" ;; + 9) echo "$OPENSSH_FIXTURE_IMAGE_9" ;; + totp) echo "$TOTP_IMAGE" ;; + esac +} + +# docker cp into a created container lands before init runs. /config/sshd does not exist yet, so +# the drop-in has to arrive inside its directory, not as a single file. +create_server() { + local name="$1" image offset conf access container="$PREFIX$1" + read -r image offset conf access <<< "$(server_spec "$name")" + stage_server "$name" "$image" "$conf" + local args=( + --name "$container" --network "$NETWORK" --network-alias "$name" + -e "USER_NAME=$LOGIN" -e "USER_PASSWORD=$PASSWORD" -e "PASSWORD_ACCESS=$access" + -e SUDO_ACCESS=false -e LOG_STDOUT=true + ) + if [ "$offset" != "-" ]; then + args+=(-p "127.0.0.1:$((PORT_BASE + offset)):2222") + fi + docker rm -f -v "$container" > /dev/null 2>&1 || true + docker create "${args[@]}" "$(image_ref "$image")" > /dev/null + docker cp "$STATE_DIR/stage/$name/." "$container:/config/" + docker start "$container" > /dev/null +} + +# hop2 is not published, so its banner is read from inside its own container. +wait_ready() { + local name="$1" port + if port="$(server_port "$name")"; then + openssh_wait_for_banner 127.0.0.1 "$port" 90 && return 0 + else + local deadline=$((SECONDS + 90)) + while ((SECONDS < deadline)); do + docker exec "$PREFIX$name" bash -c \ + 'exec 3<> /dev/tcp/127.0.0.1/2222 && IFS= read -r -t 3 line <&3 && [[ $line == SSH-2.0-* ]]' \ + > /dev/null 2>&1 && return 0 + sleep 0.25 + done + fi + echo "$name sent no SSH banner within 90s; its log:" >&2 + docker logs "$PREFIX$name" 2>&1 | tail -20 >&2 + return 1 +} + +start_redis() { + local container="${PREFIX}redis" _ + docker rm -f -v "$container" > /dev/null 2>&1 || true + docker run -d --name "$container" --network "$NETWORK" --network-alias redis \ + "$OPENSSH_FIXTURE_REDIS_IMAGE" redis-server --save "" --appendonly no --protected-mode no > /dev/null + for _ in $(seq 1 120); do + [ "$(docker exec "$container" redis-cli ping 2> /dev/null)" = "PONG" ] && return 0 + sleep 0.25 + done + die "redis did not answer PING within 30s" +} + +remove_all() { + local name + for name in $SERVERS redis; do + docker rm -f -v "$PREFIX$name" > /dev/null 2>&1 || true + done + docker network rm "$NETWORK" > /dev/null 2>&1 || true +} + +cmd_up() { + local names="$*" name image offset conf access needs_totp=0 + require_docker + for name in $names; do + require_server "$name" + done + if [ -z "$names" ]; then + names="$SERVERS" + remove_all + mkdir -p "$STATE_DIR" + touch "$STATE_MARKER" + make_keys + elif [ ! -f "$STATE_DIR/keys/ed25519" ]; then + die "no keys in $STATE_DIR; run a bare up first" + fi + + for name in $names; do + read -r image offset conf access <<< "$(server_spec "$name")" + [ "$image" = "totp" ] && needs_totp=1 + done + ensure_image "$OPENSSH_FIXTURE_IMAGE_10" + ensure_image "$OPENSSH_FIXTURE_IMAGE_9" + ensure_image "$OPENSSH_FIXTURE_REDIS_IMAGE" + TOTP_IMAGE="" + if [ "$needs_totp" = 1 ]; then + TOTP_IMAGE="$(totp_image)" + fi + + docker network inspect "$NETWORK" > /dev/null 2>&1 || docker network create "$NETWORK" > /dev/null + if [ "$(docker inspect -f '{{.State.Running}}' "${PREFIX}redis" 2> /dev/null)" != "true" ]; then + start_redis + fi + for name in $names; do + create_server "$name" + done + for name in $names; do + wait_ready "$name" || exit 1 + done + echo "SSH test servers ready: $names" +} + +cmd_down() { + remove_all + if [ -f "$STATE_MARKER" ]; then + rm -rf "$STATE_DIR/keys" "$STATE_DIR/keys-nopub" "$STATE_DIR/stage" + rm -f "$STATE_MARKER" + rmdir "$STATE_DIR" 2> /dev/null || true + fi + echo "removed the SSH test servers" +} + +cmd_status() { + local name state port banner failed=0 + for name in $SERVERS redis; do + state="$(docker inspect -f '{{.State.Status}}' "$PREFIX$name" 2> /dev/null || echo "missing")" + if [ "$state" != "running" ]; then + printf '%-9s %s\n' "$name" "$state" + failed=1 + elif [ "$name" = "redis" ] || ! port="$(server_port "$name")"; then + printf '%-9s running, not published\n' "$name" + elif banner="$(openssh_banner 127.0.0.1 "$port")"; then + printf '%-9s 127.0.0.1:%s %s\n' "$name" "$port" "$banner" + else + printf '%-9s 127.0.0.1:%s no banner\n' "$name" "$port" + failed=1 + fi + done + return "$failed" +} + +cmd_env() { + local name port + printf 'SSH_TEST_HOST=127.0.0.1\n' + printf 'SSH_TEST_USER=%q\n' "$LOGIN" + printf 'SSH_TEST_PASSWORD=%q\n' "$PASSWORD" + printf 'SSH_TEST_KEY_PASSPHRASE=%q\n' "$KEY_PASSPHRASE" + printf 'SSH_TEST_TOTP_SECRET=%q\n' "$TOTP_SECRET" + printf 'SSH_TEST_KEYS=%q\n' "$STATE_DIR/keys" + printf 'SSH_TEST_KEYS_NOPUB=%q\n' "$STATE_DIR/keys-nopub" + printf 'SSH_TEST_DECOY_KEYS=%q\n' "$DECOY_KEYS" + printf 'SSH_TEST_TARGET=redis:6379\n' + printf 'SSH_TEST_HOP2=hop2:2222\n' + for name in $SERVERS; do + port="$(server_port "$name")" || continue + printf 'SSH_TEST_PORT_%s=%s\n' "$(echo "$name" | tr '[:lower:]' '[:upper:]')" "$port" + done +} + +case "${1:-}" in + up) + shift + cmd_up "$@" + ;; + down) + cmd_down + ;; + status) + cmd_status + ;; + kill) + require_server "${2:-}" + docker kill "$PREFIX$2" > /dev/null + echo "killed $2" + ;; + start) + require_server "${2:-}" + docker start "$PREFIX$2" > /dev/null + wait_ready "$2" + echo "$2 ready" + ;; + env) + cmd_env + ;; + *) + usage + ;; +esac diff --git a/apple/scripts/ssh-test-servers/common.conf b/apple/scripts/ssh-test-servers/common.conf new file mode 100644 index 0000000000..1ad8cfa8c0 --- /dev/null +++ b/apple/scripts/ssh-test-servers/common.conf @@ -0,0 +1,6 @@ +# Every server. The image writes AllowTcpForwarding no into its own sshd_config. +AllowTcpForwarding yes +# Every connection from the Mac arrives from the Docker gateway, so OpenSSH's per-source +# penalties refuse the runner after about twenty failed logins: "Not allowed at this time". +PerSourcePenalties no +LogLevel DEBUG1 diff --git a/apple/scripts/ssh-test-servers/kbdint-password.conf b/apple/scripts/ssh-test-servers/kbdint-password.conf new file mode 100644 index 0000000000..493362c01a --- /dev/null +++ b/apple/scripts/ssh-test-servers/kbdint-password.conf @@ -0,0 +1,5 @@ +# The password method is off and keyboard-interactive runs the image's own PAM stack, which asks +# "Password: ". A client that only sends the password method cannot sign in here. +UsePAM yes +KbdInteractiveAuthentication yes +PasswordAuthentication no diff --git a/apple/scripts/ssh-test-servers/mac-only.conf b/apple/scripts/ssh-test-servers/mac-only.conf new file mode 100644 index 0000000000..651ce03cc7 --- /dev/null +++ b/apple/scripts/ssh-test-servers/mac-only.conf @@ -0,0 +1,4 @@ +# Key exchange and cipher agree, the MAC does not: aes128-ctr needs a MAC and libssh2 has no UMAC. +# It fails with the same -5 as a key exchange mismatch, so it tells the two stages apart. +Ciphers aes128-ctr +MACs umac-128-etm@openssh.com diff --git a/apple/scripts/ssh-test-servers/make-sk-key.py b/apple/scripts/ssh-test-servers/make-sk-key.py new file mode 100755 index 0000000000..5f74a7a863 --- /dev/null +++ b/apple/scripts/ssh-test-servers/make-sk-key.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# Writes an sk-ssh-ed25519@openssh.com private key and its .pub without a security key attached. +# Signing with it can never work, which is the point: the cell checks the client names the key +# type before it tries. ssh-keygen -l reads it as ED25519-SK and sshd accepts the .pub. +# +# Usage: make-sk-key.py + +import base64 +import os +import struct +import sys + +KEY_TYPE = "sk-ssh-ed25519@openssh.com" +APPLICATION = "ssh:" + + +def string(value): + if isinstance(value, str): + value = value.encode() + return struct.pack(">I", len(value)) + value + + +def main(): + if len(sys.argv) != 2: + sys.exit("usage: make-sk-key.py ") + path = sys.argv[1] + public_key = os.urandom(32) + public_blob = string(KEY_TYPE) + string(public_key) + string(APPLICATION) + + check = os.urandom(4) + flags = bytes([0x01]) + private = ( + check + + check + + string(KEY_TYPE) + + string(public_key) + + string(APPLICATION) + + flags + + string(os.urandom(64)) + + string(b"") + + string("tablepro-sk") + ) + pad = 1 + while len(private) % 8: + private += bytes([pad]) + pad += 1 + + body = ( + b"openssh-key-v1\x00" + + string("none") + + string("none") + + string(b"") + + struct.pack(">I", 1) + + string(public_blob) + + string(private) + ) + encoded = base64.b64encode(body).decode() + lines = [encoded[i : i + 70] for i in range(0, len(encoded), 70)] + + with open(path, "w", encoding="ascii") as handle: + handle.write("-----BEGIN OPENSSH PRIVATE KEY-----\n") + handle.write("\n".join(lines) + "\n") + handle.write("-----END OPENSSH PRIVATE KEY-----\n") + os.chmod(path, 0o600) + with open(path + ".pub", "w", encoding="ascii") as handle: + handle.write(f"{KEY_TYPE} {base64.b64encode(public_blob).decode()} tablepro-sk\n") + + +if __name__ == "__main__": + main() diff --git a/apple/scripts/ssh-test-servers/max-auth-tries.conf b/apple/scripts/ssh-test-servers/max-auth-tries.conf new file mode 100644 index 0000000000..ec3fdbde00 --- /dev/null +++ b/apple/scripts/ssh-test-servers/max-auth-tries.conf @@ -0,0 +1,2 @@ +# Fewer tries than the decoy keys an agent offers before the right one (#2601). +MaxAuthTries 3 diff --git a/apple/scripts/ssh-test-servers/pam-sshd-totp b/apple/scripts/ssh-test-servers/pam-sshd-totp new file mode 100644 index 0000000000..abb9f60d9f --- /dev/null +++ b/apple/scripts/ssh-test-servers/pam-sshd-totp @@ -0,0 +1,12 @@ +#%PAM-1.0 +# sshd sends the password method and keyboard-interactive through this one stack, and answers each +# hidden prompt of the password method with the password. So the code comes first and a failed code +# falls through to the password: on passtotp the password step signs in and keyboard-interactive +# asks for the code. That also lets a wrong code followed by the password in on passtotp, so a +# wrong-code cell belongs on totp or keytotp. Those start with PASSWORD_ACCESS=false, which keeps +# /etc/shadow from sshd, so pam_unix always fails there and only the code signs in. pam_unix also +# reports an unreadable shadow entry as an expired account, hence pam_permit for the account check. +auth [success=done default=ignore] pam_google_authenticator.so secret=/config/.google_authenticator +auth required pam_unix.so +account required pam_permit.so +session include base-session diff --git a/apple/scripts/ssh-test-servers/password-totp.conf b/apple/scripts/ssh-test-servers/password-totp.conf new file mode 100644 index 0000000000..ab9a4488c7 --- /dev/null +++ b/apple/scripts/ssh-test-servers/password-totp.conf @@ -0,0 +1,6 @@ +# The password, then a verification code. The right password alone is a partial success, which +# libssh2 reports with the same -18 as a wrong one. +UsePAM yes +KbdInteractiveAuthentication yes +PasswordAuthentication yes +AuthenticationMethods password,keyboard-interactive diff --git a/apple/scripts/ssh-test-servers/pq-only.conf b/apple/scripts/ssh-test-servers/pq-only.conf new file mode 100644 index 0000000000..225e0c2a78 --- /dev/null +++ b/apple/scripts/ssh-test-servers/pq-only.conf @@ -0,0 +1,3 @@ +# Post-quantum key exchange only. libssh2 1.11.1 has neither ML-KEM nor sntrup761, so no key +# exchange agrees and the client must name that stage. +KexAlgorithms mlkem768x25519-sha256,sntrup761x25519-sha512 diff --git a/apple/scripts/ssh-test-servers/publickey-totp.conf b/apple/scripts/ssh-test-servers/publickey-totp.conf new file mode 100644 index 0000000000..9638e443a1 --- /dev/null +++ b/apple/scripts/ssh-test-servers/publickey-totp.conf @@ -0,0 +1,6 @@ +# A key, then a verification code. The key alone is a partial success, which libssh2 reports as a +# failed public key login. +UsePAM yes +KbdInteractiveAuthentication yes +PasswordAuthentication no +AuthenticationMethods publickey,keyboard-interactive diff --git a/apple/scripts/ssh-test-servers/rsa-sha2-256.conf b/apple/scripts/ssh-test-servers/rsa-sha2-256.conf new file mode 100644 index 0000000000..91705cb2ba --- /dev/null +++ b/apple/scripts/ssh-test-servers/rsa-sha2-256.conf @@ -0,0 +1,2 @@ +# server-sig-algs then lists only this signature, so an RSA key has to sign with it. +PubkeyAcceptedAlgorithms rsa-sha2-256 diff --git a/apple/scripts/ssh-test-servers/rsa-sha2-512.conf b/apple/scripts/ssh-test-servers/rsa-sha2-512.conf new file mode 100644 index 0000000000..e0ae66d263 --- /dev/null +++ b/apple/scripts/ssh-test-servers/rsa-sha2-512.conf @@ -0,0 +1,2 @@ +# server-sig-algs then lists only this signature, so an RSA key has to sign with it. +PubkeyAcceptedAlgorithms rsa-sha2-512 diff --git a/apple/scripts/ssh-test-servers/totp.Dockerfile b/apple/scripts/ssh-test-servers/totp.Dockerfile new file mode 100644 index 0000000000..6a4ec83f49 --- /dev/null +++ b/apple/scripts/ssh-test-servers/totp.Dockerfile @@ -0,0 +1,7 @@ +# The 10.3 server with a TOTP second factor. Built by scripts/ssh-test-servers.sh, which passes the +# pinned image from scripts/lib/openssh-fixture.sh. The package version is pinned too, so the build +# fails rather than changing when Alpine moves it. +ARG OPENSSH_IMAGE +FROM ${OPENSSH_IMAGE} +RUN apk add --no-cache google-authenticator=1.11-r0 +COPY pam-sshd-totp /etc/pam.d/sshd diff --git a/apple/scripts/ssh-test-servers/totp.conf b/apple/scripts/ssh-test-servers/totp.conf new file mode 100644 index 0000000000..43ab0c5b9b --- /dev/null +++ b/apple/scripts/ssh-test-servers/totp.conf @@ -0,0 +1,6 @@ +# A verification code and nothing else: keyboard-interactive asks "Verification code: " first, +# a prompt a password cannot answer. +UsePAM yes +KbdInteractiveAuthentication yes +PasswordAuthentication no +AuthenticationMethods keyboard-interactive