ssh/knownhosts: compare only public key portions for revocation

Revocation matching compared full marshaled key blobs, so a @revoked
line containing a plain host key did not revoke a certificate
certifying that key: when the server presented such a certificate,
IsRevoked only matched the whole certificate blob or the CA key, and
verification succeeded even though the underlying host key was revoked.

OpenSSH's check_key_not_revoked compares keys with sshkey_equal_public,
which considers only the public portions and explicitly allows
comparisons between certificates and plain keys. Match that behavior by
normalizing both @revoked entries and lookups to the underlying public
key: for a certificate, the certified key rather than the certificate
blob. This also makes a @revoked line containing a certificate revoke
the plain key it certifies

Change-Id: I3029d59e68fb01f2340763e2eae25cac23c6193d
Reviewed-on: https://go-review.googlesource.com/c/crypto/+/816841
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: David Chase <drchase@google.com>
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
diff --git a/ssh/knownhosts/knownhosts.go b/ssh/knownhosts/knownhosts.go
index cf520ad..017c696 100644
--- a/ssh/knownhosts/knownhosts.go
+++ b/ssh/knownhosts/knownhosts.go
@@ -142,6 +142,15 @@
 	return bytes.Equal(a.Marshal(), b.Marshal())
 }
 
+// plainKeyBlob returns the serialized public portion of key: for a
+// certificate this is the certified key, otherwise the key itself.
+func plainKeyBlob(key ssh.PublicKey) string {
+	if cert, ok := key.(*ssh.Certificate); ok {
+		return string(cert.Key.Marshal())
+	}
+	return string(key.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)
@@ -160,10 +169,10 @@
 
 // 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 {
+	if _, ok := db.revoked[plainKeyBlob(key)]; ok {
 		return true
 	}
-	if _, ok := db.revoked[string(key.SignatureKey.Marshal())]; ok {
+	if _, ok := db.revoked[plainKeyBlob(key.SignatureKey)]; ok {
 		return true
 	}
 	return false
@@ -228,7 +237,7 @@
 	}
 
 	if marker == markerRevoked {
-		db.revoked[string(key.Marshal())] = &KnownKey{
+		db.revoked[plainKeyBlob(key)] = &KnownKey{
 			Key:      key,
 			Filename: filename,
 			Line:     linenum,
@@ -341,7 +350,7 @@
 // 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 {
+	if revoked := db.revoked[plainKeyBlob(remoteKey)]; revoked != nil {
 		return &RevokedError{Revoked: *revoked}
 	}
 
diff --git a/ssh/knownhosts/knownhosts_test.go b/ssh/knownhosts/knownhosts_test.go
index 5e51447..947cf70 100644
--- a/ssh/knownhosts/knownhosts_test.go
+++ b/ssh/knownhosts/knownhosts_test.go
@@ -316,6 +316,68 @@
 	}
 }
 
+func TestRevokedCertifiedKey(t *testing.T) {
+	_, caPriv, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		t.Fatal(err)
+	}
+	caSigner, err := ssh.NewSignerFromKey(caPriv)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	_, hostPriv, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		t.Fatal(err)
+	}
+	hostKey, err := ssh.NewPublicKey(hostPriv.Public())
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	signCert := func(serial uint64, principal string) *ssh.Certificate {
+		cert := &ssh.Certificate{
+			CertType:        ssh.HostCert,
+			Key:             hostKey,
+			Serial:          serial,
+			ValidBefore:     ssh.CertTimeInfinity,
+			ValidPrincipals: []string{principal},
+		}
+		if err := cert.SignCert(rand.Reader, caSigner); err != nil {
+			t.Fatal(err)
+		}
+		return cert
+	}
+	cert := signCert(1, "server.org")
+
+	// Revoking the host key revokes any certificate for it, even when
+	// the CA is trusted, matching OpenSSH's check_key_not_revoked.
+	db := testDB(t, "@revoked server.org "+serialize(hostKey)+"\n"+
+		"@cert-authority server.org "+serialize(caSigner.PublicKey())+"\n")
+	if !db.IsRevoked(cert) {
+		t.Error("IsRevoked returned false for certificate with revoked certified key")
+	}
+
+	// Conversely, a @revoked line containing a certificate revokes the
+	// plain key it certifies.
+	db = testDB(t, "@revoked server.org "+serialize(cert)+"\n")
+	if err := db.check("server.org:22", testAddr, hostKey); err == nil {
+		t.Error("no error for plain key revoked via certificate")
+	} else if _, ok := err.(*RevokedError); !ok {
+		t.Errorf("got %T (%v), want *RevokedError", err, err)
+	}
+
+	// Since only public portions are compared, revoking a certificate
+	// also revokes any sibling certificate certifying the same key,
+	// whatever its serial number and principals.
+	sibling := signCert(2, "other.org")
+	db = testDB(t, "@revoked * "+serialize(cert)+"\n"+
+		"@cert-authority * "+serialize(caSigner.PublicKey())+"\n")
+	if !db.IsRevoked(sibling) {
+		t.Error("IsRevoked returned false for sibling certificate of a revoked certificate")
+	}
+}
+
 const testHostname = "hostname"
 
 // generated with keygen -H -f