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>
This commit is contained in:
Nicola Murino
2026-08-23 06:53:19 -07:00
parent 82adefa711
commit 71488c48c2
2 changed files with 75 additions and 4 deletions
+13 -4
View File
@@ -142,6 +142,15 @@ func keyEq(a, b ssh.PublicKey) bool {
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 @@ func (db *hostKeyDB) IsHostAuthority(remote ssh.PublicKey, address string) bool
// 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 @@ func (db *hostKeyDB) parseLine(line []byte, filename string, linenum int) error
}
if marker == markerRevoked {
db.revoked[string(key.Marshal())] = &KnownKey{
db.revoked[plainKeyBlob(key)] = &KnownKey{
Key: key,
Filename: filename,
Line: linenum,
@@ -341,7 +350,7 @@ func (r *RevokedError) Error() string {
// 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}
}
+62
View File
@@ -316,6 +316,68 @@ func TestRevokedCA(t *testing.T) {
}
}
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