| 1 | package transport |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "net" |
| 6 | |
| 7 | xssh "golang.org/x/crypto/ssh" |
| 8 | "golang.org/x/crypto/ssh/knownhosts" |
| 9 | ) |
| 10 | |
| 11 | // Why this file exists |
| 12 | // |
| 13 | // x/crypto/ssh negotiates the host key algorithm from ClientConfig. |
| 14 | // HostKeyAlgorithms; left empty, the client advertises its own default |
| 15 | // preference and the server picks from that. knownhosts then checks whatever |
| 16 | // key came back against known_hosts — and if the file holds a key for the host |
| 17 | // but not in the negotiated algorithm, the result is a *mismatch*, not a |
| 18 | // fallback to an algorithm we do have. |
| 19 | // |
| 20 | // That is the difference between OpenSSH and a naive Go client. OpenSSH orders |
| 21 | // HostKeyAlgorithms by what known_hosts already has for the host, so it asks for |
| 22 | // the key it can verify. A Go client that skips this connects fine to a host |
| 23 | // whose known_hosts entry happens to match the negotiated algorithm and reports |
| 24 | // a bogus "key mismatch" for one that does not — the same file, the same server, |
| 25 | // verdict decided by which key type was recorded first. |
| 26 | // |
| 27 | // Seen on a fresh Arch host offering ssh-rsa + ecdsa + ed25519 whose known_hosts |
| 28 | // entry was ed25519-only: Go negotiated ecdsa and the dial failed with |
| 29 | // "knownhosts: key mismatch" even though the recorded key was correct and |
| 30 | // unchanged. Other hosts in the same file worked purely because all three of |
| 31 | // their key types had been recorded. |
| 32 | // |
| 33 | // x/crypto/ssh/knownhosts exposes no helper for this (github.com/skeema/knownhosts |
| 34 | // exists precisely to fill the gap), but KeyError.Want carries the known keys for |
| 35 | // the host — so probing the callback with a key the file cannot contain hands |
| 36 | // back exactly the list we need, with no new dependency and no second parser to |
| 37 | // keep in sync with OpenSSH's hashed-hostname format. |
| 38 | |
| 39 | // probeKey is a non-certificate public key of a type no known_hosts file can |
| 40 | // contain, so the knownhosts callback always reports a mismatch and populates |
| 41 | // KeyError.Want with the host's recorded keys. It is never sent to a server. |
| 42 | type probeKey struct{} |
| 43 | |
| 44 | func (probeKey) Type() string { return "legatus-probe" } |
| 45 | func (probeKey) Marshal() []byte { return []byte("legatus-probe") } |
| 46 | func (probeKey) Verify([]byte, *xssh.Signature) error { return errors.New("probe key cannot verify") } |
| 47 | |
| 48 | // hostKeyAlgosFor returns the host key algorithms recorded in known_hosts for |
| 49 | // addr ("host:port"), in the order they appear, or nil when the host is unknown |
| 50 | // (nil leaves ClientConfig.HostKeyAlgorithms empty, i.e. the library default — |
| 51 | // an unknown host must still fail the callback, not fail to negotiate). |
| 52 | func hostKeyAlgosFor(cb xssh.HostKeyCallback, addr string) []string { |
| 53 | if cb == nil { |
| 54 | return nil |
| 55 | } |
| 56 | // A dummy remote: the knownhosts callback prefers the address argument and |
| 57 | // only falls back to remote when it is empty, so this is never consulted. |
| 58 | remote := &net.TCPAddr{IP: net.IPv4zero, Port: 0} |
| 59 | |
| 60 | err := cb(addr, remote, probeKey{}) |
| 61 | var ke *knownhosts.KeyError |
| 62 | if !errors.As(err, &ke) || len(ke.Want) == 0 { |
| 63 | // Unknown host, or a non-knownhosts callback (InsecureIgnoreHostKey, a |
| 64 | // test double). Either way we have nothing to prefer. |
| 65 | return nil |
| 66 | } |
| 67 | |
| 68 | var algos []string |
| 69 | seen := map[string]bool{} |
| 70 | add := func(a string) { |
| 71 | if !seen[a] { |
| 72 | seen[a] = true |
| 73 | algos = append(algos, a) |
| 74 | } |
| 75 | } |
| 76 | for _, k := range ke.Want { |
| 77 | switch t := k.Key.Type(); t { |
| 78 | case xssh.KeyAlgoRSA: |
| 79 | // known_hosts records an RSA key as "ssh-rsa" — that is the key |
| 80 | // format, not the signature algorithm. Modern servers sign with |
| 81 | // rsa-sha2-*, and SHA-1 ssh-rsa is disabled by default on current |
| 82 | // OpenSSH, so advertising only "ssh-rsa" would fail to negotiate |
| 83 | // against exactly the hosts that matter. Offer the SHA-2 variants |
| 84 | // first and keep ssh-rsa last for ancient servers. |
| 85 | add(xssh.KeyAlgoRSASHA512) |
| 86 | add(xssh.KeyAlgoRSASHA256) |
| 87 | add(xssh.KeyAlgoRSA) |
| 88 | default: |
| 89 | add(t) |
| 90 | } |
| 91 | } |
| 92 | return algos |
| 93 | } |
| 94 | |