Skip to content

Commit 1f70c06

Browse files
LibGit2: consult known hosts files to verify SSH server identity (#38580)
* LibGit2: consult known hosts files to verify SSH server identity * SSH host verification: improved error message wording * qualify libssh2 ccalls
1 parent ab94776 commit 1f70c06

File tree

6 files changed

+290
-6
lines changed

6 files changed

+290
-6
lines changed

stdlib/LibGit2/Project.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ name = "LibGit2"
22
uuid = "76f85450-5226-5b5a-8eaa-529ad045b433"
33

44
[deps]
5+
Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
56
NetworkOptions = "ca575930-c2e3-43a9-ace4-1e988b2c1908"
67
Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7"
8+
SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce"
79

810
[extras]
911
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"

stdlib/LibGit2/src/LibGit2.jl

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ module LibGit2
77

88
import Base: ==
99
using Base: something, notnothing
10+
using Base64: base64decode
11+
using NetworkOptions
1012
using Printf: @printf
11-
import NetworkOptions
13+
using SHA: sha1, sha256
1214

1315
export with, GitRepo, GitConfig
1416

stdlib/LibGit2/src/callbacks.jl

Lines changed: 165 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -359,19 +359,179 @@ function fetchhead_foreach_callback(ref_name::Cstring, remote_url::Cstring,
359359
return Cint(0)
360360
end
361361

362+
struct CertHostKey
363+
parent :: Cint
364+
mask :: Cint
365+
md5 :: NTuple{16,UInt8}
366+
sha1 :: NTuple{20,UInt8}
367+
sha256 :: NTuple{32,UInt8}
368+
end
369+
370+
struct KeyHashes
371+
sha1 :: Union{NTuple{20,UInt8}, Nothing}
372+
sha256 :: Union{NTuple{32,UInt8}, Nothing}
373+
end
374+
375+
function KeyHashes(cert_p::Ptr{CertHostKey})
376+
cert = unsafe_load(cert_p)
377+
return KeyHashes(
378+
cert.mask & Consts.CERT_SSH_SHA1 != 0 ? cert.sha1 : nothing,
379+
cert.mask & Consts.CERT_SSH_SHA256 != 0 ? cert.sha256 : nothing,
380+
)
381+
end
382+
383+
function verify_host_error(message::AbstractString)
384+
printstyled(stderr, "$message\n", color = :cyan, bold = true)
385+
end
386+
362387
function certificate_callback(
363-
cert_p :: Ptr{Cvoid},
388+
cert_p :: Ptr{CertHostKey},
364389
valid :: Cint,
365390
host_p :: Ptr{Cchar},
366-
user_p :: Ptr{Cvoid},
391+
data_p :: Ptr{Cvoid},
367392
)::Cint
368393
valid != 0 && return Consts.CERT_ACCEPT
369394
host = unsafe_string(host_p)
370395
cert_type = unsafe_load(convert(Ptr{Cint}, cert_p))
371396
transport = cert_type == Consts.CERT_TYPE_TLS ? "TLS" :
372397
cert_type == Consts.CERT_TYPE_SSH ? "SSH" : nothing
373-
verify = NetworkOptions.verify_host(host, transport)
374-
verify ? Consts.PASSTHROUGH : Consts.CERT_ACCEPT
398+
if !NetworkOptions.verify_host(host, transport)
399+
# user has opted out of host verification
400+
return Consts.CERT_ACCEPT
401+
end
402+
if transport == "TLS"
403+
# TLS verification is done before the callback and indicated with the
404+
# incoming `valid` flag, so if we get here then host verification failed
405+
verify_host_error("TLS host verification: the identity of the server `$host` could not be verified. Someone could be trying to man-in-the-middle your connection. It is also possible that the correct server is using an invalid certificate or that your system's certificate authority root store is misconfigured.")
406+
return Consts.CERT_REJECT
407+
elseif transport == "SSH"
408+
# SSH verification has to be done here
409+
files = [joinpath(homedir(), ".ssh", "known_hosts")]
410+
check = ssh_knownhost_check(files, host, KeyHashes(cert_p))
411+
valid = false
412+
if check == Consts.SSH_HOST_KNOWN
413+
valid = true
414+
elseif check == Consts.SSH_HOST_UNKNOWN
415+
if Sys.which("ssh-keyscan") !== nothing
416+
msg = "Please run `ssh-keyscan $host >> $(files[1])` in order to add the server to your known hosts file and the try again."
417+
else
418+
msg = "Please connect once using `ssh $host` in order to add the server to your known hosts file and then try again. You may not be allowed to log in (wrong user and/or no login allowed), but ssh will prompt you to add a host key for the server which will allow libgit2 to verify the server."
419+
end
420+
verify_host_error("SSH host verification: the server `$host` is not a known host. $msg")
421+
elseif check == Consts.SSH_HOST_MISMATCH
422+
verify_host_error("SSH host verification: the identity of the server `$host` does not match its known hosts record. Someone could be trying to man-in-the-middle your connection. It is also possible that the server has changed its key, in which case you should check with the server administrator and if they confirm that the key has been changed, update your known hosts file.")
423+
elseif check == Consts.SSH_HOST_BAD_HASH
424+
verify_host_error("SSH host verification: no secure certificate hash available for `$host`, cannot verify server identity.")
425+
else
426+
@error("unexpected SSH known host check result", check)
427+
end
428+
return valid ? Consts.CERT_ACCEPT : Consts.CERT_REJECT
429+
end
430+
@error("unexpected transport encountered, refusing to validate", cert_type)
431+
return Consts.CERT_REJECT
432+
end
433+
434+
## SSH known host checking
435+
#
436+
# We can't use libssh2_knownhost_check because libgit2, for no good reason,
437+
# doesn't give us a host fingerprint that we can use for that and instead gives
438+
# us multiple hashes of that fingerprint instead. Moreover, since a host can
439+
# have multiple fingerprints in the known hosts file with different encryption
440+
# types (gitlab.com does this, for example), we need to iterate through all the
441+
# known hosts entries and manually check if any of them is a match.
442+
#
443+
# The fact that libgit2 won't give us a fingerprint also means that we cannot,
444+
# even if we wanted to, prompt the user for whether to add the fingerprint to
445+
# the known hosts file, since we don't have the fingerprint that should be
446+
# added. The only option is to instruct the user how to add it themselves.
447+
#
448+
# Check logic: if a host appears in a known hosts file at all then one of the
449+
# keys in that file must match or we declare a mismatch; if the host name
450+
# doesn't appear in the file at all, however, we will continue searching files.
451+
#
452+
# This allows adding a host to the system known hosts file to fully override
453+
# that host appearing in a bundled known hosts file. It is necessary to allow
454+
# any of multiple entries in a single file to match, however, to allow for the
455+
# possiblity that the file contains multiple fingerprints for the same host. If
456+
# libgit2 gave us the fucking fingerprint then we could search for only an entry
457+
# with the correct type, but we can't do that without the actual fingerprint.
458+
459+
struct KnownHost
460+
magic :: Cuint
461+
node :: Ptr{Cvoid}
462+
name :: Ptr{Cchar}
463+
key :: Ptr{Cchar}
464+
type :: Cint
465+
end
466+
467+
function ssh_knownhost_check(
468+
files :: AbstractVector{<:AbstractString},
469+
host :: AbstractString,
470+
hashes :: KeyHashes,
471+
)
472+
hashes.sha1 === hashes.sha256 === nothing &&
473+
return Consts.SSH_HOST_BAD_HASH
474+
session = @ccall "libssh2".libssh2_session_init_ex(
475+
C_NULL :: Ptr{Cvoid},
476+
C_NULL :: Ptr{Cvoid},
477+
C_NULL :: Ptr{Cvoid},
478+
C_NULL :: Ptr{Cvoid},
479+
) :: Ptr{Cvoid}
480+
for file in files
481+
ispath(file) || continue
482+
hosts = @ccall "libssh2".libssh2_knownhost_init(
483+
session :: Ptr{Cvoid},
484+
) :: Ptr{Cvoid}
485+
count = @ccall "libssh2".libssh2_knownhost_readfile(
486+
hosts :: Ptr{Cvoid},
487+
file :: Cstring,
488+
1 :: Cint, # standard OpenSSH format
489+
) :: Cint
490+
if count < 0
491+
@warn("Error parsing SSH known hosts file `$file`")
492+
@ccall "libssh2".libssh2_knownhost_free(hosts::Ptr{Cvoid})::Cvoid
493+
continue
494+
end
495+
name_match = false
496+
prev = Ptr{KnownHost}(0)
497+
store = Ref{Ptr{KnownHost}}()
498+
while true
499+
get = @ccall "libssh2".libssh2_knownhost_get(
500+
hosts :: Ptr{Cvoid},
501+
store :: Ptr{Ptr{KnownHost}},
502+
prev :: Ptr{KnownHost},
503+
) :: Cint
504+
get < 0 && @warn("Error searching SSH known hosts file `$file`")
505+
get == 0 || break # end of file or error
506+
# got a known hosts record for host, now check its key hash
507+
prev = store[]
508+
known_host = unsafe_load(prev)
509+
known_host.name == C_NULL && continue
510+
host == unsafe_string(known_host.name) || continue
511+
name_match = true # we've found some entry in this file
512+
key_match = true # all available hashes must match
513+
key = base64decode(unsafe_string(known_host.key))
514+
if hashes.sha1 !== nothing
515+
key_match &= sha1(key) == collect(hashes.sha1)
516+
end
517+
if hashes.sha256 !== nothing
518+
key_match &= sha256(key) == collect(hashes.sha256)
519+
end
520+
key_match || continue
521+
# name and key match found
522+
@ccall "libssh2".libssh2_knownhost_free(hosts::Ptr{Cvoid})::Cvoid
523+
@assert 0 == @ccall "libssh2".libssh2_session_free(session::Ptr{Cvoid})::Cint
524+
return Consts.SSH_HOST_KNOWN
525+
end
526+
@ccall "libssh2".libssh2_knownhost_free(hosts::Ptr{Cvoid})::Cvoid
527+
name_match || continue # no name match, search more files
528+
# name match but no key match => host mismatch
529+
@assert 0 == @ccall "libssh2".libssh2_session_free(session::Ptr{Cvoid})::Cint
530+
return Consts.SSH_HOST_MISMATCH
531+
end
532+
# name not found in any known hosts files
533+
@assert 0 == @ccall "libssh2".libssh2_session_free(session::Ptr{Cvoid})::Cint
534+
return Consts.SSH_HOST_UNKNOWN
375535
end
376536

377537
"C function pointer for `mirror_callback`"
@@ -381,4 +541,4 @@ credentials_cb() = @cfunction(credentials_callback, Cint, (Ptr{Ptr{Cvoid}}, Cstr
381541
"C function pointer for `fetchhead_foreach_callback`"
382542
fetchhead_foreach_cb() = @cfunction(fetchhead_foreach_callback, Cint, (Cstring, Cstring, Ptr{GitHash}, Cuint, Any))
383543
"C function pointer for `certificate_callback`"
384-
certificate_cb() = @cfunction(certificate_callback, Cint, (Ptr{Cvoid}, Cint, Ptr{Cchar}, Ptr{Cvoid}))
544+
certificate_cb() = @cfunction(certificate_callback, Cint, (Ptr{CertHostKey}, Cint, Ptr{Cchar}, Ptr{Cvoid}))

stdlib/LibGit2/src/consts.jl

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,25 @@ const PASSTHROUGH = -30
317317
const CERT_REJECT = -1
318318
const CERT_ACCEPT = 0
319319

320+
# certificate hash flags
321+
const CERT_SSH_MD5 = 1 << 0
322+
const CERT_SSH_SHA1 = 1 << 1
323+
const CERT_SSH_SHA256 = 1 << 2
324+
325+
# libssh2 known host constants
326+
const LIBSSH2_KNOWNHOST_TYPE_PLAIN = 1
327+
const LIBSSH2_KNOWNHOST_TYPE_SHA1 = 2
328+
const LIBSSH2_KNOWNHOST_TYPE_CUSTOM = 3
329+
330+
const LIBSSH2_KNOWNHOST_KEYENC_RAW = 1 << 16
331+
const LIBSSH2_KNOWNHOST_KEYENC_BASE64 = 2 << 16
332+
333+
# internal constants for SSH host verification outcomes
334+
const SSH_HOST_KNOWN = 0
335+
const SSH_HOST_UNKNOWN = 1
336+
const SSH_HOST_MISMATCH = 2
337+
const SSH_HOST_BAD_HASH = 3
338+
320339
@enum(GIT_SUBMODULE_IGNORE, SUBMODULE_IGNORE_UNSPECIFIED = -1, # use the submodule's configuration
321340
SUBMODULE_IGNORE_NONE = 1, # any change or untracked == dirty
322341
SUBMODULE_IGNORE_UNTRACKED = 2, # dirty if tracked files change

stdlib/LibGit2/test/known_hosts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==
2+
gitlab.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9
3+
gitlab.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3Hw9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY=
4+
gitlab.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf

stdlib/LibGit2/test/libgit2.jl

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2390,6 +2390,103 @@ mktempdir() do dir
23902390
Base.shred!(valid_p_cred)
23912391
end
23922392

2393+
@testset "SSH known host checking" begin
2394+
key_hashes(sha1::String, sha256::String) = LibGit2.KeyHashes(
2395+
Tuple(hex2bytes(sha1)),
2396+
Tuple(hex2bytes(sha256)),
2397+
)
2398+
# randomly generated hashes matching no hosts
2399+
random_key_hashes = key_hashes(
2400+
"a9971372d02a67bdfea82e2b4808b4cf478b49c0",
2401+
"45aac5c20d5c7f8b998fee12fa9b75086c0d3ed6e33063f7ce940409ff4efbbc"
2402+
)
2403+
# hashes of the unique github.com fingerprint
2404+
github_key_hashes = key_hashes(
2405+
"bf6b6825d2977c511a475bbefb88aad54a92ac73",
2406+
"9d385b83a9175292561a5ec4d4818e0aca51a264f17420112ef88ac3a139498f"
2407+
)
2408+
# hashes of the middle github.com fingerprint
2409+
gitlab_key_hashes = key_hashes(
2410+
"4db6b9ab0209fcde106cbf0fc4560ad063a962ad",
2411+
"1db5b783ccd48cd4a4b056ea4e25163d683606ad71f3174652b9625c5cd29d4c"
2412+
)
2413+
2414+
# various key hash collections
2415+
partial_hashes(keys::LibGit2.KeyHashes) = [ keys,
2416+
LibGit2.KeyHashes(keys.sha1, nothing),
2417+
LibGit2.KeyHashes(nothing, keys.sha256),
2418+
]
2419+
bad_hashes = LibGit2.KeyHashes(nothing, nothing)
2420+
random_hashes = partial_hashes(random_key_hashes)
2421+
github_hashes = partial_hashes(github_key_hashes)
2422+
gitlab_hashes = partial_hashes(gitlab_key_hashes)
2423+
2424+
# various known hosts files
2425+
no_file = tempname()
2426+
empty_file = tempname(); touch(empty_file)
2427+
known_hosts = joinpath(@__DIR__, "known_hosts")
2428+
wrong_hosts = tempname()
2429+
open(wrong_hosts, write=true) do io
2430+
for line in eachline(known_hosts)
2431+
words = split(line)
2432+
words[1] = words[1] == "github.com" ? "gitlab.com" :
2433+
words[1] == "gitlab.com" ? "github.com" :
2434+
words[1]
2435+
println(io, join(words, " "))
2436+
end
2437+
end
2438+
2439+
@testset "bad hash errors" begin
2440+
hash = bad_hashes
2441+
for host in ["github.com", "gitlab.com", "unknown.host"],
2442+
files in [[no_file], [empty_file], [known_hosts]]
2443+
check = LibGit2.ssh_knownhost_check(files, host, hash)
2444+
@test check == LibGit2.Consts.SSH_HOST_BAD_HASH
2445+
end
2446+
end
2447+
2448+
@testset "unknown hosts" begin
2449+
host = "unknown.host"
2450+
for hash in [github_hashes; gitlab_hashes; random_hashes],
2451+
files in [[no_file], [empty_file], [known_hosts]]
2452+
check = LibGit2.ssh_knownhost_check(files, host, hash)
2453+
@test check == LibGit2.Consts.SSH_HOST_UNKNOWN
2454+
end
2455+
end
2456+
2457+
@testset "known hosts" begin
2458+
for (host, hashes) in [
2459+
"github.com" => github_hashes,
2460+
"gitlab.com" => gitlab_hashes,
2461+
], hash in hashes
2462+
for files in [[no_file], [empty_file]]
2463+
check = LibGit2.ssh_knownhost_check(files, host, hash)
2464+
@test check == LibGit2.Consts.SSH_HOST_UNKNOWN
2465+
end
2466+
for files in [
2467+
[known_hosts],
2468+
[empty_file; known_hosts],
2469+
[known_hosts; empty_file],
2470+
[known_hosts; wrong_hosts],
2471+
]
2472+
check = LibGit2.ssh_knownhost_check(files, host, hash)
2473+
@test check == LibGit2.Consts.SSH_HOST_KNOWN
2474+
end
2475+
for files in [
2476+
[wrong_hosts],
2477+
[empty_file; wrong_hosts],
2478+
[wrong_hosts; empty_file],
2479+
[wrong_hosts; known_hosts],
2480+
]
2481+
check = LibGit2.ssh_knownhost_check(files, host, hash)
2482+
@test check == LibGit2.Consts.SSH_HOST_MISMATCH
2483+
end
2484+
end
2485+
end
2486+
2487+
rm(empty_file)
2488+
end
2489+
23932490
@testset "HTTPS credential prompt" begin
23942491
url = "https://github.com/test/package.jl"
23952492

0 commit comments

Comments
 (0)