diff --git a/base/libgit2/callbacks.jl b/base/libgit2/callbacks.jl index 196d8651fcfbe..9267bbb2982cf 100644 --- a/base/libgit2/callbacks.jl +++ b/base/libgit2/callbacks.jl @@ -42,22 +42,38 @@ end function user_abort() # Note: Potentially it could be better to just throw a Julia error. ccall((:giterr_set_str, :libgit2), Void, - (Cint, Cstring), - Cint(Error.Callback), "Aborting, user cancelled credential request.") - + (Cint, Cstring), Cint(Error.Callback), + "Aborting, user cancelled credential request.") return Cint(Error.EUSER) end +function prompt_limit() + ccall((:giterr_set_str, :libgit2), Void, + (Cint, Cstring), Cint(Error.Callback), + "Aborting, maximum number of prompts reached.") + return Cint(Error.EAUTH) +end + +function exhausted_abort() + ccall((:giterr_set_str, :libgit2), Void, + (Cint, Cstring), Cint(Error.Callback), + "All authentication methods have failed.") + return Cint(Error.EAUTH) +end + function authenticate_ssh(libgit2credptr::Ptr{Ptr{Void}}, p::CredentialPayload, username_ptr) - creds = Base.get(p.credential)::SSHCredentials + cred = Base.get(p.credential)::SSHCredentials + revised = false - # Reset password on sucessive calls - if !p.first_pass - creds.pass = "" + # Use a filled credential as-is on the first pass. Reset password on sucessive calls. + if p.first_pass && isfilled(cred) + revised = true + elseif !p.first_pass + cred.pass = "" end # first try ssh-agent if credentials support its usage - if p.use_ssh_agent && username_ptr != Cstring(C_NULL) + if p.use_ssh_agent && username_ptr != Cstring(C_NULL) && (!revised || !isfilled(cred)) err = ccall((:git_cred_ssh_key_from_agent, :libgit2), Cint, (Ptr{Ptr{Void}}, Cstring), libgit2credptr, username_ptr) @@ -65,154 +81,171 @@ function authenticate_ssh(libgit2credptr::Ptr{Ptr{Void}}, p::CredentialPayload, err == 0 && return Cint(0) end - username = username_ptr != Cstring(C_NULL) ? unsafe_string(username_ptr) : "" + if p.use_env && (!revised || !isfilled(cred)) + if isempty(cred.user) && username_ptr != Cstring(C_NULL) + cred.user = unsafe_string(username_ptr) + end - privatekey = Base.get(ENV, "SSH_KEY_PATH") do - default = joinpath(homedir(), ".ssh", "id_rsa") - if isempty(creds.prvkey) && isfile(default) - default - else - creds.prvkey + cred.prvkey = Base.get(ENV, "SSH_KEY_PATH") do + default = joinpath(homedir(), ".ssh", "id_rsa") + if isempty(cred.prvkey) && isfile(default) + default + else + cred.prvkey + end end - end - publickey = Base.get(ENV, "SSH_PUB_KEY_PATH") do - default = privatekey * ".pub" - if isempty(creds.pubkey) && isfile(default) - default - else - creds.pubkey + cred.pubkey = Base.get(ENV, "SSH_PUB_KEY_PATH") do + default = cred.prvkey * ".pub" + if isempty(cred.pubkey) && isfile(default) + default + else + cred.pubkey + end end - end - passphrase = Base.get(ENV, "SSH_KEY_PASS", creds.pass) + cred.pass = Base.get(ENV, "SSH_KEY_PASS", cred.pass) - if p.allow_prompt - # if username is not provided or empty, then prompt for it - if isempty(username) - prompt_url = git_url(scheme=p.scheme, host=p.host) - response = Base.prompt("Username for '$prompt_url'", default=creds.user) + revised = true + p.use_env = false + end + + if p.remaining_prompts > 0 && (!revised || !isfilled(cred)) + if isempty(cred.user) || username_ptr == Cstring(C_NULL) + url = git_url(scheme=p.scheme, host=p.host) + response = Base.prompt("Username for '$url'", default=cred.user) isnull(response) && return user_abort() - username = unsafe_get(response) + cred.user = unsafe_get(response) end - prompt_url = git_url(scheme=p.scheme, host=p.host, username=username) + url = git_url(scheme=p.scheme, host=p.host, username=cred.user) # For SSH we need a private key location - if !isfile(privatekey) - response = Base.prompt("Private key location for '$prompt_url'", - default=privatekey) + last_private_key = cred.prvkey + if !isfile(cred.prvkey) || !revised || !haskey(ENV, "SSH_KEY_PATH") + response = Base.prompt("Private key location for '$url'", default=cred.prvkey) isnull(response) && return user_abort() - privatekey = unsafe_get(response) + cred.prvkey = expanduser(unsafe_get(response)) # Only update the public key if the private key changed - if privatekey != creds.prvkey - publickey = privatekey * ".pub" + if cred.prvkey != last_private_key + cred.pubkey = cred.prvkey * ".pub" end end # For SSH we need a public key location. Avoid asking about the public key as # typically this will just annoy users. - if !isfile(publickey) && isfile(privatekey) - response = Base.prompt("Public key location for '$prompt_url'", - default=publickey) + stale = !p.first_pass && cred.prvkey == last_private_key && cred.pubkey != cred.prvkey * ".pub" + if isfile(cred.prvkey) && (stale || !isfile(cred.pubkey)) + response = Base.prompt("Public key location for '$url'", default=cred.pubkey) isnull(response) && return user_abort() - publickey = unsafe_get(response) + cred.pubkey = expanduser(unsafe_get(response)) end - if isempty(passphrase) && is_passphrase_required(privatekey) + # Ask for a passphrase when the private key exists and requires a passphrase + if isempty(cred.pass) && is_passphrase_required(cred.prvkey) if Sys.iswindows() response = Base.winprompt( "Your SSH Key requires a password, please enter it now:", - "Passphrase required", privatekey; prompt_username=false) + "Passphrase required", cred.prvkey; prompt_username=false) isnull(response) && return user_abort() - passphrase = unsafe_get(response)[2] + cred.pass = unsafe_get(response)[2] else - response = Base.prompt("Passphrase for $privatekey", password=true) + response = Base.prompt("Passphrase for $(cred.prvkey)", password=true) isnull(response) && return user_abort() - passphrase = unsafe_get(response) - isempty(passphrase) && return user_abort() # Ambiguous if EOF or newline + cred.pass = unsafe_get(response) + isempty(cred.pass) && return user_abort() # Ambiguous if EOF or newline end end - creds.user = username # save credentials - creds.prvkey = privatekey # save credentials - creds.pubkey = publickey # save credentials - creds.pass = passphrase - elseif !p.first_pass - return Cint(Error.EAUTH) + revised = true + + p.remaining_prompts -= 1 + p.remaining_prompts <= 0 && return prompt_limit() + end + + if !revised + return exhausted_abort() end return ccall((:git_cred_ssh_key_new, :libgit2), Cint, (Ptr{Ptr{Void}}, Cstring, Cstring, Cstring, Cstring), - libgit2credptr, creds.user, creds.pubkey, creds.prvkey, creds.pass) + libgit2credptr, cred.user, cred.pubkey, cred.prvkey, cred.pass) end function authenticate_userpass(libgit2credptr::Ptr{Ptr{Void}}, p::CredentialPayload) - creds = Base.get(p.credential)::UserPasswordCredentials + cred = Base.get(p.credential)::UserPasswordCredentials + revised = false - # Reset password on sucessive calls - if !p.first_pass - creds.pass = "" + # Use a filled credential as-is on the first pass. Reset password on sucessive calls. + if p.first_pass && isfilled(cred) + revised = true + elseif !p.first_pass + cred.pass = "" end - if p.allow_prompt - username = creds.user - userpass = creds.pass - if isempty(username) || isempty(userpass) - prompt_url = git_url(scheme=p.scheme, host=p.host) - if Sys.iswindows() - response = Base.winprompt( - "Please enter your credentials for '$prompt_url'", "Credentials required", - isempty(username) ? p.username : username; prompt_username=true) - isnull(response) && return user_abort() - username, userpass = unsafe_get(response) - else - response = Base.prompt("Username for '$prompt_url'", - default=isempty(username) ? p.username : username) - isnull(response) && return user_abort() - username = unsafe_get(response) + if p.remaining_prompts > 0 && (!revised || !isfilled(cred)) + url = git_url(scheme=p.scheme, host=p.host) + username = isempty(cred.user) ? p.username : cred.user + if Sys.iswindows() + response = Base.winprompt( + "Please enter your credentials for '$url'", "Credentials required", + username; prompt_username=true) + isnull(response) && return user_abort() + cred.user, cred.pass = unsafe_get(response) + else + response = Base.prompt("Username for '$url'", default=username) + isnull(response) && return user_abort() + cred.user = unsafe_get(response) - prompt_url = git_url(scheme=p.scheme, host=p.host, username=username) - response = Base.prompt("Password for '$prompt_url'", password=true) - isnull(response) && return user_abort() - userpass = unsafe_get(response) - isempty(userpass) && return user_abort() # Ambiguous if EOF or newline - end + url = git_url(scheme=p.scheme, host=p.host, username=cred.user) + response = Base.prompt("Password for '$url'", password=true) + isnull(response) && return user_abort() + cred.pass = unsafe_get(response) + isempty(cred.pass) && return user_abort() # Ambiguous if EOF or newline end - creds.user = username # save credentials - creds.pass = userpass # save credentials - elseif !p.first_pass - return Cint(Error.EAUTH) + + revised = true + + p.remaining_prompts -= 1 + p.remaining_prompts <= 0 && return prompt_limit() + end + + if !revised + return exhausted_abort() end return ccall((:git_cred_userpass_plaintext_new, :libgit2), Cint, (Ptr{Ptr{Void}}, Cstring, Cstring), - libgit2credptr, creds.user, creds.pass) + libgit2credptr, cred.user, cred.pass) end -"""Credentials callback function - -Function provides different credential acquisition functionality w.r.t. a connection protocol. -If a payload is provided then `payload_ptr` should contain a `LibGit2.CredentialPayload` object. +""" + credential_callback(...) -> Cint -For `LibGit2.Consts.CREDTYPE_USERPASS_PLAINTEXT` type, if the payload contains fields: -`user` & `pass`, they are used to create authentication credentials. +A LibGit2 credential callback function which provides different credential acquisition +functionality w.r.t. a connection protocol. The `payload_ptr` is required to contain a +`LibGit2.CredentialPayload` object which will keep track of state and settings. -For `LibGit2.Consts.CREDTYPE_SSH_KEY` type, if the payload contains fields: -`user`, `prvkey`, `pubkey` & `pass`, they are used to create authentication credentials. +The `allowed_types` contains a bitmask of `LibGit2.Consts.GIT_CREDTYPE` values specifying +which authentication methods should be attempted. -Typing `^D` (control key together with the `d` key) will abort the credential prompt. +Credential authentication is done in the following order (if supported): +- SSH agent +- SSH private/public key pair +- Username/password plain text -Credentials are checked in the following order (if supported): -- ssh key pair (`ssh-agent` if specified in payload's `use_ssh_agent` field) -- plain text +If a user is presented with a credential prompt they can abort the prompt by typing `^D` +(pressing the control key together with the `d` key). **Note**: Due to the specifics of the `libgit2` authentication procedure, when authentication fails, this function is called again without any indication whether authentication was successful or not. To avoid an infinite loop from repeatedly using the same faulty credentials, we will keep track of state using the payload. + +For addition details see the LibGit2 guide on +[authenticating against a server](https://libgit2.github.com/docs/guides/authentication/). """ function credentials_callback(libgit2credptr::Ptr{Ptr{Void}}, url_ptr::Cstring, username_ptr::Cstring, @@ -232,7 +265,6 @@ function credentials_callback(libgit2credptr::Ptr{Ptr{Void}}, url_ptr::Cstring, p.scheme = m[:scheme] === nothing ? "" : m[:scheme] p.username = m[:user] === nothing ? "" : m[:user] p.host = m[:host] - p.path = m[:path] # When an explicit credential is supplied we will make sure to use the given # credential during the first callback by modifying the allowed types. The diff --git a/base/libgit2/types.jl b/base/libgit2/types.jl index 564b0536cb81b..6a8ade8e76cd3 100644 --- a/base/libgit2/types.jl +++ b/base/libgit2/types.jl @@ -1062,6 +1062,13 @@ import Base.securezero! "Abstract credentials payload" abstract type AbstractCredentials end +""" + isfilled(cred::AbstractCredentials) -> Bool + +Verifies that a credential is ready for use in authentication. +""" +isfilled(::AbstractCredentials) + "Credentials that support only `user` and `password` parameters" mutable struct UserPasswordCredentials <: AbstractCredentials user::String @@ -1093,6 +1100,10 @@ function Base.:(==)(a::UserPasswordCredentials, b::UserPasswordCredentials) a.user == b.user && a.pass == b.pass end +function isfilled(cred::UserPasswordCredentials) + !isempty(cred.user) && !isempty(cred.pass) +end + "SSH credentials type" mutable struct SSHCredentials <: AbstractCredentials user::String @@ -1130,6 +1141,11 @@ function Base.:(==)(a::SSHCredentials, b::SSHCredentials) a.user == b.user && a.pass == b.pass && a.prvkey == b.prvkey && a.pubkey == b.pubkey end +function isfilled(cred::SSHCredentials) + !isempty(cred.user) && isfile(cred.prvkey) && isfile(cred.pubkey) && + (!isempty(cred.pass) || !is_passphrase_required(cred.prvkey)) +end + "Credentials that support caching" struct CachedCredentials <: AbstractCredentials cred::Dict{String,AbstractCredentials} @@ -1177,11 +1193,13 @@ mutable struct CredentialPayload <: Payload credential::Nullable{AbstractCredentials} first_pass::Bool use_ssh_agent::Bool + use_env::Bool + remaining_prompts::Int + url::String scheme::String username::String host::String - path::String function CredentialPayload( credential::Nullable{<:AbstractCredentials}=Nullable{AbstractCredentials}(), @@ -1211,15 +1229,22 @@ function reset!(p::CredentialPayload) p.credential = Nullable{AbstractCredentials}() p.first_pass = true p.use_ssh_agent = p.allow_ssh_agent + p.use_env = true + p.remaining_prompts = p.allow_prompt ? 3 : 0 p.url = "" p.scheme = "" p.username = "" p.host = "" - p.path = "" return p end +""" + approve(payload::CredentialPayload) -> Void + +Store the `payload` credential for re-use in a future authentication. Should only be called +when authentication was successful. +""" function approve(p::CredentialPayload) isnull(p.credential) && return # No credentials were used cred = unsafe_get(p.credential) @@ -1229,6 +1254,12 @@ function approve(p::CredentialPayload) end end +""" + reject(payload::CredentialPayload) -> Void + +Discard the `payload` credential from begin re-used in future authentication. Should only be +called when authentication was unsuccessful. +""" function reject(p::CredentialPayload) isnull(p.credential) && return # No credentials were used cred = unsafe_get(p.credential) diff --git a/deps/libgit2.mk b/deps/libgit2.mk index 1196c0f6d0ac8..5d16aee5063fd 100644 --- a/deps/libgit2.mk +++ b/deps/libgit2.mk @@ -85,6 +85,11 @@ $(LIBGIT2_SRC_PATH)/libgit2-mbedtls-fixup.patch-applied: $(LIBGIT2_SRC_PATH)/sou patch -p1 -f < $(SRCDIR)/patches/libgit2-mbedtls-fixup.patch echo 1 > $@ +$(LIBGIT2_SRC_PATH)/libgit2-ssh-loop.patch-applied: $(LIBGIT2_SRC_PATH)/source-extracted | $(LIBGIT2_SRC_PATH)/libgit2-mbedtls-fixup.patch-applied + cd $(LIBGIT2_SRC_PATH) && \ + patch -p1 -f < $(SRCDIR)/patches/libgit2-ssh-loop.patch + echo 1 > $@ + $(build_datarootdir)/julia/cert.pem: $(CERTFILE) mkdir -p $(build_datarootdir)/julia cp -f $(CERTFILE) $@ @@ -94,7 +99,8 @@ $(BUILDDIR)/$(LIBGIT2_SRC_DIR)/build-configured: \ $(LIBGIT2_SRC_PATH)/libgit2-ssh.patch-applied \ $(LIBGIT2_SRC_PATH)/libgit2-agent-nonfatal.patch-applied \ $(LIBGIT2_SRC_PATH)/libgit2-mbedtls-verify.patch-applied \ - $(LIBGIT2_SRC_PATH)/libgit2-mbedtls-fixup.patch-applied + $(LIBGIT2_SRC_PATH)/libgit2-mbedtls-fixup.patch-applied \ + $(LIBGIT2_SRC_PATH)/libgit2-ssh-loop.patch-applied \ ifneq ($(CERTFILE),) $(BUILDDIR)/$(LIBGIT2_SRC_DIR)/build-configured: $(build_datarootdir)/julia/cert.pem diff --git a/deps/patches/libgit2-ssh-loop.patch b/deps/patches/libgit2-ssh-loop.patch new file mode 100644 index 0000000000000..dfc0ac632c95b --- /dev/null +++ b/deps/patches/libgit2-ssh-loop.patch @@ -0,0 +1,24 @@ +commit eac62497aec204568a494743f829d922787d69c5 +Author: Curtis Vogt +Date: Thu Sep 21 15:51:52 2017 -0500 + + Ask for credentials again when passphrase is wrong + + When trying to decode the private key it looks like LibSSH2 returns a + LIBSSH2_ERROR_PUBLICKEY_UNVERIFIED when the passphrase is incorrect. + +diff --git a/src/transports/ssh.c b/src/transports/ssh.c +index 172ef413c..ec3b0b6ff 100644 +--- a/src/transports/ssh.c ++++ b/src/transports/ssh.c +@@ -420,8 +420,8 @@ static int _git_ssh_authenticate_session( + } + } while (LIBSSH2_ERROR_EAGAIN == rc || LIBSSH2_ERROR_TIMEOUT == rc); + +- if (rc == LIBSSH2_ERROR_PASSWORD_EXPIRED || rc == LIBSSH2_ERROR_AUTHENTICATION_FAILED) +- return GIT_EAUTH; ++ if (rc == LIBSSH2_ERROR_PASSWORD_EXPIRED || rc == LIBSSH2_ERROR_AUTHENTICATION_FAILED || rc == LIBSSH2_ERROR_PUBLICKEY_UNVERIFIED) ++ return GIT_EAUTH; + + if (rc != LIBSSH2_ERROR_NONE) { + if (!giterr_last()) diff --git a/doc/src/devdocs/libgit2.md b/doc/src/devdocs/libgit2.md index f31eed7e6b396..3b6c49065691f 100644 --- a/doc/src/devdocs/libgit2.md +++ b/doc/src/devdocs/libgit2.md @@ -12,9 +12,7 @@ For more information on some of the objects and methods referenced here, consult [libgit2 API reference](https://libgit2.github.com/libgit2/#v0.25.1). ```@docs -Base.LibGit2.AbstractCredentials Base.LibGit2.Buffer -Base.LibGit2.CachedCredentials Base.LibGit2.CheckoutOptions Base.LibGit2.CloneOptions Base.LibGit2.DescribeOptions @@ -49,13 +47,11 @@ Base.LibGit2.PushOptions Base.LibGit2.RebaseOperation Base.LibGit2.RebaseOptions Base.LibGit2.RemoteCallbacks -Base.LibGit2.SSHCredentials Base.LibGit2.SignatureStruct Base.LibGit2.StatusEntry Base.LibGit2.StatusOptions Base.LibGit2.StrArrayStruct Base.LibGit2.TimeStruct -Base.LibGit2.UserPasswordCredentials Base.LibGit2.add! Base.LibGit2.add_fetch! Base.LibGit2.add_push! @@ -92,6 +88,7 @@ Base.LibGit2.features Base.LibGit2.filename Base.LibGit2.filemode Base.LibGit2.gitdir +Base.LibGit2.git_url Base.LibGit2.@githash_str Base.LibGit2.head Base.LibGit2.head! @@ -153,4 +150,12 @@ Base.LibGit2.with Base.LibGit2.with_warn Base.LibGit2.workdir Base.LibGit2.GitObject(::Base.LibGit2.GitTreeEntry) +Base.LibGit2.AbstractCredentials +Base.LibGit2.UserPasswordCredentials +Base.LibGit2.SSHCredentials +Base.LibGit2.isfilled +Base.LibGit2.CachedCredentials +Base.LibGit2.CredentialPayload +Base.LibGit2.approve +Base.LibGit2.reject ``` diff --git a/test/libgit2-online.jl b/test/libgit2-online.jl index 5424ef31bd9dc..39c31a5b1946d 100644 --- a/test/libgit2-online.jl +++ b/test/libgit2-online.jl @@ -43,13 +43,7 @@ mktempdir() do dir error("unexpected") catch ex @test isa(ex, LibGit2.Error.GitError) - if Sys.iswindows() && LibGit2.version() >= v"0.26.0" - # see #22681 and https://github.com/libgit2/libgit2/pull/4055 - @test_broken ex.code == LibGit2.Error.EAUTH - @test ex.code == LibGit2.Error.ERROR - else - @test ex.code == LibGit2.Error.EAUTH - end + @test ex.code == LibGit2.Error.EAUTH end end end diff --git a/test/libgit2.jl b/test/libgit2.jl index ac290a189d516..83149d7bb5327 100644 --- a/test/libgit2.jl +++ b/test/libgit2.jl @@ -7,6 +7,7 @@ const LIBGIT2_MIN_VER = v"0.23.0" const LIBGIT2_HELPER_PATH = joinpath(@__DIR__, "libgit2-helpers.jl") const KEY_DIR = joinpath(@__DIR__, "libgit2") +const HOME = Sys.iswindows() ? "USERPROFILE" : "HOME" # Environment variable name for home function get_global_dir() buf = Ref(LibGit2.Buffer()) @@ -1643,7 +1644,7 @@ mktempdir() do dir @test !haskey(cache, cred_id) - # Reject a credential which wasn't stored + # Attempt to reject a credential which wasn't stored LibGit2.reject(cache, cred, url) @test !haskey(cache, cred_id) @test cred.user == "julia" @@ -1673,14 +1674,18 @@ mktempdir() do dir LibGit2.Error.Callback, LibGit2.Error.EUSER, "Aborting, user cancelled credential request.") + prompt_limit = LibGit2.GitError( + LibGit2.Error.Callback, LibGit2.Error.EAUTH, + "Aborting, maximum number of prompts reached.") + incompatible_error = LibGit2.GitError( LibGit2.Error.Callback, LibGit2.Error.EAUTH, "The explicitly provided credential is incompatible with the requested " * "authentication methods.") - eauth_error = LibGit2.GitError( - LibGit2.Error.None, LibGit2.Error.EAUTH, - "No errors") + exhausted_error = LibGit2.GitError( + LibGit2.Error.Callback, LibGit2.Error.EAUTH, + "All authentication methods have failed.") @testset "SSH credential prompt" begin url = "git@github.com:test/package.jl" @@ -1712,14 +1717,14 @@ mktempdir() do dir # sure a users will actually have these files. Instead we will use the ENV # variables to set the default values. - # Default credentials are valid + # ENV credentials are valid withenv("SSH_KEY_PATH" => valid_key) do err, auth_attempts = challenge_prompt(ssh_ex, []) @test err == git_ok @test auth_attempts == 1 end - # Default credentials are valid but requires a passphrase + # ENV credentials are valid but requires a passphrase withenv("SSH_KEY_PATH" => valid_p_key) do challenges = [ "Passphrase for $valid_p_key:" => "$passphrase\n", @@ -1734,7 +1739,7 @@ mktempdir() do dir # could also just re-call the credential callback like they do for HTTP. challenges = [ "Passphrase for $valid_p_key:" => "foo\n", - # "Private key location for 'git@github.com' [$valid_p_key]:" => "\n", + "Private key location for 'git@github.com' [$valid_p_key]:" => "\n", "Passphrase for $valid_p_key:" => "$passphrase\n", ] err, auth_attempts = challenge_prompt(ssh_p_ex, challenges) @@ -1758,6 +1763,7 @@ mktempdir() do dir @test auth_attempts == 1 end + # ENV credential requiring passphrase withenv("SSH_KEY_PATH" => valid_p_key, "SSH_KEY_PASS" => passphrase) do err, auth_attempts = challenge_prompt(ssh_p_ex, []) @test err == git_ok @@ -1795,6 +1801,7 @@ mktempdir() do dir challenges = [ "Username for 'github.com':" => "foo\n", "Username for 'github.com' [foo]:" => "\n", + "Private key location for 'foo@github.com' [$valid_key]:" => "\n", "Username for 'github.com' [foo]:" => "\x04", # Need to manually abort ] err, auth_attempts = challenge_prompt(ssh_u_ex, challenges) @@ -1802,7 +1809,7 @@ mktempdir() do dir @test auth_attempts == 3 # Credential callback is given an empty string in the `username_ptr` - # instead of the typical C_NULL. + # instead of the C_NULL in the other missing username tests. ssh_user_empty_ex = gen_ex(valid_cred, username="") challenges = [ "Username for 'github.com':" => "$username\n", @@ -1817,7 +1824,7 @@ mktempdir() do dir withenv("SSH_KEY_PATH" => nothing, "SSH_PUB_KEY_PATH" => nothing, "SSH_KEY_PASS" => nothing, - (Sys.iswindows() ? "USERPROFILE" : "HOME") => tempdir()) do + HOME => dir) do # Set the USERPROFILE / HOME above to be a directory that does not contain # the "~/.ssh/id_rsa" file. If this file exists the credential callback @@ -1857,22 +1864,40 @@ mktempdir() do dir err, auth_attempts = challenge_prompt(ssh_ex, challenges) @test err == abort_prompt @test auth_attempts == 2 + + # User provides an invalid private key until prompt limit reached. + # Note: the prompt should not supply an invalid default. + challenges = [ + "Private key location for 'git@github.com':" => "foo\n", + "Private key location for 'git@github.com' [foo]:" => "foo\n", + "Private key location for 'git@github.com' [foo]:" => "foo\n", + ] + err, auth_attempts = challenge_prompt(ssh_ex, challenges) + @test err == prompt_limit + @test auth_attempts == 3 end - # TODO: Tests are currently broken. Credential callback currently infinite loops - # and never prompts user to change private keys. - #= # Explicitly setting these env variables to an existing but invalid key pair # means the user will be given a prompt with that defaults to the given values. - withenv("SSH_KEY_PATH" => invalid_key, "SSH_PUB_KEY_PATH" => invalid_key * ".pub") do + withenv("SSH_KEY_PATH" => invalid_key, + "SSH_PUB_KEY_PATH" => invalid_key * ".pub") do challenges = [ "Private key location for 'git@github.com' [$invalid_key]:" => "$valid_key\n", ] err, auth_attempts = challenge_prompt(ssh_ex, challenges) @test err == git_ok @test auth_attempts == 2 + + # User repeatedly chooses the default invalid private key until prompt limit reached + challenges = [ + "Private key location for 'git@github.com' [$invalid_key]:" => "\n", + "Private key location for 'git@github.com' [$invalid_key]:" => "\n", + "Private key location for 'git@github.com' [$invalid_key]:" => "\n", + ] + err, auth_attempts = challenge_prompt(ssh_ex, challenges) + @test err == prompt_limit + @test auth_attempts == 4 end - =# # Explicitly set the public key ENV variable to a non-existent file. withenv("SSH_KEY_PATH" => valid_key, @@ -1888,9 +1913,6 @@ mktempdir() do dir @test auth_attempts == 1 end - # TODO: Tests are currently broken. Credential callback currently infinite loops - # and never prompts user to change private keys. - #= # Explicitly set the public key ENV variable to a public key that doesn't match # the private key. withenv("SSH_KEY_PATH" => valid_key, @@ -1905,7 +1927,6 @@ mktempdir() do dir @test err == git_ok @test auth_attempts == 2 end - =# end @testset "HTTPS credential prompt" begin @@ -1961,12 +1982,14 @@ mktempdir() do dir challenges = [ "Username for 'https://github.com':" => "foo\n", "Password for 'https://foo@github.com':" => "bar\n", - "Username for 'https://github.com' [foo]:" => "$valid_username\n", - "Password for 'https://$valid_username@github.com':" => "$valid_password\n", + "Username for 'https://github.com' [foo]:" => "foo\n", + "Password for 'https://foo@github.com':" => "bar\n", + "Username for 'https://github.com' [foo]:" => "foo\n", + "Password for 'https://foo@github.com':" => "bar\n", ] err, auth_attempts = challenge_prompt(https_ex, challenges) - @test err == git_ok - @test auth_attempts == 2 + @test err == prompt_limit + @test auth_attempts == 3 end @testset "SSH agent username" begin @@ -1986,17 +2009,123 @@ mktempdir() do dir # An empty string username_ptr ex = gen_ex(username="") err, auth_attempts = challenge_prompt(ex, []) - @test err == eauth_error - @test auth_attempts == 2 + @test err == exhausted_error + @test auth_attempts == 3 # A null username_ptr passed into `git_cred_ssh_key_from_agent` can cause a # segfault. ex = gen_ex(username=nothing) err, auth_attempts = challenge_prompt(ex, []) - @test err == eauth_error + @test err == exhausted_error @test auth_attempts == 2 end + @testset "SSH default" begin + mktempdir() do home_dir + url = "github.com:test/package.jl" + + default_key = joinpath(home_dir, ".ssh", "id_rsa") + mkdir(dirname(default_key)) + + valid_key = joinpath(KEY_DIR, "valid") + valid_cred = LibGit2.SSHCredentials("git", "", valid_key, valid_key * ".pub") + + valid_p_key = joinpath(KEY_DIR, "valid-passphrase") + passphrase = "secret" + valid_p_cred = LibGit2.SSHCredentials("git", passphrase, valid_p_key, valid_p_key * ".pub") + + function gen_ex(cred) + quote + valid_cred = $cred + + default_cred = deepcopy(valid_cred) + default_cred.prvkey = $default_key + default_cred.pubkey = $default_key * ".pub" + + cp(valid_cred.prvkey, default_cred.prvkey) + cp(valid_cred.pubkey, default_cred.pubkey) + + try + include($LIBGIT2_HELPER_PATH) + credential_loop(default_cred, $url, "git") + finally + rm(default_cred.prvkey) + rm(default_cred.pubkey) + end + end + end + + withenv("SSH_KEY_PATH" => nothing, + "SSH_PUB_KEY_PATH" => nothing, + "SSH_KEY_PASS" => nothing, + HOME => home_dir) do + + # Automatically use the default key + ex = gen_ex(valid_cred) + err, auth_attempts = challenge_prompt(ex, []) + @test err == git_ok + @test auth_attempts == 1 + + # Confirm the private key if any other prompting is required + ex = gen_ex(valid_p_cred) + challenges = [ + "Private key location for 'git@github.com' [$default_key]:" => "\n", + "Passphrase for $default_key:" => "$passphrase\n", + ] + err, auth_attempts = challenge_prompt(ex, challenges) + @test err == git_ok + @test auth_attempts == 1 + end + end + end + + @testset "SSH expand tilde" begin + url = "git@github.com:test/package.jl" + + valid_key = joinpath(KEY_DIR, "valid") + valid_cred = LibGit2.SSHCredentials("git", "", valid_key, valid_key * ".pub") + + invalid_key = joinpath(KEY_DIR, "invalid") + + ssh_ex = quote + include($LIBGIT2_HELPER_PATH) + payload = CredentialPayload(allow_ssh_agent=false, allow_prompt=true) + err, auth_attempts = credential_loop($valid_cred, $url, "git", payload) + (err, auth_attempts, payload.credential) + end + + withenv("SSH_KEY_PATH" => nothing, + "SSH_PUB_KEY_PATH" => nothing, + "SSH_KEY_PASS" => nothing, + HOME => KEY_DIR) do + + # Expand tilde during the private key prompt + challenges = [ + "Private key location for 'git@github.com':" => "~/valid\n", + ] + err, auth_attempts, credential = challenge_prompt(ssh_ex, challenges) + @test err == git_ok + @test auth_attempts == 1 + @test get(credential).prvkey == abspath(valid_key) + end + + withenv("SSH_KEY_PATH" => valid_key, + "SSH_PUB_KEY_PATH" => invalid_key * ".pub", + "SSH_KEY_PASS" => nothing, + HOME => KEY_DIR) do + + # Expand tilde during the public key prompt + challenges = [ + "Private key location for 'git@github.com' [$valid_key]:" => "\n", + "Public key location for 'git@github.com' [$invalid_key.pub]:" => "~/valid.pub\n", + ] + err, auth_attempts, credential = challenge_prompt(ssh_ex, challenges) + @test err == git_ok + @test auth_attempts == 2 + @test get(credential).pubkey == abspath(valid_key * ".pub") + end + end + @testset "SSH explicit credentials" begin url = "git@github.com:test/package.jl" username = "git" @@ -2008,25 +2137,27 @@ mktempdir() do dir invalid_key = joinpath(KEY_DIR, "invalid") invalid_cred = LibGit2.SSHCredentials(username, "", invalid_key, invalid_key * ".pub") - function gen_ex(cred; allow_prompt=true) + function gen_ex(cred; allow_prompt=true, allow_ssh_agent=false) quote include($LIBGIT2_HELPER_PATH) - payload = CredentialPayload($cred, allow_ssh_agent=false, allow_prompt=$allow_prompt) + payload = CredentialPayload($cred, allow_ssh_agent=$allow_ssh_agent, + allow_prompt=$allow_prompt) credential_loop($valid_cred, $url, $username, payload) end end - # Explicitly provided credential is correct - ex = gen_ex(valid_cred, allow_prompt=true) + # Explicitly provided credential is correct. Note: allowing prompting and + # SSH agent to ensure they are skipped. + ex = gen_ex(valid_cred, allow_prompt=true, allow_ssh_agent=true) err, auth_attempts = challenge_prompt(ex, []) @test err == git_ok @test auth_attempts == 1 # Explicitly provided credential is incorrect - ex = gen_ex(invalid_cred, allow_prompt=false) + ex = gen_ex(invalid_cred, allow_prompt=false, allow_ssh_agent=false) err, auth_attempts = challenge_prompt(ex, []) - @test err == eauth_error - @test auth_attempts == 2 + @test err == exhausted_error + @test auth_attempts == 3 end @testset "HTTPS explicit credentials" begin @@ -2052,7 +2183,7 @@ mktempdir() do dir # Explicitly provided credential is incorrect ex = gen_ex(invalid_cred, allow_prompt=false) err, auth_attempts = challenge_prompt(ex, []) - @test err == eauth_error + @test err == exhausted_error @test auth_attempts == 2 end @@ -2124,7 +2255,7 @@ mktempdir() do dir # An EAUTH error should remove credentials from the cache ex = gen_ex(cached_cred=invalid_cred, allow_prompt=false) err, auth_attempts, cache = challenge_prompt(ex, []) - @test err == eauth_error + @test err == exhausted_error @test auth_attempts == 2 @test typeof(cache) == LibGit2.CachedCredentials @test cache.cred == Dict() @@ -2217,182 +2348,6 @@ mktempdir() do dir end end - #= temporarily disabled until working on the buildbots, ref https://github.com/JuliaLang/julia/pull/17651#issuecomment-238211150 - @testset "SSH" begin - sshd_command = "" - ssh_repo = joinpath(dir, "Example.SSH") - if !Sys.iswindows() - try - # SSHD needs to be executed by its full absolute path - sshd_command = strip(read(`which sshd`, String)) - catch - warn("Skipping SSH tests (Are `which` and `sshd` installed?)") - end - end - if !isempty(sshd_command) - mktempdir() do fakehomedir - mkdir(joinpath(fakehomedir,".ssh")) - # Unsetting the SSH agent serves two purposes. First, we make - # sure that we don't accidentally pick up an existing agent, - # and second we test that we fall back to using a key file - # if the agent isn't present. - withenv("HOME"=>fakehomedir,"SSH_AUTH_SOCK"=>nothing) do - # Generate user file, first an unencrypted one - wait(spawn(`ssh-keygen -N "" -C juliatest@localhost -f $fakehomedir/.ssh/id_rsa`)) - - # Generate host keys - wait(spawn(`ssh-keygen -f $fakehomedir/ssh_host_rsa_key -N '' -t rsa`)) - wait(spawn(`ssh-keygen -f $fakehomedir/ssh_host_dsa_key -N '' -t dsa`)) - - our_ssh_port = rand(13000:14000) # Chosen arbitrarily - - key_option = "AuthorizedKeysFile $fakehomedir/.ssh/id_rsa.pub" - pidfile_option = "PidFile $fakehomedir/sshd.pid" - sshp = agentp = nothing - logfile = tempname() - ssh_debug = false - function spawn_sshd() - debug_flags = ssh_debug ? `-d -d` : `` - _p = open(logfile, "a") do logfilestream - spawn(pipeline(pipeline(`$sshd_command - -e -f /dev/null $debug_flags - -h $fakehomedir/ssh_host_rsa_key - -h $fakehomedir/ssh_host_dsa_key -p $our_ssh_port - -o $pidfile_option - -o 'Protocol 2' - -o $key_option - -o 'UsePrivilegeSeparation no' - -o 'StrictModes no'`,STDOUT),stderr=logfilestream)) - end - # Give the SSH server 5 seconds to start up - yield(); sleep(5) - _p - end - sshp = spawn_sshd() - - TIOCSCTTY_str = "ccall(:ioctl, Void, (Cint, Cint, Int64), 0, - (Sys.isbsd() || Sys.isapple()) ? 0x20007461 : Sys.islinux() ? 0x540E : - error(\"Fill in TIOCSCTTY for this OS here\"), 0)" - - # To fail rather than hang - function killer_task(p, master) - @async begin - sleep(10) - kill(p) - if isopen(master) - nb_available(master) > 0 && - write(logfile, - readavailable(master)) - close(master) - end - end - end - - try - function try_clone(challenges = []) - cmd = """ - repo = nothing - try - $TIOCSCTTY_str - reponame = "ssh://$(ENV["USER"])@localhost:$our_ssh_port$cache_repo" - repo = LibGit2.clone(reponame, "$ssh_repo") - catch err - open("$logfile","a") do f - println(f,"HOME: ",ENV["HOME"]) - println(f, err) - end - finally - close(repo) - end - """ - # We try to be helpful by desperately looking for - # a way to prompt the password interactively. Pretend - # to be a TTY to suppress those shenanigans. Further, we - # need to detach and change the controlling terminal with - # TIOCSCTTY, since getpass opens the controlling terminal - TestHelpers.with_fake_pty() do slave, master - err = Base.Pipe() - let p = spawn(detach( - `$(Base.julia_cmd()) --startup-file=no -e $cmd`),slave,slave,STDERR) - killer_task(p, master) - for (challenge, response) in challenges - readuntil(master, challenge) - sleep(1) - print(master, response) - end - sleep(2) - wait(p) - close(master) - end - end - @test isfile(joinpath(ssh_repo,"testfile")) - rm(ssh_repo, recursive = true) - end - - # Should use the default files, no interaction required. - try_clone() - ssh_debug && (kill(sshp); sshp = spawn_sshd()) - - # Ok, now encrypt the file and test with that (this also - # makes sure that we don't accidentally fall back to the - # unencrypted version) - wait(spawn(`ssh-keygen -p -N "xxxxx" -f $fakehomedir/.ssh/id_rsa`)) - - # Try with the encrypted file. Needs a password. - try_clone(["Passphrase"=>"xxxxx\r\n"]) - ssh_debug && (kill(sshp); sshp = spawn_sshd()) - - # Move the file. It should now ask for the location and - # then the passphrase - mv("$fakehomedir/.ssh/id_rsa","$fakehomedir/.ssh/id_rsa2") - cp("$fakehomedir/.ssh/id_rsa.pub","$fakehomedir/.ssh/id_rsa2.pub") - try_clone(["location"=>"$fakehomedir/.ssh/id_rsa2\n", - "Passphrase"=>"xxxxx\n"]) - mv("$fakehomedir/.ssh/id_rsa2","$fakehomedir/.ssh/id_rsa") - rm("$fakehomedir/.ssh/id_rsa2.pub") - - # Ok, now start an agent - agent_sock = tempname() - agentp = spawn(`ssh-agent -a $agent_sock -d`) - while stat(agent_sock).mode == 0 # Wait until the agent is started - sleep(1) - end - - # fake pty is required for the same reason as in try_clone - # above - withenv("SSH_AUTH_SOCK" => agent_sock) do - TestHelpers.with_fake_pty() do slave, master - cmd = """ - $TIOCSCTTY_str - run(pipeline(`ssh-add $fakehomedir/.ssh/id_rsa`, - stderr = DevNull)) - """ - addp = spawn(detach(`$(Base.julia_cmd()) --startup-file=no -e $cmd`), - slave, slave, STDERR) - killer_task(addp, master) - sleep(2) - write(master, "xxxxx\n") - wait(addp) - end - - # Should now use the agent - try_clone() - end - catch err - println("SSHD logfile contents follows:") - println(read(logfile, String)) - rethrow(err) - finally - rm(logfile) - sshp !== nothing && kill(sshp) - agentp !== nothing && kill(agentp) - end - end - end - end - end - =# - # Note: Tests only work on linux as SSL_CERT_FILE is only respected on linux systems. @testset "Hostname verification" begin openssl_installed = false