diff --git a/Sources/NIOSSH/Connection State Machine/Operations/AcceptsUserAuthMessages.swift b/Sources/NIOSSH/Connection State Machine/Operations/AcceptsUserAuthMessages.swift index 19b8f6b6..749cb244 100644 --- a/Sources/NIOSSH/Connection State Machine/Operations/AcceptsUserAuthMessages.swift +++ b/Sources/NIOSSH/Connection State Machine/Operations/AcceptsUserAuthMessages.swift @@ -111,6 +111,22 @@ extension AcceptsUserAuthMessages { } } + mutating func receiveUserAuthInfoRequest( + _ message: SSHMessage.UserAuthInfoRequestMessage + ) throws -> SSHConnectionStateMachine.StateMachineInboundProcessResult { + let result = try self.userAuthStateMachine.receiveUserAuthInfoRequest(message) + + if let future = result { + return .possibleFutureMessage( + future.map { response in + response.map { SSHMultiMessage(.userAuthInfoResponse($0)) } + } + ) + } else { + return .noMessage + } + } + mutating func receiveUserAuthBanner( _ message: SSHMessage.UserAuthBannerMessage ) throws -> SSHConnectionStateMachine.StateMachineInboundProcessResult { diff --git a/Sources/NIOSSH/Connection State Machine/Operations/SendsUserAuthMessages.swift b/Sources/NIOSSH/Connection State Machine/Operations/SendsUserAuthMessages.swift index c5ffbfba..638e81c7 100644 --- a/Sources/NIOSSH/Connection State Machine/Operations/SendsUserAuthMessages.swift +++ b/Sources/NIOSSH/Connection State Machine/Operations/SendsUserAuthMessages.swift @@ -63,4 +63,12 @@ extension SendsUserAuthMessages { self.userAuthStateMachine.sendUserAuthPKOK(message) try self.serializer.serialize(message: .userAuthPKOK(message), to: &buffer) } + + mutating func writeUserAuthInfoResponse( + _ message: SSHMessage.UserAuthInfoResponseMessage, + into buffer: inout ByteBuffer + ) throws { + self.userAuthStateMachine.sendUserAuthInfoResponse(message) + try self.serializer.serialize(message: .userAuthInfoResponse(message), to: &buffer) + } } diff --git a/Sources/NIOSSH/Connection State Machine/SSHConnectionStateMachine.swift b/Sources/NIOSSH/Connection State Machine/SSHConnectionStateMachine.swift index ea3462dc..f393df31 100644 --- a/Sources/NIOSSH/Connection State Machine/SSHConnectionStateMachine.swift +++ b/Sources/NIOSSH/Connection State Machine/SSHConnectionStateMachine.swift @@ -310,6 +310,14 @@ struct SSHConnectionStateMachine { case .userAuthentication(var state): do { // In this state we tolerate receiving user auth messages. + // + // Message identifier 60 is overloaded: it is SSH_MSG_USERAUTH_INFO_REQUEST + // during a keyboard-interactive attempt and SSH_MSG_USERAUTH_PK_OK otherwise. + // Tell the parser which one to expect based on the authoritative auth state, + // re-synced on every read so it is always correct regardless of write ordering. + state.parser.keyboardInteractiveInfoRequestExpected = + state.userAuthStateMachine.expectingKeyboardInteractiveInfoRequest + guard let message = try state.parser.nextPacket() else { self = .userAuthentication(state) return nil @@ -347,6 +355,11 @@ struct SSHConnectionStateMachine { self = .userAuthentication(state) return result + case .userAuthInfoRequest(let message): + let result = try state.receiveUserAuthInfoRequest(message) + self = .userAuthentication(state) + return result + case .disconnect: self = .receivedDisconnect(state.role) return .disconnect @@ -959,9 +972,15 @@ struct SSHConnectionStateMachine { self.state = .userAuthentication(state) case .userAuthRequest(let message): + // The parser is told how to decode the overloaded identifier 60 based on the auth + // state machine (see the inbound handler), so no parser bookkeeping is needed here. try state.writeUserAuthRequest(message, into: &buffer) self.state = .userAuthentication(state) + case .userAuthInfoResponse(let message): + try state.writeUserAuthInfoResponse(message, into: &buffer) + self.state = .userAuthentication(state) + case .userAuthSuccess: try state.writeUserAuthSuccess(into: &buffer) // Ok we're good to go! diff --git a/Sources/NIOSSH/NIOSSHError.swift b/Sources/NIOSSH/NIOSSHError.swift index c2b8a542..0bdd9c7e 100644 --- a/Sources/NIOSSH/NIOSSHError.swift +++ b/Sources/NIOSSH/NIOSSHError.swift @@ -161,6 +161,16 @@ extension NIOSSHError { internal static func invalidCertificate(diagnostics: String) -> NIOSSHError { NIOSSHError(type: .invalidCertificate, diagnostics: diagnostics) } + + @inline(never) + internal static func unsupportedUserAuthenticationMethod(_ diagnostics: String) -> NIOSSHError { + NIOSSHError(type: .unsupportedUserAuthenticationMethod, diagnostics: diagnostics) + } + + @inline(never) + internal static func invalidKeyboardInteractiveResponse(_ diagnostics: String) -> NIOSSHError { + NIOSSHError(type: .invalidKeyboardInteractiveResponse, diagnostics: diagnostics) + } } // MARK: - NIOSSHError CustomStringConvertible conformance. @@ -207,6 +217,8 @@ extension NIOSSHError { case invalidHostKeyForKeyExchange case invalidOpenSSHPublicKey case invalidCertificate + case unsupportedUserAuthenticationMethod + case invalidKeyboardInteractiveResponse } private var base: Base @@ -304,6 +316,17 @@ extension NIOSSHError { /// A certificate failed validation. public static let invalidCertificate: ErrorType = .init(.invalidCertificate) + + /// An authentication method was offered that is not supported. + public static let unsupportedUserAuthenticationMethod: ErrorType = .init( + .unsupportedUserAuthenticationMethod + ) + + /// The response to a keyboard-interactive challenge was invalid, for example because the + /// number of responses did not match the number of prompts. + public static let invalidKeyboardInteractiveResponse: ErrorType = .init( + .invalidKeyboardInteractiveResponse + ) } } diff --git a/Sources/NIOSSH/SSHMessages.swift b/Sources/NIOSSH/SSHMessages.swift index a127d336..a99858a4 100644 --- a/Sources/NIOSSH/SSHMessages.swift +++ b/Sources/NIOSSH/SSHMessages.swift @@ -38,6 +38,8 @@ enum SSHMessage: Equatable { case userAuthSuccess case userAuthBanner(UserAuthBannerMessage) case userAuthPKOK(UserAuthPKOKMessage) + case userAuthInfoRequest(UserAuthInfoRequestMessage) + case userAuthInfoResponse(UserAuthInfoResponseMessage) case globalRequest(GlobalRequestMessage) case requestSuccess(RequestSuccessMessage) case requestFailure @@ -146,6 +148,7 @@ extension SSHMessage { case none case publicKey(PublicKeyAuthType) case password(String) + case keyboardInteractive(KeyboardInteractive) } enum PublicKeyAuthType: Equatable { @@ -153,6 +156,12 @@ extension SSHMessage { case unknown } + /// The payload of an RFC 4256 `keyboard-interactive` initial authentication request. + struct KeyboardInteractive: Equatable { + var languageTag: String + var submethods: String + } + var username: String var service: String var method: Method @@ -188,6 +197,38 @@ extension SSHMessage { var key: NIOSSHPublicKey } + /// SSH_MSG_USERAUTH_INFO_REQUEST, sent by the server during RFC 4256 `keyboard-interactive` + /// authentication. + /// + /// - Note: This message shares its numeric identifier (60) with + /// ``UserAuthPKOKMessage``. The two are disambiguated based on the authentication method + /// currently in flight; see ``SSHPacketParser``. + struct UserAuthInfoRequestMessage: Equatable { + // SSH_MSG_USERAUTH_INFO_REQUEST + static let id: UInt8 = 60 + + struct Prompt: Equatable { + var prompt: String + /// Whether the client should echo the user's response. `false` indicates a sensitive + /// value (such as a password) that must not be displayed. + var echo: Bool + } + + var name: String + var instruction: String + var languageTag: String + var prompts: [Prompt] + } + + /// SSH_MSG_USERAUTH_INFO_RESPONSE, sent by the client during RFC 4256 `keyboard-interactive` + /// authentication. + struct UserAuthInfoResponseMessage: Equatable { + // SSH_MSG_USERAUTH_INFO_RESPONSE + static let id: UInt8 = 61 + + var responses: [String] + } + struct GlobalRequestMessage: Equatable { // SSH_MSG_GLOBAL_REQUEST static let id: UInt8 = 80 @@ -365,7 +406,14 @@ extension ByteBuffer { /// /// This function will consume as many bytes as the message should require. If it cannot read enough bytes, /// it will return nil. - mutating func readSSHMessage() throws -> SSHMessage? { + /// + /// - parameter keyboardInteractiveInfoRequestExpected: Message identifier 60 is overloaded in + /// the SSH user-authentication protocol: it is `SSH_MSG_USERAUTH_PK_OK` when a public-key + /// query is outstanding, but `SSH_MSG_USERAUTH_INFO_REQUEST` when a `keyboard-interactive` + /// attempt is in flight. When this flag is `true`, identifier 60 is decoded as an info request. + mutating func readSSHMessage( + keyboardInteractiveInfoRequestExpected: Bool = false + ) throws -> SSHMessage? { try self.rewindOnNilOrError { `self` in guard let type = self.readInteger(as: UInt8.self) else { return nil @@ -437,10 +485,24 @@ extension ByteBuffer { } return .userAuthBanner(message) case SSHMessage.UserAuthPKOKMessage.id: - guard let message = try self.readUserAuthPKOKMessage() else { + // Identifier 60 is shared between SSH_MSG_USERAUTH_PK_OK and + // SSH_MSG_USERAUTH_INFO_REQUEST. Decode based on the in-flight auth method. + if keyboardInteractiveInfoRequestExpected { + guard let message = self.readUserAuthInfoRequestMessage() else { + return nil + } + return .userAuthInfoRequest(message) + } else { + guard let message = try self.readUserAuthPKOKMessage() else { + return nil + } + return .userAuthPKOK(message) + } + case SSHMessage.UserAuthInfoResponseMessage.id: + guard let message = self.readUserAuthInfoResponseMessage() else { return nil } - return .userAuthPKOK(message) + return .userAuthInfoResponse(message) case SSHMessage.GlobalRequestMessage.id: guard let message = try self.readGlobalRequestMessage() else { return nil @@ -719,6 +781,15 @@ extension ByteBuffer { method = .publicKey(.unknown) } + case "keyboard-interactive": + guard + let languageTag = self.readSSHStringAsString(), + let submethods = self.readSSHStringAsString() + else { + return nil + } + + method = .keyboardInteractive(.init(languageTag: languageTag, submethods: submethods)) default: return nil } @@ -779,6 +850,57 @@ extension ByteBuffer { } } + mutating func readUserAuthInfoRequestMessage() -> SSHMessage.UserAuthInfoRequestMessage? { + self.rewindReaderOnNil { `self` in + guard + let name = self.readSSHStringAsString(), + let instruction = self.readSSHStringAsString(), + let languageTag = self.readSSHStringAsString(), + let numPrompts = self.readInteger(as: UInt32.self) + else { + return nil + } + + var prompts = [SSHMessage.UserAuthInfoRequestMessage.Prompt]() + prompts.reserveCapacity(Int(numPrompts)) + for _ in 0.. SSHMessage.UserAuthInfoResponseMessage? { + self.rewindReaderOnNil { `self` in + guard let numResponses = self.readInteger(as: UInt32.self) else { + return nil + } + + var responses = [String]() + responses.reserveCapacity(Int(numResponses)) + for _ in 0.. SSHMessage.GlobalRequestMessage? { self.rewindReaderOnNil { `self` in guard @@ -1226,6 +1348,12 @@ extension ByteBuffer { case .userAuthPKOK(let message): writtenBytes += self.writeInteger(SSHMessage.UserAuthPKOKMessage.id) writtenBytes += self.writeUserAuthPKOKMessage(message) + case .userAuthInfoRequest(let message): + writtenBytes += self.writeInteger(SSHMessage.UserAuthInfoRequestMessage.id) + writtenBytes += self.writeUserAuthInfoRequestMessage(message) + case .userAuthInfoResponse(let message): + writtenBytes += self.writeInteger(SSHMessage.UserAuthInfoResponseMessage.id) + writtenBytes += self.writeUserAuthInfoResponseMessage(message) case .globalRequest(let message): writtenBytes += self.writeInteger(SSHMessage.GlobalRequestMessage.id) writtenBytes += self.writeGlobalRequestMessage(message) @@ -1386,8 +1514,34 @@ extension ByteBuffer { case .publicKey(.unknown): preconditionFailure("We cannot write user auth request messages on unknown keys") + case .keyboardInteractive(let keyboardInteractive): + writtenBytes += self.writeSSHString("keyboard-interactive".utf8) + writtenBytes += self.writeSSHString(keyboardInteractive.languageTag.utf8) + writtenBytes += self.writeSSHString(keyboardInteractive.submethods.utf8) + } + + return writtenBytes + } + + mutating func writeUserAuthInfoRequestMessage(_ message: SSHMessage.UserAuthInfoRequestMessage) -> Int { + var writtenBytes = 0 + writtenBytes += self.writeSSHString(message.name.utf8) + writtenBytes += self.writeSSHString(message.instruction.utf8) + writtenBytes += self.writeSSHString(message.languageTag.utf8) + writtenBytes += self.writeInteger(UInt32(message.prompts.count)) + for prompt in message.prompts { + writtenBytes += self.writeSSHString(prompt.prompt.utf8) + writtenBytes += self.writeSSHBoolean(prompt.echo) } + return writtenBytes + } + mutating func writeUserAuthInfoResponseMessage(_ message: SSHMessage.UserAuthInfoResponseMessage) -> Int { + var writtenBytes = 0 + writtenBytes += self.writeInteger(UInt32(message.responses.count)) + for response in message.responses { + writtenBytes += self.writeSSHString(response.utf8) + } return writtenBytes } diff --git a/Sources/NIOSSH/SSHPacketParser.swift b/Sources/NIOSSH/SSHPacketParser.swift index 314b2143..3abc4c4c 100644 --- a/Sources/NIOSSH/SSHPacketParser.swift +++ b/Sources/NIOSSH/SSHPacketParser.swift @@ -28,6 +28,14 @@ struct SSHPacketParser { private var state: State private(set) var sequenceNumber: UInt32 + /// Whether the next message with identifier 60 should be decoded as + /// `SSH_MSG_USERAUTH_INFO_REQUEST` rather than `SSH_MSG_USERAUTH_PK_OK`. + /// + /// Identifier 60 is overloaded in the user-authentication protocol. The connection state + /// machine sets this to `true` while a `keyboard-interactive` attempt is outstanding so that the + /// parser interprets identifier 60 correctly. + var keyboardInteractiveInfoRequestExpected: Bool = false + /// Testing only: the number of bytes we can discard from this buffer. internal var _discardableBytes: Int { self.buffer.readerIndex @@ -183,7 +191,10 @@ struct SSHPacketParser { buffer.moveReaderIndex(forwardBy: MemoryLayout.size) var content = try buffer.sliceContentFromPadding() - guard let message = try content.readSSHMessage(), content.readableBytes == 0, buffer.readableBytes == 0 + guard + let message = try content.readSSHMessage( + keyboardInteractiveInfoRequestExpected: self.keyboardInteractiveInfoRequestExpected + ), content.readableBytes == 0, buffer.readableBytes == 0 else { // Throw this error if the content wasn't exactly the right length for the message. throw NIOSSHError.invalidPacketFormat @@ -200,7 +211,10 @@ struct SSHPacketParser { } var content = try protection.decryptAndVerifyRemainingPacket(&buffer, sequenceNumber: self.sequenceNumber) - guard let message = try content.readSSHMessage(), content.readableBytes == 0, buffer.readableBytes == 0 + guard + let message = try content.readSSHMessage( + keyboardInteractiveInfoRequestExpected: self.keyboardInteractiveInfoRequestExpected + ), content.readableBytes == 0, buffer.readableBytes == 0 else { // Throw this error if the content wasn't exactly the right length for the message. throw NIOSSHError.invalidPacketFormat diff --git a/Sources/NIOSSH/User Authentication/ClientUserAuthenticationDelegate.swift b/Sources/NIOSSH/User Authentication/ClientUserAuthenticationDelegate.swift index 2842bbad..2c7343f3 100644 --- a/Sources/NIOSSH/User Authentication/ClientUserAuthenticationDelegate.swift +++ b/Sources/NIOSSH/User Authentication/ClientUserAuthenticationDelegate.swift @@ -38,4 +38,43 @@ public protocol NIOSSHClientUserAuthenticationDelegate { availableMethods: NIOSSHAvailableUserAuthenticationMethods, nextChallengePromise: EventLoopPromise ) + + /// Called when the server issues a keyboard-interactive challenge (RFC 4256) in response to a + /// ``NIOSSHUserAuthenticationOffer/Offer-swift.enum/keyboardInteractive(_:)`` offer. + /// + /// The delegate must complete `responsePromise` with exactly one response per prompt in + /// `challenge`, in order. A single authentication attempt may involve multiple challenges, so + /// this may be called several times for one offer. If the challenge carries no prompts the + /// delegate must succeed the promise with an empty array. + /// + /// If the delegate fails `responsePromise`, or provides a number of responses that does not + /// match the number of prompts, the authentication attempt fails. + /// + /// - Important: Prompts whose ``NIOSSHKeyboardInteractivePrompt/echo`` is `false` are sensitive. + /// Implementations must not log the prompts' responses. + /// + /// - parameters: + /// - challenge: The challenge issued by the server. + /// - responsePromise: An `EventLoopPromise` to be fulfilled with one response per prompt. + func respondToKeyboardInteractiveChallenge( + _ challenge: NIOSSHKeyboardInteractiveChallenge, + responsePromise: EventLoopPromise<[String]> + ) +} + +extension NIOSSHClientUserAuthenticationDelegate { + /// Default implementation for delegates that do not support keyboard-interactive authentication. + /// + /// This fails the authentication attempt, preserving source compatibility for existing + /// delegates that were written before keyboard-interactive support existed. + public func respondToKeyboardInteractiveChallenge( + _ challenge: NIOSSHKeyboardInteractiveChallenge, + responsePromise: EventLoopPromise<[String]> + ) { + responsePromise.fail( + NIOSSHError.unsupportedUserAuthenticationMethod( + "keyboard-interactive is not supported by this authentication delegate" + ) + ) + } } diff --git a/Sources/NIOSSH/User Authentication/KeyboardInteractive.swift b/Sources/NIOSSH/User Authentication/KeyboardInteractive.swift new file mode 100644 index 00000000..53948a1a --- /dev/null +++ b/Sources/NIOSSH/User Authentication/KeyboardInteractive.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2024 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore + +/// A single prompt within a keyboard-interactive authentication challenge (RFC 4256). +public struct NIOSSHKeyboardInteractivePrompt: Sendable, Hashable { + /// The text to display to the user when requesting a response. + public var prompt: String + + /// Whether the client should echo the user's response as it is typed. + /// + /// When this is `false` the response is sensitive (for example a password or one-time code) + /// and must not be displayed or logged. + public var echo: Bool + + public init(prompt: String, echo: Bool) { + self.prompt = prompt + self.echo = echo + } +} + +/// A keyboard-interactive authentication challenge (RFC 4256), issued by the server. +/// +/// A single keyboard-interactive authentication attempt may involve any number of challenges. For +/// each challenge the client must provide exactly one response per ``prompts`` entry, in order. +public struct NIOSSHKeyboardInteractiveChallenge: Sendable, Hashable { + /// The name of the challenge, which may be displayed to the user as a title. May be empty. + public var name: String + + /// Instructions to display to the user. May be empty. + public var instruction: String + + /// A language tag, per RFC 4256. Usually empty. + public var languageTag: String + + /// The prompts the user must respond to, in order. May be empty, in which case the client must + /// respond with an empty list of responses. + public var prompts: [NIOSSHKeyboardInteractivePrompt] + + public init( + name: String, + instruction: String, + languageTag: String, + prompts: [NIOSSHKeyboardInteractivePrompt] + ) { + self.name = name + self.instruction = instruction + self.languageTag = languageTag + self.prompts = prompts + } +} + +extension NIOSSHKeyboardInteractiveChallenge { + internal init(_ message: SSHMessage.UserAuthInfoRequestMessage) { + self.name = message.name + self.instruction = message.instruction + self.languageTag = message.languageTag + self.prompts = message.prompts.map { .init(prompt: $0.prompt, echo: $0.echo) } + } +} diff --git a/Sources/NIOSSH/User Authentication/UserAuthenticationMethod.swift b/Sources/NIOSSH/User Authentication/UserAuthenticationMethod.swift index d66df2aa..e2c6403f 100644 --- a/Sources/NIOSSH/User Authentication/UserAuthenticationMethod.swift +++ b/Sources/NIOSSH/User Authentication/UserAuthenticationMethod.swift @@ -33,7 +33,14 @@ public struct NIOSSHAvailableUserAuthenticationMethods: OptionSet, Sendable { /// Host-based authentication is acceptable. public static let hostBased: NIOSSHAvailableUserAuthenticationMethods = .init(rawValue: 1 << 2) - /// A short-hand for all supported authentication types. + /// Keyboard-interactive authentication (RFC 4256) is acceptable. + /// + /// - Note: This is only supported for the client role: ``NIOSSH`` cannot yet act as the server + /// for keyboard-interactive authentication, so this method is deliberately excluded from + /// ``all``. + public static let keyboardInteractive: NIOSSHAvailableUserAuthenticationMethods = .init(rawValue: 1 << 3) + + /// A short-hand for all authentication types ``NIOSSH`` can accept as a server. public static let all: NIOSSHAvailableUserAuthenticationMethods = [.publicKey, .password, .hostBased] } @@ -49,6 +56,8 @@ extension NIOSSHAvailableUserAuthenticationMethods { self.insert(.password) case "hostbased": self.insert(.hostBased) + case "keyboard-interactive": + self.insert(.keyboardInteractive) default: // This is an unknown method, which we ignore. break @@ -63,7 +72,7 @@ extension NIOSSHAvailableUserAuthenticationMethods { // We need an array. var methods = [Substring]() - methods.reserveCapacity(3) + methods.reserveCapacity(4) if self.contains(.password) { methods.append("password") @@ -74,6 +83,9 @@ extension NIOSSHAvailableUserAuthenticationMethods { if self.contains(.hostBased) { methods.append("hostbased") } + if self.contains(.keyboardInteractive) { + methods.append("keyboard-interactive") + } return methods } @@ -185,6 +197,9 @@ extension NIOSSHUserAuthenticationOffer { /// This method is currently unsupported by ``NIOSSH``. case hostBased(HostBased) + /// The client would like to perform keyboard-interactive authentication (RFC 4256). + case keyboardInteractive(KeyboardInteractive) + /// The client believes it does not need authentication. case none } @@ -232,6 +247,26 @@ extension NIOSSHUserAuthenticationOffer.Offer { fatalError("PublicKeyRequest is currently unimplemented") } } + + /// Information provided by the client when attempting to perform keyboard-interactive + /// authentication (RFC 4256). + /// + /// The individual challenge/response rounds are handled by + /// ``NIOSSHClientUserAuthenticationDelegate/respondToKeyboardInteractiveChallenge(_:responsePromise:)``, + /// so this offer only carries the initial request parameters. + public struct KeyboardInteractive: Sendable { + /// An optional language tag, per RFC 4256. Usually empty. + public var languageTag: String + + /// An optional, comma-separated list of preferred submethods the client would like the + /// server to use. Per RFC 4256 this is advisory and usually empty. + public var submethods: String + + public init(languageTag: String = "", submethods: String = "") { + self.languageTag = languageTag + self.submethods = submethods + } + } } extension SSHMessage.UserAuthRequestMessage { @@ -252,6 +287,13 @@ extension SSHMessage.UserAuthRequestMessage { self.method = .publicKey(.known(key: privateKeyRequest.publicKey, signature: signature)) case .password(let passwordRequest): self.method = .password(passwordRequest.password) + case .keyboardInteractive(let keyboardInteractiveRequest): + self.method = .keyboardInteractive( + .init( + languageTag: keyboardInteractiveRequest.languageTag, + submethods: keyboardInteractiveRequest.submethods + ) + ) case .hostBased: fatalError("Unsupported") case .none: diff --git a/Sources/NIOSSH/User Authentication/UserAuthenticationStateMachine.swift b/Sources/NIOSSH/User Authentication/UserAuthenticationStateMachine.swift index 3cda5113..af865914 100644 --- a/Sources/NIOSSH/User Authentication/UserAuthenticationStateMachine.swift +++ b/Sources/NIOSSH/User Authentication/UserAuthenticationStateMachine.swift @@ -20,6 +20,13 @@ struct UserAuthenticationStateMachine { private let loop: EventLoop private var sessionID: ByteBuffer + /// Whether the most recently sent authentication request used the `keyboard-interactive` method. + /// + /// This is used to disambiguate message identifier 60, which the server may use for either + /// `SSH_MSG_USERAUTH_PK_OK` or `SSH_MSG_USERAUTH_INFO_REQUEST`. See + /// ``expectingKeyboardInteractiveInfoRequest``. + private var lastSentMethodWasKeyboardInteractive: Bool = false + // TODO: The server SHOULD limit the number of authentication attempts the client may make. init(role: SSHConnectionRole, loop: EventLoop, sessionID: ByteBuffer) { self.state = .idle @@ -28,6 +35,23 @@ struct UserAuthenticationStateMachine { self.sessionID = sessionID } + /// Whether an inbound message with identifier 60 should be decoded as + /// `SSH_MSG_USERAUTH_INFO_REQUEST` (rather than `SSH_MSG_USERAUTH_PK_OK`). + /// + /// This is `true` exactly when the client is awaiting the outcome of a `keyboard-interactive` + /// authentication attempt. Deriving it from the authoritative auth state (rather than setting it + /// speculatively when a request is written) means the packet parser is always told how to + /// interpret identifier 60 correctly, independent of any ordering between writes and reads. + var expectingKeyboardInteractiveInfoRequest: Bool { + switch self.state { + case .awaitingResponses: + return self.lastSentMethodWasKeyboardInteractive + case .idle, .awaitingServiceAcceptance, .awaitingNextRequest, .authenticationSucceeded, + .authenticationFailed: + return false + } + } + fileprivate static let serviceName: String = "ssh-userauth" fileprivate static let nextServiceName: String = "ssh-connection" @@ -106,9 +130,11 @@ extension UserAuthenticationStateMachine { ) } - // Cool, we can begin the auth dance. + // Cool, we can begin the auth dance. We haven't been told which methods the server + // accepts yet, so we optimistically offer the full set the client can drive (including + // keyboard-interactive). The server will tell us what it really accepts if it rejects us. self.state = .awaitingNextRequest - return self.requestNextAuthRequest(methods: .all, delegate: delegate) + return self.requestNextAuthRequest(methods: .all.union(.keyboardInteractive), delegate: delegate) case (.client, .authenticationSucceeded): // We should ignore all further auth messages in this state. return nil @@ -243,6 +269,39 @@ extension UserAuthenticationStateMachine { } } + /// A UserAuthInfoRequest message (a keyboard-interactive challenge) was received from the server. + mutating func receiveUserAuthInfoRequest( + _ message: SSHMessage.UserAuthInfoRequestMessage + ) throws -> EventLoopFuture? { + switch (self.delegate, self.state) { + case (.client(let delegate), .awaitingResponses(let responseCount)): + // A keyboard-interactive attempt may involve any number of challenge/response rounds. + // We remain in `.awaitingResponses` throughout: the server will eventually send a + // success or failure to conclude the attempt. + precondition(responseCount == 1, "We don't support parallel authentication attempts yet!") + return self.requestKeyboardInteractiveResponse(request: message, delegate: delegate) + case (.client, .authenticationSucceeded): + // We should ignore all further auth messages in this state. + return nil + case (.client, .idle), (.client, .awaitingServiceAcceptance): + throw NIOSSHError.protocolViolation( + protocolName: Self.protocolName, + violation: "server sent keyboard-interactive info request unprompted" + ) + case (.client, .awaitingNextRequest), (.client, .authenticationFailed): + throw NIOSSHError.protocolViolation( + protocolName: Self.protocolName, + violation: "unsolicited keyboard-interactive info request" + ) + case (.server, _): + // Servers may never receive info request messages. + throw NIOSSHError.protocolViolation( + protocolName: Self.protocolName, + violation: "client sent keyboard-interactive info request" + ) + } + } + mutating func receiveUserAuthBanner(_: SSHMessage.UserAuthBannerMessage) throws { switch (self.delegate, self.state) { case (.client, .idle), (.client, .authenticationSucceeded): @@ -301,9 +360,16 @@ extension UserAuthenticationStateMachine { } } - mutating func sendUserAuthRequest(_: SSHMessage.UserAuthRequestMessage) { + mutating func sendUserAuthRequest(_ message: SSHMessage.UserAuthRequestMessage) { switch (self.delegate, self.state) { case (.client, .awaitingNextRequest): + // Record whether this attempt is keyboard-interactive so we can correctly decode a + // server response carrying the overloaded message identifier 60. + if case .keyboardInteractive = message.method { + self.lastSentMethodWasKeyboardInteractive = true + } else { + self.lastSentMethodWasKeyboardInteractive = false + } self.state = .awaitingResponses(1) case (.client, .idle), (.client, .awaitingServiceAcceptance): @@ -323,6 +389,26 @@ extension UserAuthenticationStateMachine { } } + mutating func sendUserAuthInfoResponse(_: SSHMessage.UserAuthInfoResponseMessage) { + switch (self.delegate, self.state) { + case (.client, .awaitingResponses): + // We're responding to a keyboard-interactive challenge. We stay in `.awaitingResponses`: + // the server may send further challenges, or conclude with success/failure. + break + case (.client, .idle), + (.client, .awaitingServiceAcceptance), + (.client, .awaitingNextRequest): + preconditionFailure("Sent an info response without a challenge outstanding") + case (.client, .authenticationSucceeded): + preconditionFailure("Attempted to send an info response after auth succeeded") + case (.client, .authenticationFailed): + preconditionFailure("Attempted to send an info response after auth failed") + case (.server, _): + // Servers may never send info response messages. + preconditionFailure("Servers may not authenticate") + } + } + mutating func sendUserAuthPKOK(_: SSHMessage.UserAuthPKOKMessage) { switch (self.delegate, self.state) { case (.server, .idle), @@ -452,6 +538,25 @@ extension UserAuthenticationStateMachine { try request.map { try SSHMessage.UserAuthRequestMessage(request: $0, sessionID: sessionID) } } } + + fileprivate func requestKeyboardInteractiveResponse( + request: SSHMessage.UserAuthInfoRequestMessage, + delegate: NIOSSHClientUserAuthenticationDelegate + ) -> EventLoopFuture { + let challenge = NIOSSHKeyboardInteractiveChallenge(request) + let promise = self.loop.makePromise(of: [String].self) + delegate.respondToKeyboardInteractiveChallenge(challenge, responsePromise: promise) + + let expectedResponses = request.prompts.count + return promise.futureResult.flatMapThrowing { responses in + guard responses.count == expectedResponses else { + throw NIOSSHError.invalidKeyboardInteractiveResponse( + "expected \(expectedResponses) response(s), delegate provided \(responses.count)" + ) + } + return SSHMessage.UserAuthInfoResponseMessage(responses: responses) + } + } } // MARK: Interacting with server delegate @@ -517,6 +622,13 @@ extension UserAuthenticationStateMachine { .failure(.init(authentications: delegate.supportedAuthenticationMethods.strings, partialSuccess: false)) ) + case .keyboardInteractive: + // Server-side keyboard-interactive authentication is not implemented. Reject the + // attempt by reporting the methods we do support, so the client can try another. + return self.loop.makeSucceededFuture( + .failure(.init(authentications: delegate.supportedAuthenticationMethods.strings, partialSuccess: false)) + ) + case .none: let request = NIOSSHUserAuthenticationRequest( username: request.username, diff --git a/Tests/NIOSSHTests/SSHMessagesTests.swift b/Tests/NIOSSHTests/SSHMessagesTests.swift index 442ab36a..4dde1251 100644 --- a/Tests/NIOSSHTests/SSHMessagesTests.swift +++ b/Tests/NIOSSHTests/SSHMessagesTests.swift @@ -797,4 +797,148 @@ final class SSHMessagesTests: XCTestCase { try self.assertCorrectlyManagesPartialRead(message) } + + // MARK: - Keyboard-interactive (RFC 4256) + + func testUserAuthRequestKeyboardInteractive() throws { + var buffer = ByteBufferAllocator().buffer(capacity: 100) + let message = SSHMessage.userAuthRequest( + .init( + username: "test", + service: "ssh-connection", + method: .keyboardInteractive(.init(languageTag: "", submethods: "")) + ) + ) + + buffer.writeSSHMessage(message) + XCTAssertEqual(try buffer.readSSHMessage(), message) + + try self.assertCorrectlyManagesPartialRead(message) + } + + func testUserAuthRequestKeyboardInteractiveWithSubmethods() throws { + var buffer = ByteBufferAllocator().buffer(capacity: 100) + let message = SSHMessage.userAuthRequest( + .init( + username: "test", + service: "ssh-connection", + method: .keyboardInteractive(.init(languageTag: "en-US", submethods: "otp,password")) + ) + ) + + buffer.writeSSHMessage(message) + XCTAssertEqual(try buffer.readSSHMessage(), message) + + try self.assertCorrectlyManagesPartialRead(message) + } + + func testUserAuthInfoRequestZeroPrompts() throws { + var buffer = ByteBufferAllocator().buffer(capacity: 100) + let message = SSHMessage.userAuthInfoRequest( + .init(name: "", instruction: "", languageTag: "", prompts: []) + ) + + buffer.writeSSHMessage(message) + // Identifier 60 is overloaded, so decoding an info request requires the context flag. + XCTAssertEqual( + try buffer.readSSHMessage(keyboardInteractiveInfoRequestExpected: true), + message + ) + } + + func testUserAuthInfoRequestSinglePrompt() throws { + var buffer = ByteBufferAllocator().buffer(capacity: 100) + let message = SSHMessage.userAuthInfoRequest( + .init( + name: "PAM Authentication", + instruction: "Please enter your credentials", + languageTag: "", + prompts: [.init(prompt: "Password: ", echo: false)] + ) + ) + + buffer.writeSSHMessage(message) + XCTAssertEqual( + try buffer.readSSHMessage(keyboardInteractiveInfoRequestExpected: true), + message + ) + } + + func testUserAuthInfoRequestMultiplePrompts() throws { + var buffer = ByteBufferAllocator().buffer(capacity: 100) + let message = SSHMessage.userAuthInfoRequest( + .init( + name: "Two-factor", + instruction: "Complete both steps", + languageTag: "en-US", + prompts: [ + .init(prompt: "Password: ", echo: false), + .init(prompt: "Verification code: ", echo: false), + .init(prompt: "Username confirmation: ", echo: true), + ] + ) + ) + + buffer.writeSSHMessage(message) + let decoded = try buffer.readSSHMessage(keyboardInteractiveInfoRequestExpected: true) + XCTAssertEqual(decoded, message) + + // Partial reads must return nil and preserve the reader index. + var partial = ByteBufferAllocator().buffer(capacity: 100) + partial.writeSSHMessage(message) + let messageBytes = partial.readableBytesView[...] + partial.clear() + for byte in messageBytes.dropLast() { + partial.writeInteger(byte) + XCTAssertNil(try partial.readSSHMessage(keyboardInteractiveInfoRequestExpected: true)) + } + partial.writeInteger(messageBytes.last!) + XCTAssertEqual(try partial.readSSHMessage(keyboardInteractiveInfoRequestExpected: true), message) + } + + func testIdentifier60DecodesAsPKOKWithoutContext() throws { + // Without the keyboard-interactive context, identifier 60 must still decode as PK_OK. + var buffer = ByteBufferAllocator().buffer(capacity: 100) + let key = NIOSSHPrivateKey(ed25519Key: .init()) + let message = SSHMessage.userAuthPKOK(.init(key: key.publicKey)) + + buffer.writeSSHMessage(message) + XCTAssertEqual(try buffer.readSSHMessage(), message) + } + + func testUserAuthInfoResponseZeroResponses() throws { + var buffer = ByteBufferAllocator().buffer(capacity: 100) + let message = SSHMessage.userAuthInfoResponse(.init(responses: [])) + + buffer.writeSSHMessage(message) + XCTAssertEqual(try buffer.readSSHMessage(), message) + + try self.assertCorrectlyManagesPartialRead(message) + } + + func testUserAuthInfoResponseMultipleResponses() throws { + var buffer = ByteBufferAllocator().buffer(capacity: 100) + let message = SSHMessage.userAuthInfoResponse(.init(responses: ["hunter2", "123456", ""])) + + buffer.writeSSHMessage(message) + XCTAssertEqual(try buffer.readSSHMessage(), message) + + try self.assertCorrectlyManagesPartialRead(message) + } + + func testAvailableMethodsParseKeyboardInteractive() throws { + let failure = SSHMessage.UserAuthFailureMessage( + authentications: ["password", "keyboard-interactive"], + partialSuccess: false + ) + let methods = NIOSSHAvailableUserAuthenticationMethods(failure) + XCTAssertTrue(methods.contains(.keyboardInteractive)) + XCTAssertTrue(methods.contains(.password)) + XCTAssertFalse(methods.contains(.publicKey)) + } + + func testAvailableMethodsSerializeKeyboardInteractive() throws { + let methods: NIOSSHAvailableUserAuthenticationMethods = [.keyboardInteractive] + XCTAssertEqual(methods.strings, ["keyboard-interactive"]) + } } diff --git a/Tests/NIOSSHTests/SSHPacketParserTests.swift b/Tests/NIOSSHTests/SSHPacketParserTests.swift index 7f645c6f..2e71b3c6 100644 --- a/Tests/NIOSSHTests/SSHPacketParserTests.swift +++ b/Tests/NIOSSHTests/SSHPacketParserTests.swift @@ -253,6 +253,118 @@ final class SSHPacketParserTests: XCTestCase { } } + /// Serialize a message into a cleartext SSH packet frame, ready to feed to a parser that has + /// already consumed a version string. + private func serializedPacket(_ message: SSHMessage) throws -> ByteBuffer { + var serializer = SSHPacketSerializer() + var buffer = ByteBufferAllocator().buffer(capacity: 128) + try serializer.serialize(message: .version("SSH-2.0-TestPeer"), to: &buffer) + buffer.clear() + try serializer.serialize(message: message, to: &buffer) + return buffer + } + + func testIdentifier60DecodesAsPKOKByDefault() throws { + var parser = SSHPacketParser(isServer: false, allocator: ByteBufferAllocator()) + self.feedVersion(to: &parser) + + let key = NIOSSHPrivateKey(ed25519Key: .init()).publicKey + var packet = try self.serializedPacket(.userAuthPKOK(.init(key: key))) + parser.append(bytes: &packet) + + switch try parser.nextPacket() { + case .userAuthPKOK(let message): + XCTAssertEqual(message.key, key) + default: + XCTFail("Expecting .userAuthPKOK") + } + } + + func testIdentifier60DecodesAsInfoRequestWhenExpected() throws { + var parser = SSHPacketParser(isServer: false, allocator: ByteBufferAllocator()) + self.feedVersion(to: &parser) + + // Simulate the connection state machine flagging that a keyboard-interactive attempt is in + // flight, which makes identifier 60 an info request rather than a PK_OK. + parser.keyboardInteractiveInfoRequestExpected = true + + let infoRequest = SSHMessage.UserAuthInfoRequestMessage( + name: "PAM", + instruction: "Authenticate", + languageTag: "", + prompts: [ + .init(prompt: "Password: ", echo: false), + .init(prompt: "Verification code: ", echo: false), + ] + ) + var packet = try self.serializedPacket(.userAuthInfoRequest(infoRequest)) + parser.append(bytes: &packet) + + switch try parser.nextPacket() { + case .userAuthInfoRequest(let message): + XCTAssertEqual(message, infoRequest) + default: + XCTFail("Expecting .userAuthInfoRequest") + } + } + + func testEncryptedIdentifier60DecodesAsInfoRequestWhenExpected() throws { + // Mirror a real connection: encryption is negotiated, and identifier 60 arrives encrypted + // while a keyboard-interactive attempt is in flight. + let allocator = ByteBufferAllocator() + var parser = SSHPacketParser(isServer: false, allocator: allocator) + self.feedVersion(to: &parser) + + let key = SymmetricKey(size: .bits128) + let macKey = SymmetricKey(size: .bits128) + func makeProtection() -> TestTransportProtection { + TestTransportProtection( + initialKeys: .init( + initialInboundIV: [], + initialOutboundIV: [], + inboundEncryptionKey: key, + outboundEncryptionKey: key, + inboundMACKey: macKey, + outboundMACKey: macKey + ) + ) + } + + // Advance the parser to sequence number 1 so it matches the packet we encrypt below, then + // enable encryption. + var newKeysPacket = try self.serializedPacket(.newKeys) + parser.append(bytes: &newKeysPacket) + XCTAssertEqual(try parser.nextPacket(), .newKeys) + parser.addEncryption(makeProtection()) + + // The server is doing keyboard-interactive, so identifier 60 must decode as an info request. + parser.keyboardInteractiveInfoRequestExpected = true + + let infoRequest = SSHMessage.UserAuthInfoRequestMessage( + name: "PAM", + instruction: "Authenticate", + languageTag: "", + prompts: [.init(prompt: "Password: ", echo: false)] + ) + + var encrypted = allocator.buffer(capacity: 256) + let protection = makeProtection() + encrypted.writeSSHPacket( + message: .userAuthInfoRequest(infoRequest), + lengthEncrypted: protection.lengthEncrypted, + blockSize: protection.cipherBlockSize + ) + XCTAssertNoThrow(try protection.encryptPacket(&encrypted, sequenceNumber: 1)) + parser.append(bytes: &encrypted) + + switch try parser.nextPacket() { + case .userAuthInfoRequest(let message): + XCTAssertEqual(message, infoRequest) + default: + XCTFail("Expecting .userAuthInfoRequest over encrypted transport") + } + } + func testWeReclaimStorage() throws { var parser = SSHPacketParser(isServer: false, allocator: ByteBufferAllocator()) self.feedVersion(to: &parser) diff --git a/Tests/NIOSSHTests/UserAuthenticationStateMachineTests.swift b/Tests/NIOSSHTests/UserAuthenticationStateMachineTests.swift index 990d8f1a..abfd2602 100644 --- a/Tests/NIOSSHTests/UserAuthenticationStateMachineTests.swift +++ b/Tests/NIOSSHTests/UserAuthenticationStateMachineTests.swift @@ -97,6 +97,52 @@ final class InfiniteCertificateDelegate: NIOSSHClientUserAuthenticationDelegate } } +/// An authentication delegate that offers keyboard-interactive authentication and answers challenges +/// by echoing back one canned response per prompt. +final class KeyboardInteractiveDelegate: NIOSSHClientUserAuthenticationDelegate { + /// The response to provide for each prompt in a challenge. If `nil`, one response is generated + /// per prompt (so the count always matches). + var cannedResponses: [String]? + + /// Records the challenges received, for assertion purposes. + private(set) var receivedChallenges: [NIOSSHKeyboardInteractiveChallenge] = [] + + init(cannedResponses: [String]? = nil) { + self.cannedResponses = cannedResponses + } + + func nextAuthenticationType( + availableMethods: NIOSSHAvailableUserAuthenticationMethods, + nextChallengePromise: EventLoopPromise + ) { + guard availableMethods.contains(.keyboardInteractive) else { + nextChallengePromise.succeed(nil) + return + } + + nextChallengePromise.succeed( + NIOSSHUserAuthenticationOffer( + username: "foo", + serviceName: "", + offer: .keyboardInteractive(.init()) + ) + ) + } + + func respondToKeyboardInteractiveChallenge( + _ challenge: NIOSSHKeyboardInteractiveChallenge, + responsePromise: EventLoopPromise<[String]> + ) { + self.receivedChallenges.append(challenge) + + if let cannedResponses = self.cannedResponses { + responsePromise.succeed(cannedResponses) + } else { + responsePromise.succeed(challenge.prompts.map { "response-to-\($0.prompt)" }) + } + } +} + /// An authentication delegate that denies some number of requests and then accepts exactly one and fails the rest. final class DenyThenAcceptDelegate: NIOSSHServerUserAuthenticationDelegate { let supportedAuthenticationMethods: NIOSSHAvailableUserAuthenticationMethods = .all @@ -1127,6 +1173,294 @@ final class UserAuthenticationStateMachineTests: XCTestCase { XCTAssertNoThrow(try stateMachine.receiveUserAuthSuccess()) } + /// Drives an inbound keyboard-interactive info request through the state machine and returns the + /// resulting info response message (or nil). + private func receiveInfoRequest( + _ message: SSHMessage.UserAuthInfoRequestMessage, + stateMachine: inout UserAuthenticationStateMachine + ) throws -> SSHMessage.UserAuthInfoResponseMessage? { + let future = try assertNoThrowWithValue(stateMachine.receiveUserAuthInfoRequest(message)) + XCTAssertNotNil(future) + guard let future = future else { + return nil + } + + let response = NIOLoopBoundBox(nil, eventLoop: future.eventLoop) + future.whenComplete { + switch $0 { + case .success(let message): + response.value = message + case .failure(let error): + XCTFail("Unexpected error: \(error)") + } + } + self.loop.run() + return response.value + } + + func testKeyboardInteractiveSingleRound() throws { + let delegate = KeyboardInteractiveDelegate() + var stateMachine = UserAuthenticationStateMachine( + role: .client(.init(userAuthDelegate: delegate, serverAuthDelegate: AcceptAllHostKeysDelegate())), + loop: self.loop, + sessionID: self.sessionID + ) + + XCTAssertNoThrow(try self.beginAuthentication(stateMachine: &stateMachine)) + stateMachine.sendServiceRequest(.init(service: "ssh-userauth")) + + // The server offers keyboard-interactive, so the client should offer it back. + let firstMessage = SSHMessage.UserAuthRequestMessage( + username: "foo", + service: "ssh-connection", + method: .keyboardInteractive(.init(languageTag: "", submethods: "")) + ) + // Before offering keyboard-interactive, identifier 60 must decode as PK_OK. + XCTAssertFalse(stateMachine.expectingKeyboardInteractiveInfoRequest) + + XCTAssertNoThrow( + try self.serviceAccepted(service: "ssh-userauth", nextMessage: firstMessage, stateMachine: &stateMachine) + ) + stateMachine.sendUserAuthRequest(firstMessage) + + // Now that a keyboard-interactive attempt is in flight, identifier 60 must decode as an + // info request. This is what drives the packet parser in the connection state machine. + XCTAssertTrue(stateMachine.expectingKeyboardInteractiveInfoRequest) + + // The server sends a challenge. + let infoRequest = SSHMessage.UserAuthInfoRequestMessage( + name: "PAM", + instruction: "", + languageTag: "", + prompts: [.init(prompt: "Password: ", echo: false)] + ) + let response = try self.receiveInfoRequest(infoRequest, stateMachine: &stateMachine) + XCTAssertEqual(response, .init(responses: ["response-to-Password: "])) + XCTAssertEqual(delegate.receivedChallenges.count, 1) + XCTAssertEqual(delegate.receivedChallenges.first?.prompts.first?.echo, false) + + stateMachine.sendUserAuthInfoResponse(response!) + + // Still in flight until the server concludes the attempt. + XCTAssertTrue(stateMachine.expectingKeyboardInteractiveInfoRequest) + + // Server is happy. + XCTAssertNoThrow(try stateMachine.receiveUserAuthSuccess()) + XCTAssertFalse(stateMachine.expectingKeyboardInteractiveInfoRequest) + } + + func testExpectingInfoRequestIsFalseForPasswordAuth() throws { + let delegate = InfinitePasswordDelegate() + var stateMachine = UserAuthenticationStateMachine( + role: .client(.init(userAuthDelegate: delegate, serverAuthDelegate: AcceptAllHostKeysDelegate())), + loop: self.loop, + sessionID: self.sessionID + ) + + XCTAssertNoThrow(try self.beginAuthentication(stateMachine: &stateMachine)) + stateMachine.sendServiceRequest(.init(service: "ssh-userauth")) + + let firstMessage = SSHMessage.UserAuthRequestMessage( + username: "foo", + service: "ssh-connection", + method: .password("bar") + ) + XCTAssertNoThrow( + try self.serviceAccepted(service: "ssh-userauth", nextMessage: firstMessage, stateMachine: &stateMachine) + ) + stateMachine.sendUserAuthRequest(firstMessage) + + // A password attempt must leave identifier 60 decoding as PK_OK. + XCTAssertFalse(stateMachine.expectingKeyboardInteractiveInfoRequest) + } + + func testKeyboardInteractiveMultipleRounds() throws { + let delegate = KeyboardInteractiveDelegate() + var stateMachine = UserAuthenticationStateMachine( + role: .client(.init(userAuthDelegate: delegate, serverAuthDelegate: AcceptAllHostKeysDelegate())), + loop: self.loop, + sessionID: self.sessionID + ) + + XCTAssertNoThrow(try self.beginAuthentication(stateMachine: &stateMachine)) + stateMachine.sendServiceRequest(.init(service: "ssh-userauth")) + + let firstMessage = SSHMessage.UserAuthRequestMessage( + username: "foo", + service: "ssh-connection", + method: .keyboardInteractive(.init(languageTag: "", submethods: "")) + ) + XCTAssertNoThrow( + try self.serviceAccepted(service: "ssh-userauth", nextMessage: firstMessage, stateMachine: &stateMachine) + ) + stateMachine.sendUserAuthRequest(firstMessage) + + // Round 1: password. + let round1 = SSHMessage.UserAuthInfoRequestMessage( + name: "", + instruction: "", + languageTag: "", + prompts: [.init(prompt: "Password: ", echo: false)] + ) + let response1 = try self.receiveInfoRequest(round1, stateMachine: &stateMachine) + XCTAssertEqual(response1, .init(responses: ["response-to-Password: "])) + stateMachine.sendUserAuthInfoResponse(response1!) + + // Round 2: a zero-prompt info request (valid, must produce an empty response). + let round2 = SSHMessage.UserAuthInfoRequestMessage( + name: "", + instruction: "Please wait...", + languageTag: "", + prompts: [] + ) + let response2 = try self.receiveInfoRequest(round2, stateMachine: &stateMachine) + XCTAssertEqual(response2, .init(responses: [])) + stateMachine.sendUserAuthInfoResponse(response2!) + + // Round 3: OTP. + let round3 = SSHMessage.UserAuthInfoRequestMessage( + name: "", + instruction: "", + languageTag: "", + prompts: [.init(prompt: "Verification code: ", echo: false)] + ) + let response3 = try self.receiveInfoRequest(round3, stateMachine: &stateMachine) + XCTAssertEqual(response3, .init(responses: ["response-to-Verification code: "])) + stateMachine.sendUserAuthInfoResponse(response3!) + + XCTAssertEqual(delegate.receivedChallenges.count, 3) + + // Success at last. + XCTAssertNoThrow(try stateMachine.receiveUserAuthSuccess()) + } + + func testKeyboardInteractiveFailureTriesNextMethod() throws { + let delegate = KeyboardInteractiveDelegate() + var stateMachine = UserAuthenticationStateMachine( + role: .client(.init(userAuthDelegate: delegate, serverAuthDelegate: AcceptAllHostKeysDelegate())), + loop: self.loop, + sessionID: self.sessionID + ) + + XCTAssertNoThrow(try self.beginAuthentication(stateMachine: &stateMachine)) + stateMachine.sendServiceRequest(.init(service: "ssh-userauth")) + + let firstMessage = SSHMessage.UserAuthRequestMessage( + username: "foo", + service: "ssh-connection", + method: .keyboardInteractive(.init(languageTag: "", submethods: "")) + ) + XCTAssertNoThrow( + try self.serviceAccepted(service: "ssh-userauth", nextMessage: firstMessage, stateMachine: &stateMachine) + ) + stateMachine.sendUserAuthRequest(firstMessage) + + // Server rejects and no longer offers keyboard-interactive, so the client gives up. + let failure = SSHMessage.UserAuthFailureMessage(authentications: ["publickey"], partialSuccess: false) + try self.authFailed(failure: failure, nextMessage: nil, stateMachine: &stateMachine) + stateMachine.noFurtherMethods() + } + + func testKeyboardInteractiveResponseCountMismatchFails() throws { + // Delegate returns two responses for a single-prompt challenge. + let delegate = KeyboardInteractiveDelegate(cannedResponses: ["one", "two"]) + var stateMachine = UserAuthenticationStateMachine( + role: .client(.init(userAuthDelegate: delegate, serverAuthDelegate: AcceptAllHostKeysDelegate())), + loop: self.loop, + sessionID: self.sessionID + ) + + XCTAssertNoThrow(try self.beginAuthentication(stateMachine: &stateMachine)) + stateMachine.sendServiceRequest(.init(service: "ssh-userauth")) + + let firstMessage = SSHMessage.UserAuthRequestMessage( + username: "foo", + service: "ssh-connection", + method: .keyboardInteractive(.init(languageTag: "", submethods: "")) + ) + XCTAssertNoThrow( + try self.serviceAccepted(service: "ssh-userauth", nextMessage: firstMessage, stateMachine: &stateMachine) + ) + stateMachine.sendUserAuthRequest(firstMessage) + + let infoRequest = SSHMessage.UserAuthInfoRequestMessage( + name: "", + instruction: "", + languageTag: "", + prompts: [.init(prompt: "Password: ", echo: false)] + ) + let future = try assertNoThrowWithValue(stateMachine.receiveUserAuthInfoRequest(infoRequest)) + XCTAssertNotNil(future) + + let error = NIOLoopBoundBox(nil, eventLoop: future!.eventLoop) + future!.whenComplete { + switch $0 { + case .success: + XCTFail("Expected failure due to response count mismatch") + case .failure(let e): + error.value = e + } + } + self.loop.run() + + XCTAssertEqual((error.value as? NIOSSHError)?.type, .invalidKeyboardInteractiveResponse) + } + + func testKeyboardInteractiveDefaultDelegateFailsCleanly() throws { + // A delegate that offers keyboard-interactive but does not override the challenge callback + // must fail the challenge with a clear error via the default implementation. + final class OffersButDoesNotAnswer: NIOSSHClientUserAuthenticationDelegate { + func nextAuthenticationType( + availableMethods: NIOSSHAvailableUserAuthenticationMethods, + nextChallengePromise: EventLoopPromise + ) { + nextChallengePromise.succeed( + .init(username: "foo", serviceName: "", offer: .keyboardInteractive(.init())) + ) + } + } + + let delegate = OffersButDoesNotAnswer() + var stateMachine = UserAuthenticationStateMachine( + role: .client(.init(userAuthDelegate: delegate, serverAuthDelegate: AcceptAllHostKeysDelegate())), + loop: self.loop, + sessionID: self.sessionID + ) + + XCTAssertNoThrow(try self.beginAuthentication(stateMachine: &stateMachine)) + stateMachine.sendServiceRequest(.init(service: "ssh-userauth")) + + let firstMessage = SSHMessage.UserAuthRequestMessage( + username: "foo", + service: "ssh-connection", + method: .keyboardInteractive(.init(languageTag: "", submethods: "")) + ) + XCTAssertNoThrow( + try self.serviceAccepted(service: "ssh-userauth", nextMessage: firstMessage, stateMachine: &stateMachine) + ) + stateMachine.sendUserAuthRequest(firstMessage) + + let infoRequest = SSHMessage.UserAuthInfoRequestMessage( + name: "", + instruction: "", + languageTag: "", + prompts: [.init(prompt: "Password: ", echo: false)] + ) + let future = try assertNoThrowWithValue(stateMachine.receiveUserAuthInfoRequest(infoRequest)) + let error = NIOLoopBoundBox(nil, eventLoop: future!.eventLoop) + future!.whenComplete { + switch $0 { + case .success: + XCTFail("Expected failure from default keyboard-interactive implementation") + case .failure(let e): + error.value = e + } + } + self.loop.run() + + XCTAssertEqual((error.value as? NIOSSHError)?.type, .unsupportedUserAuthenticationMethod) + } + func testCertificateClientAuthFlow() throws { let delegate = try InfiniteCertificateDelegate() var stateMachine = UserAuthenticationStateMachine(