diff --git a/cmd/upterm/command/host.go b/cmd/upterm/command/host.go index 1d6d948d1..60e4ae471 100644 --- a/cmd/upterm/command/host.go +++ b/cmd/upterm/command/host.go @@ -22,11 +22,12 @@ import ( ) var ( - flagServer string - flagForceCommand string - flagPrivateKeys []string - flagAuthorizedKeys string - flagReadOnly bool + flagServer string + flagForceCommand string + flagPrivateKeys []string + flagKnownHostsFilename string + flagAuthorizedKeys string + flagReadOnly bool ) func hostCmd() *cobra.Command { @@ -55,9 +56,15 @@ func hostCmd() *cobra.Command { RunE: shareRunE, } + homeDir, err := os.UserHomeDir() + if err != nil { + log.Fatal(err) + } + cmd.PersistentFlags().StringVarP(&flagServer, "server", "", "ssh://uptermd.upterm.dev:22", "upterm server address (required), supported protocols are shh, ws, or wss.") cmd.PersistentFlags().StringVarP(&flagForceCommand, "force-command", "f", "", "force execution of a command and attach its input/output to client's.") - cmd.PersistentFlags().StringSliceVarP(&flagPrivateKeys, "private-key", "i", nil, "private key for public key authentication against the upterm server (required).") + cmd.PersistentFlags().StringSliceVarP(&flagPrivateKeys, "private-key", "i", defaultPrivateKeys(homeDir), "private key for public key authentication against the upterm server (required).") + cmd.PersistentFlags().StringVarP(&flagKnownHostsFilename, "known-hosts", "", defaultKnownHost(homeDir), "a file contains the known keys for remote hosts (required).") cmd.PersistentFlags().StringVarP(&flagAuthorizedKeys, "authorized-key", "a", "", "an authorized_keys file that lists public keys that are permitted to connect.") cmd.PersistentFlags().BoolVarP(&flagReadOnly, "read-only", "r", false, "host a read-only session. Clients won't be able to interact.") @@ -88,15 +95,11 @@ func validateShareRequiredFlags(c *cobra.Command, args []string) error { } if len(flagPrivateKeys) == 0 { - homeDir, err := os.UserHomeDir() - if err != nil { - return err - } + result = multierror.Append(result, fmt.Errorf("missing flag --private-key")) + } - flagPrivateKeys = defaultPrivateKeys(homeDir) - if len(flagPrivateKeys) == 0 { - result = multierror.Append(result, fmt.Errorf("missing flag --private-key")) - } + if flagKnownHostsFilename == "" { + result = multierror.Append(result, fmt.Errorf("missing flag --known-hosts")) } return result @@ -138,16 +141,25 @@ func shareRunE(c *cobra.Command, args []string) error { if cleanup != nil { defer cleanup() } + + hkcb, err := host.NewPromptingHostKeyCallback(os.Stdin, os.Stdout, flagKnownHostsFilename) + if err != nil { + return err + } + h := &host.Host{ Host: flagServer, Command: args, ForceCommand: forceCommand, Signers: signers, + HostKeyCallback: hkcb, AuthorizedKeys: authorizedKeys, KeepAliveDuration: 50 * time.Second, // nlb is 350 sec & heroku router is 55 sec SessionCreatedCallback: displaySessionCallback, ClientJoinedCallback: clientJoinedCallback, ClientLeftCallback: clientLeftCallback, + Stdin: os.Stdin, + Stdout: os.Stdout, Logger: log.New(), ReadOnly: flagReadOnly, } @@ -208,3 +220,7 @@ func defaultPrivateKeys(homeDir string) []string { return pks } + +func defaultKnownHost(homeDir string) string { + return filepath.Join(homeDir, ".ssh", "known_hosts") +} diff --git a/ftests/ftests_test.go b/ftests/ftests_test.go index 24f882856..e14ead2df 100644 --- a/ftests/ftests_test.go +++ b/ftests/ftests_test.go @@ -290,6 +290,7 @@ func (c *Host) Share(url string) error { ClientLeftCallback: c.ClientLeftCallback, KeepAliveDuration: 10 * time.Second, Logger: logger, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), Stdin: stdinr, Stdout: stdoutw, ReadOnly: c.ReadOnly, diff --git a/host/host.go b/host/host.go index bf257567f..9cad584bf 100644 --- a/host/host.go +++ b/host/host.go @@ -1,11 +1,15 @@ package host import ( + "bufio" "context" "fmt" + "io" + "net" "net/url" "os" "path/filepath" + "strings" "time" "github.com/jingweno/upterm/host/api" @@ -17,14 +21,118 @@ import ( "github.com/olebedev/emitter" log "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" ) +func NewPromptingHostKeyCallback(stdin io.Reader, stdout io.Writer, knownHostsFilename string) (ssh.HostKeyCallback, error) { + cb, err := knownhosts.New(knownHostsFilename) + if err != nil { + return nil, err + } + + hkcb := hostKeyCallback{ + stdin: stdin, + stdout: stdout, + file: knownHostsFilename, + HostKeyCallback: cb, + } + + return hkcb.checkHostKey, nil +} + +const ( + errKeyMismatch = ` +@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ +@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @ +@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ +IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY! +Someone could be eavesdropping on you right now (man-in-the-middle attack)! +It is also possible that a host key has just been changed. +The fingerprint for the %s key sent by the remote host is +%s. +Please contact your system administrator. +Add correct host key in %s to get rid of this message. +Offending %s key in %s:%d` +) + +type hostKeyCallback struct { + stdin io.Reader + stdout io.Writer + file string + ssh.HostKeyCallback +} + +func (cb hostKeyCallback) checkHostKey(hostname string, remote net.Addr, key ssh.PublicKey) error { + if err := cb.HostKeyCallback(hostname, remote, key); err != nil { + kerr, ok := err.(*knownhosts.KeyError) + if !ok { + return err + } + + // If keer.Want is non-empty, there was a mismatch, which can signify a MITM attack + if len(kerr.Want) != 0 { + kk := kerr.Want[0] // TODO: take care of multiple key mismatches + fp := utils.FingerprintSHA256(kk.Key) + kt := keyType(kk.Key.Type()) + return fmt.Errorf(errKeyMismatch, kt, fp, kk.Filename, kt, kk.Filename, kk.Line) + } + + return cb.promptForConfirmation(hostname, remote, key) + + } + + return nil +} + +func (cb hostKeyCallback) promptForConfirmation(hostname string, remote net.Addr, key ssh.PublicKey) error { + fp := utils.FingerprintSHA256(key) + fmt.Fprintf(cb.stdout, "The authenticity of host '%s (%s)' can't be established.\n", knownhosts.Normalize(hostname), knownhosts.Normalize(remote.String())) + fmt.Fprintf(cb.stdout, "%s key fingerprint is %s.\n", keyType(key.Type()), fp) + fmt.Fprintf(cb.stdout, "Are you sure you want to continue connecting (yes/no/[fingerprint])? ") + + reader := bufio.NewReader(cb.stdin) + for { + confirm, err := reader.ReadString('\n') + if err != nil { + return err + } + + confirm = strings.TrimSpace(confirm) + + if confirm == "yes" || confirm == fp { + return cb.appendHostLine(hostname, key) + } + + if confirm == "no" { + return fmt.Errorf("Host key verification failed.") + } + + fmt.Fprintf(cb.stdout, "Please type 'yes', 'no' or the fingerprint: ") + } +} + +func (cb hostKeyCallback) appendHostLine(hostname string, key ssh.PublicKey) error { + f, err := os.OpenFile(cb.file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + + line := knownhosts.Line([]string{hostname}, key) + if _, err := f.WriteString(line + "\n"); err != nil { + return err + } + + return nil +} + type Host struct { Host string KeepAliveDuration time.Duration Command []string ForceCommand []string Signers []ssh.Signer + HostKeyCallback ssh.HostKeyCallback AuthorizedKeys []ssh.PublicKey AdminSocketFile string SessionCreatedCallback func(*models.APIGetSessionResponse) error @@ -66,6 +174,7 @@ func (c *Host) Run(ctx context.Context) error { rt := internal.ReverseTunnel{ Host: u, Signers: c.Signers, + HostKeyCallback: c.HostKeyCallback, AuthorizedKeys: c.AuthorizedKeys, KeepAliveDuration: c.KeepAliveDuration, Logger: log.WithField("com", "reverse-tunnel"), @@ -186,3 +295,7 @@ func (c *Host) Run(ctx context.Context) error { return g.Run() } + +func keyType(t string) string { + return strings.ToUpper(strings.TrimPrefix(t, "ssh-")) +} diff --git a/host/host_test.go b/host/host_test.go new file mode 100644 index 000000000..b97306a5b --- /dev/null +++ b/host/host_test.go @@ -0,0 +1,69 @@ +package host + +import ( + "bytes" + "io/ioutil" + "net" + "os" + "strings" + "testing" + + "github.com/jingweno/upterm/utils" + "golang.org/x/crypto/ssh" +) + +const ( + testPublicKey = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN0EWrjdcHcuMfI8bGAyHPcGsAc/vd/gl5673pRkRBGY` +) + +func Test_hostKeyCallback(t *testing.T) { + tmpfile, err := ioutil.TempFile("", "known_hosts") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpfile.Name()) + + if _, err := tmpfile.Write([]byte("[127.0.0.1]:23 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKpVcpc3t5GZHQFlbSLyj6sQY4wWLjNZsLTkfo9Cdjit\n")); err != nil { + t.Fatal(err) + } + tmpfile.Close() + + stdin := bytes.NewBufferString("yes\n") // Simulate typing "yes" in stdin + stdout := bytes.NewBuffer(nil) + + pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(testPublicKey)) + if err != nil { + t.Fatal(err) + } + fp := utils.FingerprintSHA256(pk) + + cb, err := NewPromptingHostKeyCallback(stdin, stdout, tmpfile.Name()) + if err != nil { + t.Fatal(err) + } + + // 127.0.0.1:22 is not in known_hosts + addr := &net.TCPAddr{ + IP: net.IPv4(127, 0, 0, 1), + Port: 22, + } + if err := cb("127.0.0.1:22", addr, pk); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "ED25519 key fingerprint is "+fp) { + t.Fatalf("stdout should contain fingerprint %s: %s", fp, stdout) + } + + // 127.0.0.1:23 is in known_hosts + addr = &net.TCPAddr{ + IP: net.IPv4(127, 0, 0, 1), + Port: 23, + } + err = cb("127.0.0.1:23", addr, pk) + if err == nil { + t.Fatalf("key mismatched error is expected") + } + if !strings.Contains(err.Error(), "Offending ED25519 key in "+tmpfile.Name()) { + t.Fatalf("unexpected error message: %s", err.Error()) + } +} diff --git a/host/internal/reversetunnel.go b/host/internal/reversetunnel.go index 9e66373fa..19af3a978 100644 --- a/host/internal/reversetunnel.go +++ b/host/internal/reversetunnel.go @@ -29,6 +29,7 @@ type ReverseTunnel struct { Signers []ssh.Signer AuthorizedKeys []ssh.PublicKey KeepAliveDuration time.Duration + HostKeyCallback ssh.HostKeyCallback Logger log.FieldLogger ln net.Listener @@ -72,10 +73,18 @@ func (c *ReverseTunnel) Establish(ctx context.Context) (*server.CreateSessionRes } config := &ssh.ClientConfig{ - User: encodedID, - Auth: auths, - ClientVersion: upterm.HostSSHClientVersion, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), + User: encodedID, + Auth: auths, + ClientVersion: upterm.HostSSHClientVersion, + // Enforce a restricted set of algorithms for security + // TODO: make this configurable if necessary + HostKeyAlgorithms: []string{ + ssh.CertAlgoRSAv01, + ssh.CertAlgoED25519v01, + ssh.KeyAlgoED25519, + ssh.KeyAlgoRSA, + }, + HostKeyCallback: c.HostKeyCallback, } if isWSScheme(c.Host.Scheme) { diff --git a/vendor/golang.org/x/crypto/ssh/knownhosts/knownhosts.go b/vendor/golang.org/x/crypto/ssh/knownhosts/knownhosts.go new file mode 100644 index 000000000..260cfe58c --- /dev/null +++ b/vendor/golang.org/x/crypto/ssh/knownhosts/knownhosts.go @@ -0,0 +1,540 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package knownhosts implements a parser for the OpenSSH known_hosts +// host key database, and provides utility functions for writing +// OpenSSH compliant known_hosts files. +package knownhosts + +import ( + "bufio" + "bytes" + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "os" + "strings" + + "golang.org/x/crypto/ssh" +) + +// See the sshd manpage +// (http://man.openbsd.org/sshd#SSH_KNOWN_HOSTS_FILE_FORMAT) for +// background. + +type addr struct{ host, port string } + +func (a *addr) String() string { + h := a.host + if strings.Contains(h, ":") { + h = "[" + h + "]" + } + return h + ":" + a.port +} + +type matcher interface { + match(addr) bool +} + +type hostPattern struct { + negate bool + addr addr +} + +func (p *hostPattern) String() string { + n := "" + if p.negate { + n = "!" + } + + return n + p.addr.String() +} + +type hostPatterns []hostPattern + +func (ps hostPatterns) match(a addr) bool { + matched := false + for _, p := range ps { + if !p.match(a) { + continue + } + if p.negate { + return false + } + matched = true + } + return matched +} + +// See +// https://android.googlesource.com/platform/external/openssh/+/ab28f5495c85297e7a597c1ba62e996416da7c7e/addrmatch.c +// The matching of * has no regard for separators, unlike filesystem globs +func wildcardMatch(pat []byte, str []byte) bool { + for { + if len(pat) == 0 { + return len(str) == 0 + } + if len(str) == 0 { + return false + } + + if pat[0] == '*' { + if len(pat) == 1 { + return true + } + + for j := range str { + if wildcardMatch(pat[1:], str[j:]) { + return true + } + } + return false + } + + if pat[0] == '?' || pat[0] == str[0] { + pat = pat[1:] + str = str[1:] + } else { + return false + } + } +} + +func (p *hostPattern) match(a addr) bool { + return wildcardMatch([]byte(p.addr.host), []byte(a.host)) && p.addr.port == a.port +} + +type keyDBLine struct { + cert bool + matcher matcher + knownKey KnownKey +} + +func serialize(k ssh.PublicKey) string { + return k.Type() + " " + base64.StdEncoding.EncodeToString(k.Marshal()) +} + +func (l *keyDBLine) match(a addr) bool { + return l.matcher.match(a) +} + +type hostKeyDB struct { + // Serialized version of revoked keys + revoked map[string]*KnownKey + lines []keyDBLine +} + +func newHostKeyDB() *hostKeyDB { + db := &hostKeyDB{ + revoked: make(map[string]*KnownKey), + } + + return db +} + +func keyEq(a, b ssh.PublicKey) bool { + return bytes.Equal(a.Marshal(), b.Marshal()) +} + +// IsAuthorityForHost can be used as a callback in ssh.CertChecker +func (db *hostKeyDB) IsHostAuthority(remote ssh.PublicKey, address string) bool { + h, p, err := net.SplitHostPort(address) + if err != nil { + return false + } + a := addr{host: h, port: p} + + for _, l := range db.lines { + if l.cert && keyEq(l.knownKey.Key, remote) && l.match(a) { + return true + } + } + return false +} + +// IsRevoked can be used as a callback in ssh.CertChecker +func (db *hostKeyDB) IsRevoked(key *ssh.Certificate) bool { + _, ok := db.revoked[string(key.Marshal())] + return ok +} + +const markerCert = "@cert-authority" +const markerRevoked = "@revoked" + +func nextWord(line []byte) (string, []byte) { + i := bytes.IndexAny(line, "\t ") + if i == -1 { + return string(line), nil + } + + return string(line[:i]), bytes.TrimSpace(line[i:]) +} + +func parseLine(line []byte) (marker, host string, key ssh.PublicKey, err error) { + if w, next := nextWord(line); w == markerCert || w == markerRevoked { + marker = w + line = next + } + + host, line = nextWord(line) + if len(line) == 0 { + return "", "", nil, errors.New("knownhosts: missing host pattern") + } + + // ignore the keytype as it's in the key blob anyway. + _, line = nextWord(line) + if len(line) == 0 { + return "", "", nil, errors.New("knownhosts: missing key type pattern") + } + + keyBlob, _ := nextWord(line) + + keyBytes, err := base64.StdEncoding.DecodeString(keyBlob) + if err != nil { + return "", "", nil, err + } + key, err = ssh.ParsePublicKey(keyBytes) + if err != nil { + return "", "", nil, err + } + + return marker, host, key, nil +} + +func (db *hostKeyDB) parseLine(line []byte, filename string, linenum int) error { + marker, pattern, key, err := parseLine(line) + if err != nil { + return err + } + + if marker == markerRevoked { + db.revoked[string(key.Marshal())] = &KnownKey{ + Key: key, + Filename: filename, + Line: linenum, + } + + return nil + } + + entry := keyDBLine{ + cert: marker == markerCert, + knownKey: KnownKey{ + Filename: filename, + Line: linenum, + Key: key, + }, + } + + if pattern[0] == '|' { + entry.matcher, err = newHashedHost(pattern) + } else { + entry.matcher, err = newHostnameMatcher(pattern) + } + + if err != nil { + return err + } + + db.lines = append(db.lines, entry) + return nil +} + +func newHostnameMatcher(pattern string) (matcher, error) { + var hps hostPatterns + for _, p := range strings.Split(pattern, ",") { + if len(p) == 0 { + continue + } + + var a addr + var negate bool + if p[0] == '!' { + negate = true + p = p[1:] + } + + if len(p) == 0 { + return nil, errors.New("knownhosts: negation without following hostname") + } + + var err error + if p[0] == '[' { + a.host, a.port, err = net.SplitHostPort(p) + if err != nil { + return nil, err + } + } else { + a.host, a.port, err = net.SplitHostPort(p) + if err != nil { + a.host = p + a.port = "22" + } + } + hps = append(hps, hostPattern{ + negate: negate, + addr: a, + }) + } + return hps, nil +} + +// KnownKey represents a key declared in a known_hosts file. +type KnownKey struct { + Key ssh.PublicKey + Filename string + Line int +} + +func (k *KnownKey) String() string { + return fmt.Sprintf("%s:%d: %s", k.Filename, k.Line, serialize(k.Key)) +} + +// KeyError is returned if we did not find the key in the host key +// database, or there was a mismatch. Typically, in batch +// applications, this should be interpreted as failure. Interactive +// applications can offer an interactive prompt to the user. +type KeyError struct { + // Want holds the accepted host keys. For each key algorithm, + // there can be one hostkey. If Want is empty, the host is + // unknown. If Want is non-empty, there was a mismatch, which + // can signify a MITM attack. + Want []KnownKey +} + +func (u *KeyError) Error() string { + if len(u.Want) == 0 { + return "knownhosts: key is unknown" + } + return "knownhosts: key mismatch" +} + +// RevokedError is returned if we found a key that was revoked. +type RevokedError struct { + Revoked KnownKey +} + +func (r *RevokedError) Error() string { + return "knownhosts: key is revoked" +} + +// check checks a key against the host database. This should not be +// used for verifying certificates. +func (db *hostKeyDB) check(address string, remote net.Addr, remoteKey ssh.PublicKey) error { + if revoked := db.revoked[string(remoteKey.Marshal())]; revoked != nil { + return &RevokedError{Revoked: *revoked} + } + + host, port, err := net.SplitHostPort(remote.String()) + if err != nil { + return fmt.Errorf("knownhosts: SplitHostPort(%s): %v", remote, err) + } + + hostToCheck := addr{host, port} + if address != "" { + // Give preference to the hostname if available. + host, port, err := net.SplitHostPort(address) + if err != nil { + return fmt.Errorf("knownhosts: SplitHostPort(%s): %v", address, err) + } + + hostToCheck = addr{host, port} + } + + return db.checkAddr(hostToCheck, remoteKey) +} + +// checkAddr checks if we can find the given public key for the +// given address. If we only find an entry for the IP address, +// or only the hostname, then this still succeeds. +func (db *hostKeyDB) checkAddr(a addr, remoteKey ssh.PublicKey) error { + // TODO(hanwen): are these the right semantics? What if there + // is just a key for the IP address, but not for the + // hostname? + + // Algorithm => key. + knownKeys := map[string]KnownKey{} + for _, l := range db.lines { + if l.match(a) { + typ := l.knownKey.Key.Type() + if _, ok := knownKeys[typ]; !ok { + knownKeys[typ] = l.knownKey + } + } + } + + keyErr := &KeyError{} + for _, v := range knownKeys { + keyErr.Want = append(keyErr.Want, v) + } + + // Unknown remote host. + if len(knownKeys) == 0 { + return keyErr + } + + // If the remote host starts using a different, unknown key type, we + // also interpret that as a mismatch. + if known, ok := knownKeys[remoteKey.Type()]; !ok || !keyEq(known.Key, remoteKey) { + return keyErr + } + + return nil +} + +// The Read function parses file contents. +func (db *hostKeyDB) Read(r io.Reader, filename string) error { + scanner := bufio.NewScanner(r) + + lineNum := 0 + for scanner.Scan() { + lineNum++ + line := scanner.Bytes() + line = bytes.TrimSpace(line) + if len(line) == 0 || line[0] == '#' { + continue + } + + if err := db.parseLine(line, filename, lineNum); err != nil { + return fmt.Errorf("knownhosts: %s:%d: %v", filename, lineNum, err) + } + } + return scanner.Err() +} + +// New creates a host key callback from the given OpenSSH host key +// files. The returned callback is for use in +// ssh.ClientConfig.HostKeyCallback. By preference, the key check +// operates on the hostname if available, i.e. if a server changes its +// IP address, the host key check will still succeed, even though a +// record of the new IP address is not available. +func New(files ...string) (ssh.HostKeyCallback, error) { + db := newHostKeyDB() + for _, fn := range files { + f, err := os.Open(fn) + if err != nil { + return nil, err + } + defer f.Close() + if err := db.Read(f, fn); err != nil { + return nil, err + } + } + + var certChecker ssh.CertChecker + certChecker.IsHostAuthority = db.IsHostAuthority + certChecker.IsRevoked = db.IsRevoked + certChecker.HostKeyFallback = db.check + + return certChecker.CheckHostKey, nil +} + +// Normalize normalizes an address into the form used in known_hosts +func Normalize(address string) string { + host, port, err := net.SplitHostPort(address) + if err != nil { + host = address + port = "22" + } + entry := host + if port != "22" { + entry = "[" + entry + "]:" + port + } else if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") { + entry = "[" + entry + "]" + } + return entry +} + +// Line returns a line to add append to the known_hosts files. +func Line(addresses []string, key ssh.PublicKey) string { + var trimmed []string + for _, a := range addresses { + trimmed = append(trimmed, Normalize(a)) + } + + return strings.Join(trimmed, ",") + " " + serialize(key) +} + +// HashHostname hashes the given hostname. The hostname is not +// normalized before hashing. +func HashHostname(hostname string) string { + // TODO(hanwen): check if we can safely normalize this always. + salt := make([]byte, sha1.Size) + + _, err := rand.Read(salt) + if err != nil { + panic(fmt.Sprintf("crypto/rand failure %v", err)) + } + + hash := hashHost(hostname, salt) + return encodeHash(sha1HashType, salt, hash) +} + +func decodeHash(encoded string) (hashType string, salt, hash []byte, err error) { + if len(encoded) == 0 || encoded[0] != '|' { + err = errors.New("knownhosts: hashed host must start with '|'") + return + } + components := strings.Split(encoded, "|") + if len(components) != 4 { + err = fmt.Errorf("knownhosts: got %d components, want 3", len(components)) + return + } + + hashType = components[1] + if salt, err = base64.StdEncoding.DecodeString(components[2]); err != nil { + return + } + if hash, err = base64.StdEncoding.DecodeString(components[3]); err != nil { + return + } + return +} + +func encodeHash(typ string, salt []byte, hash []byte) string { + return strings.Join([]string{"", + typ, + base64.StdEncoding.EncodeToString(salt), + base64.StdEncoding.EncodeToString(hash), + }, "|") +} + +// See https://android.googlesource.com/platform/external/openssh/+/ab28f5495c85297e7a597c1ba62e996416da7c7e/hostfile.c#120 +func hashHost(hostname string, salt []byte) []byte { + mac := hmac.New(sha1.New, salt) + mac.Write([]byte(hostname)) + return mac.Sum(nil) +} + +type hashedHost struct { + salt []byte + hash []byte +} + +const sha1HashType = "1" + +func newHashedHost(encoded string) (*hashedHost, error) { + typ, salt, hash, err := decodeHash(encoded) + if err != nil { + return nil, err + } + + // The type field seems for future algorithm agility, but it's + // actually hardcoded in openssh currently, see + // https://android.googlesource.com/platform/external/openssh/+/ab28f5495c85297e7a597c1ba62e996416da7c7e/hostfile.c#120 + if typ != sha1HashType { + return nil, fmt.Errorf("knownhosts: got hash type %s, must be '1'", typ) + } + + return &hashedHost{salt: salt, hash: hash}, nil +} + +func (h *hashedHost) match(a addr) bool { + return bytes.Equal(hashHost(Normalize(a.String()), h.salt), h.hash) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index c25a469d0..ab75c481d 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -232,6 +232,7 @@ golang.org/x/crypto/internal/subtle golang.org/x/crypto/poly1305 golang.org/x/crypto/ssh golang.org/x/crypto/ssh/agent +golang.org/x/crypto/ssh/knownhosts golang.org/x/crypto/ssh/terminal # golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 ## explicit