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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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!
Expand Down
23 changes: 23 additions & 0 deletions Sources/NIOSSH/NIOSSHError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -207,6 +217,8 @@ extension NIOSSHError {
case invalidHostKeyForKeyExchange
case invalidOpenSSHPublicKey
case invalidCertificate
case unsupportedUserAuthenticationMethod
case invalidKeyboardInteractiveResponse
}

private var base: Base
Expand Down Expand Up @@ -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
)
}
}

Expand Down
160 changes: 157 additions & 3 deletions Sources/NIOSSH/SSHMessages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -146,13 +148,20 @@ extension SSHMessage {
case none
case publicKey(PublicKeyAuthType)
case password(String)
case keyboardInteractive(KeyboardInteractive)
}

enum PublicKeyAuthType: Equatable {
case known(key: NIOSSHPublicKey, signature: NIOSSHSignature?)
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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..<numPrompts {
guard
let prompt = self.readSSHStringAsString(),
let echo = self.readSSHBoolean()
else {
return nil
}
prompts.append(.init(prompt: prompt, echo: echo))
}

return SSHMessage.UserAuthInfoRequestMessage(
name: name,
instruction: instruction,
languageTag: languageTag,
prompts: prompts
)
}
}

mutating func readUserAuthInfoResponseMessage() -> 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..<numResponses {
guard let response = self.readSSHStringAsString() else {
return nil
}
responses.append(response)
}

return SSHMessage.UserAuthInfoResponseMessage(responses: responses)
}
}

mutating func readGlobalRequestMessage() throws -> SSHMessage.GlobalRequestMessage? {
self.rewindReaderOnNil { `self` in
guard
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down
18 changes: 16 additions & 2 deletions Sources/NIOSSH/SSHPacketParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -183,7 +191,10 @@ struct SSHPacketParser {
buffer.moveReaderIndex(forwardBy: MemoryLayout<UInt32>.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
Expand All @@ -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
Expand Down
Loading