From e0ffaca26010349b5a4f5c8a5978f702ab84494a Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sat, 29 Aug 2026 17:08:21 +0200 Subject: [PATCH] agessh: don't cache mismatched encrypted identities Reported by Joe Doyle of Trail of Bits. --- agessh/agessh_test.go | 66 ++++++++++++++++++++++++++++++++++++++++ agessh/encrypted_keys.go | 8 +++-- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/agessh/agessh_test.go b/agessh/agessh_test.go index 5c5060f..fe1b23e 100644 --- a/agessh/agessh_test.go +++ b/agessh/agessh_test.go @@ -9,6 +9,7 @@ import ( "crypto/ed25519" "crypto/rand" "crypto/rsa" + "encoding/pem" "reflect" "testing" @@ -185,3 +186,68 @@ func TestSSHEd25519FingerprintCollision(t *testing.T) { t.Errorf("invalid output: %x, expected %x", out, fileKey) } } + +func TestEncryptedSSHIdentityMismatch(t *testing.T) { + announcedPub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + embeddedPub, embeddedPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + announcedKey, err := ssh.NewPublicKey(announcedPub) + if err != nil { + t.Fatal(err) + } + embeddedKey, err := ssh.NewPublicKey(embeddedPub) + if err != nil { + t.Fatal(err) + } + + passphrase := []byte("passphrase") + block, err := ssh.MarshalPrivateKeyWithPassphrase(embeddedPriv, "", passphrase) + if err != nil { + t.Fatal(err) + } + prompts := 0 + i, err := agessh.NewEncryptedSSHIdentity(announcedKey, pem.EncodeToMemory(block), func() ([]byte, error) { + prompts++ + return passphrase, nil + }) + if err != nil { + t.Fatal(err) + } + + announcedRecipient, err := agessh.NewEd25519Recipient(announcedKey) + if err != nil { + t.Fatal(err) + } + embeddedRecipient, err := agessh.NewEd25519Recipient(embeddedKey) + if err != nil { + t.Fatal(err) + } + fileKey := make([]byte, 16) + if _, err := rand.Read(fileKey); err != nil { + t.Fatal(err) + } + stanzas, err := announcedRecipient.Wrap(fileKey) + if err != nil { + t.Fatal(err) + } + embeddedStanzas, err := embeddedRecipient.Wrap(fileKey) + if err != nil { + t.Fatal(err) + } + stanzas = append(stanzas, embeddedStanzas...) + + for attempt := 1; attempt <= 2; attempt++ { + _, err := i.Unwrap(stanzas) + if err == nil || err.Error() != "mismatched private and public SSH key" { + t.Fatalf("attempt %d: unexpected error: %v", attempt, err) + } + if prompts != attempt { + t.Fatalf("attempt %d: passphrase callback invoked %d times", attempt, prompts) + } + } +} diff --git a/agessh/encrypted_keys.go b/agessh/encrypted_keys.go index 9414bcf..f72d558 100644 --- a/agessh/encrypted_keys.go +++ b/agessh/encrypted_keys.go @@ -109,16 +109,17 @@ func (i *EncryptedSSHIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, er var pubKey interface { Equal(x crypto.PublicKey) bool } + var decrypted age.Identity switch k := k.(type) { case *ed25519.PrivateKey: - i.decrypted, err = NewEd25519Identity(*k) + decrypted, err = NewEd25519Identity(*k) pubKey = k.Public().(ed25519.PublicKey) // ParseRawPrivateKey returns inconsistent types. See Issue 429. case ed25519.PrivateKey: - i.decrypted, err = NewEd25519Identity(k) + decrypted, err = NewEd25519Identity(k) pubKey = k.Public().(ed25519.PublicKey) case *rsa.PrivateKey: - i.decrypted, err = NewRSAIdentity(k) + decrypted, err = NewRSAIdentity(k) pubKey = &k.PublicKey default: return nil, fmt.Errorf("unexpected SSH key type: %T", k) @@ -131,5 +132,6 @@ func (i *EncryptedSSHIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, er return nil, fmt.Errorf("mismatched private and public SSH key") } + i.decrypted = decrypted return i.decrypted.Unwrap(stanzas) }