The previous implementation used bytes.TrimSpace, which strips all Unicode whitespace categories (e.g., non-breaking spaces). However, OpenSSH's known_hosts parser (hostfile.c) strictly treats only ASCII space (0x20) and horizontal tab (0x09) as separators. This discrepancy meant the Go parser might interpret fields differently than OpenSSH, potentially treating parts of a key or hostname as separators if they contained Unicode whitespace. This change replaces bytes.TrimSpace with a local trimSpace helper that only trims " \t", ensuring parsing behavior consistent with the reference implementation. This issue was found during a security audit by NCC Group Cryptography Services, sponsored by Teleport, and was assessed and is being fixed as a non-security bug. Change-Id: Ia536889636de2c167d2507c01e3f1b7c033c9a8f Reviewed-on: https://go-review.googlesource.com/c/crypto/+/782426 Reviewed-by: David Chase <drchase@google.com> Reviewed-by: Filippo Valsorda <filippo@golang.org> LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Junyang Shao <shaojunyang@google.com>
545 lines
12 KiB
Go
545 lines
12 KiB
Go
// 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())
|
|
}
|
|
|
|
// IsHostAuthority 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 {
|
|
if _, ok := db.revoked[string(key.Marshal())]; ok {
|
|
return true
|
|
}
|
|
if _, ok := db.revoked[string(key.SignatureKey.Marshal())]; ok {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
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]), 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 multiple hostkeys. 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?
|
|
|
|
keyErr := &KeyError{}
|
|
|
|
for _, l := range db.lines {
|
|
if !l.match(a) {
|
|
continue
|
|
}
|
|
|
|
keyErr.Want = append(keyErr.Want, l.knownKey)
|
|
if keyEq(l.knownKey.Key, remoteKey) {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return keyErr
|
|
}
|
|
|
|
// 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 = 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. Supports
|
|
// IPv4, hostnames, bracketed IPv6. Any other non-standard formats are returned
|
|
// with minimal transformation.
|
|
func Normalize(address string) string {
|
|
const defaultSSHPort = "22"
|
|
|
|
host, port, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
host = address
|
|
port = defaultSSHPort
|
|
}
|
|
|
|
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
|
host = host[1 : len(host)-1]
|
|
}
|
|
|
|
if port == defaultSSHPort {
|
|
return host
|
|
}
|
|
return "[" + host + "]:" + port
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// trimSpace removes leading and trailing ASCII whitespace (space and tab). It
|
|
// is used instead of bytes.TrimSpace to match OpenSSH behavior, which strictly
|
|
// parses only ASCII space (0x20) and tab (0x09) as whitespace.
|
|
func trimSpace(in []byte) []byte {
|
|
return bytes.Trim(in, " \t")
|
|
}
|