diff --git a/gen/gen.jl b/gen/gen.jl index 224827c..1afe698 100644 --- a/gen/gen.jl +++ b/gen/gen.jl @@ -10,9 +10,12 @@ import Clang.Generators: ExprNode, AbstractFunctionNodeType ctx_objects = Dict{Symbol, Any}() # These are lists of functions that we'll rewrite to return Julia types -string_functions = [:ssh_message_auth_user, :ssh_message_auth_password] +string_functions = [:ssh_message_auth_user, :ssh_message_auth_password, + :ssh_userauth_kbdint_getanswer] bool_functions = [:ssh_message_auth_kbdint_is_response] -all_rewritable_functions = vcat(string_functions, bool_functions) +ssh_ok_functions = [:ssh_message_auth_reply_success, :ssh_message_auth_set_methods, + :ssh_message_reply_default] +all_rewritable_functions = vcat(string_functions, bool_functions, ssh_ok_functions) """ Helper function to generate documentation for symbols with missing docstrings. @@ -115,6 +118,16 @@ function rewrite!(ctx) elseif name in bool_functions wrapper = :(return ret == 1) ret_type = Bool + elseif name in ssh_ok_functions + wrapper = quote + if ret != SSH_OK + # This ugly concatenation is necessary because we + # have to interpolate the function name into the + # error string but also keep the return value + # interpolation from being escaped. + throw(LibSSHException($("Error from $name, did not return SSH_OK: ") * "$(ret)")) + end + end end if !isnothing(wrapper) diff --git a/src/bindings.jl b/src/bindings.jl index dbdf498..1dbaca6 100644 --- a/src/bindings.jl +++ b/src/bindings.jl @@ -4898,6 +4898,32 @@ function ssh_get_log_callback() @ccall libssh.ssh_get_log_callback()::ssh_logging_callback end +""" + userauth_kbdint_getanswer(session, i)::String + +Auto-generated wrapper around [`ssh_userauth_kbdint_getanswer`](@ref). +""" +function userauth_kbdint_getanswer(session, i)::String + ret = ssh_userauth_kbdint_getanswer(session, i) + if ret == C_NULL + throw(LibSSHException("Error from ssh_userauth_kbdint_getanswer, no string found (returned C_NULL)")) + else + return unsafe_string(Ptr{UInt8}(ret)) + end +end + +""" + message_reply_default(msg) + +Auto-generated wrapper around [`ssh_message_reply_default`](@ref). +""" +function message_reply_default(msg) + ret = ssh_message_reply_default(msg) + if ret != SSH_OK + throw(LibSSHException("Error from ssh_message_reply_default, did not return SSH_OK: " * "$(ret)")) + end +end + """ message_auth_user(msg)::String @@ -4936,6 +4962,30 @@ function message_auth_kbdint_is_response(msg)::Bool return ret == 1 end +""" + message_auth_reply_success(msg, partial) + +Auto-generated wrapper around [`ssh_message_auth_reply_success`](@ref). +""" +function message_auth_reply_success(msg, partial) + ret = ssh_message_auth_reply_success(msg, partial) + if ret != SSH_OK + throw(LibSSHException("Error from ssh_message_auth_reply_success, did not return SSH_OK: " * "$(ret)")) + end +end + +""" + message_auth_set_methods(msg, methods) + +Auto-generated wrapper around [`ssh_message_auth_set_methods`](@ref). +""" +function message_auth_set_methods(msg, methods) + ret = ssh_message_auth_set_methods(msg, methods) + if ret != SSH_OK + throw(LibSSHException("Error from ssh_message_auth_set_methods, did not return SSH_OK: " * "$(ret)")) + end +end + # Skipping MacroDefinition: LIBSSH_API __attribute__ ( ( visibility ( "default" ) ) ) # Skipping MacroDefinition: SSH_DEPRECATED __attribute__ ( ( deprecated ) ) diff --git a/src/server.jl b/src/server.jl index 0426bc1..d6b5b7c 100644 --- a/src/server.jl +++ b/src/server.jl @@ -382,6 +382,16 @@ end """ $(TYPEDSIGNATURES) +Wrapper around [`LibSSH.lib.message_auth_set_methods`](@ref). +""" +function set_auth_methods(msg::lib.ssh_message, auth_methods::Vector{AuthMethod}) + bitflag = reduce(|, Int.(auth_methods)) + lib.message_auth_set_methods(msg, bitflag) +end + +""" +$(TYPEDSIGNATURES) + Non-blocking wrapper around `LibSSH.lib.ssh_handle_key_exchange()`. Returns `true` or `false` depending on whether the exchange succeeded. """ @@ -411,10 +421,25 @@ $(TYPEDSIGNATURES) Set message callbacks for the sessions accepted by a Server. This must be set before `listen()` is called to take effect. `listen()` will automatically set the callback before passing the session to the user handler. + +The callback function must have the signature: + + f(session::Session, msg::lib.ssh_message, userdata)::Bool + +The return value indicates whether further handling of the message is necessary. +It should be `true` if the message wasn't handled or needs to be handled by +libssh, or `false` if the message was completely handled and doesn't need any +more action from libssh. """ function set_message_callback(f::Function, server::Server, userdata) + if !hasmethod(f, (Session, lib.ssh_message, typeof(userdata))) + throw(ArgumentError("Callback function f() doesn't have the right signature")) + end + server._message_callback = f server._message_callback_userdata = userdata + + return nothing end """ @@ -439,6 +464,50 @@ function event_dopoll(event::SshEvent, session::Session, sshchan_locks...) return ret end +""" +$(TYPEDSIGNATURES) + +## Parameters + +- `msg`: The message to reply to. +- `name`: The name of the message block. +- `instruction`: The instruction for the user. +- `prompts`: The prompts to show to the user. +- `echo`: Whether the client should echo the answer to the prompts (e.g. it + probably shouldn't echo the password). + +Wrapper around [`lib.ssh_message_auth_interactive_request`](@ref). +""" +function message_auth_interactive_request(msg::lib.ssh_message, + name::AbstractString, instruction::AbstractString, + prompts::Vector{String}, echo::Vector{Bool}) + # Check that prompts and echo have the same length + if length(prompts) != length(echo) + throw(ArgumentError("`prompts` and `echo` must have the same length! Actual lengths are $(length(prompts)) and $(length(echo))")) + end + + # Convert arguments to C types + name_cstr = Base.cconvert(Cstring, name) + instruction_cstr = Base.cconvert(Cstring, instruction) + prompts_cstrs = [Base.cconvert(Cstring, p) for p in prompts] + echo_arr = map(Cchar, echo) + + # Call library + GC.@preserve prompts_cstrs echo_arr begin + prompts_arr = pointer.(prompts_cstrs) + + ret = lib.ssh_message_auth_interactive_request(msg, name_cstr, instruction_cstr, + length(prompts), Ptr{Ptr{UInt8}}(pointer(prompts_arr)), + pointer(echo_arr)) + end + + if ret == SSH_ERROR + throw(LibSSHException("Error when responding to kbdint auth request: $(ret)")) + end + + return ret +end + module Demo @@ -483,8 +552,9 @@ end function on_auth_password(session, user, password, demo_server)::ssh.AuthStatus _add_log_event!(demo_server, :auth_password, (user, password)) + demo_server.authenticated = password == demo_server.password - return password == demo_server.password ? ssh.AuthStatus_Success : ssh.AuthStatus_Denied + return demo_server.authenticated ? ssh.AuthStatus_Success : ssh.AuthStatus_Denied end function on_auth_none(session, user, demo_server)::ssh.AuthStatus @@ -608,6 +678,46 @@ function on_message(session, msg::lib.ssh_message, demo_server)::Bool return false end + # Handle keyboard-interactive authentication + if msg_type == ssh.RequestType_Auth && msg_subtype == lib.SSH_AUTH_METHOD_INTERACTIVE + if demo_server.authenticated + _add_log_event!(demo_server, :auth_kbdint, "already authenticated") + lib.message_auth_reply_success(msg, Int(false)) + return false + end + + if !lib.message_auth_kbdint_is_response(msg) + # This means the user is requesting authentication + user = lib.message_auth_user(msg) + _add_log_event!(demo_server, :auth_kbdint, user) + ssh.message_auth_interactive_request(msg, "Demo server login", "Enter your details.", + ["Password: ", "Token: "], [true, true]) + return false + else + # Now they're responding to our prompts + n_answers = lib.ssh_userauth_kbdint_getnanswers(session.ptr) + + # If they didn't return the correct number of answers, deny the request + if n_answers != 2 + _add_log_event!(demo_server, :auth_kbdint, "denied") + lib.message_reply_default(msg) + return false + end + + # Get the answers and check them + password = lib.userauth_kbdint_getanswer(session.ptr, 0) + token = lib.userauth_kbdint_getanswer(session.ptr, 1) + if password == "foo" && token == "bar" + _add_log_event!(demo_server, :auth_kbdint, "accepted with '$password' and '$token'") + lib.message_auth_reply_success(msg, Int(false)) + demo_server.authenticated = true + return false + end + + return true + end + end + return true end @@ -646,6 +756,7 @@ end sshchan::Union{ssh.SshChannel, Nothing} = nothing verbose::Bool = false password::Union{String, Nothing} = nothing + authenticated::Bool = false exec_task::Union{Task, Nothing} = nothing exec_proc::Union{Base.Process, Nothing} = nothing diff --git a/src/session.jl b/src/session.jl index 74302a4..3881959 100644 --- a/src/session.jl +++ b/src/session.jl @@ -294,6 +294,8 @@ $(TYPEDSIGNATURES) Wrapper around `LibSSH.lib.ssh_userauth_list()`. It will throw a `LibSSHException` if the SSH server supports `AuthMethod_None` or if another error occurred. + +This wrapper will automatically call [`userauth_none()`](@ref) beforehand. """ function userauth_list(session::Session) # First we have to call ssh_userauth_none() for... some reason, according to diff --git a/test/LibSSHTests.jl b/test/LibSSHTests.jl index bc2a41a..b612760 100644 --- a/test/LibSSHTests.jl +++ b/test/LibSSHTests.jl @@ -98,6 +98,7 @@ end # Check that the authentication methods were called @test logs[:auth_none] == [true] @test logs[:auth_password] == [("foo", "bar")] + @test demo_server.authenticated # And a channel was created @test !isnothing(demo_server.sshchan) @@ -146,6 +147,21 @@ end @test demo_server.callback_log[:message_request] == [(ssh.RequestType_ChannelOpen, lib.SSH_CHANNEL_DIRECT_TCPIP)] end + + @testset "Keyboard-interactive authentication" begin + demo_server = DemoServer(2222; auth_methods=[ssh.AuthMethod_Interactive]) do + # Run the script + script_path = joinpath(@__DIR__, "interactive_ssh.sh") + proc = run(`expect -f $script_path`; wait=false) + wait(proc) + end + + # Check that authentication succeeded + @test demo_server.authenticated + + # And the command was executed + @test demo_server.callback_log[:channel_exec_request] == ["whoami"] + end end @testset "Session" begin diff --git a/test/interactive_ssh.sh b/test/interactive_ssh.sh new file mode 100755 index 0000000..36271a0 --- /dev/null +++ b/test/interactive_ssh.sh @@ -0,0 +1,18 @@ +#! /usr/bin/expect + +set timeout 5 + +# Start the SSH process +spawn ssh -o NoHostAuthenticationForLocalhost=yes -p 2222 localhost whoami + +# Pass the correct prompts +expect "Password:" { + send "foo\r" + + expect "Token:" { + send "bar\r" + } +} + +# Wait for the command to finish +wait