agessh,tag/tagtest: handle recipient hint collisions

The four-byte SSH fingerprints (and tagged recipient tags) are hints to
avoid unnecessary password prompts or hardware interactions. Treat an
authenticated unwrap failure after a hint match as a false positive and
continue looking for a matching stanza.

Without this, a colliding recipient stanza that appears before the
valid stanza makes decryption fail even though the file is correctly
encrypted to the identity. Any password prompt or hardware interaction
has already happened by then.

Reported by Claude, Anthropic's AI assistant, and triaged by the
Anthropic security team in collaboration with Anthropic Research,
as ANT-2026-S9C25K8W.
This commit is contained in:
Filippo Valsorda
2026-08-29 19:30:10 +02:00
parent 27188e780b
commit 20f9e8574f
4 changed files with 168 additions and 6 deletions
+6 -2
View File
@@ -129,7 +129,9 @@ func (i *RSAIdentity) unwrap(block *age.Stanza) ([]byte, error) {
fileKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, i.k,
block.Body, []byte(oaepLabel))
if err != nil {
return nil, fmt.Errorf("failed to decrypt file key: %v", err)
// The fingerprint is only a short hint, and might collide with the
// fingerprint of a different recipient.
return nil, age.ErrIncorrectIdentity
}
return fileKey, nil
}
@@ -344,7 +346,9 @@ func (i *Ed25519Identity) unwrap(block *age.Stanza) ([]byte, error) {
fileKey, err := aeadDecrypt(wrappingKey, block.Body)
if err != nil {
return nil, fmt.Errorf("failed to decrypt file key: %v", err)
// The fingerprint is only a short hint, and might collide with the
// fingerprint of a different recipient.
return nil, age.ErrIncorrectIdentity
}
return fileKey, nil
}
+84
View File
@@ -59,6 +59,48 @@ func TestSSHRSARoundTrip(t *testing.T) {
}
}
func TestSSHRSAFingerprintCollision(t *testing.T) {
targetKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
targetIdentity, err := agessh.NewRSAIdentity(targetKey)
if err != nil {
t.Fatal(err)
}
otherKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
otherIdentity, err := agessh.NewRSAIdentity(otherKey)
if err != nil {
t.Fatal(err)
}
fileKey := make([]byte, 16)
if _, err := rand.Read(fileKey); err != nil {
t.Fatal(err)
}
stanzas, err := targetIdentity.Recipient().Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
collision, err := otherIdentity.Recipient().Wrap(make([]byte, 16))
if err != nil {
t.Fatal(err)
}
collision[0].Args[0] = stanzas[0].Args[0]
out, err := targetIdentity.Unwrap(append(collision, stanzas...))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fileKey, out) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
}
func TestSSHEd25519RoundTrip(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
@@ -101,3 +143,45 @@ func TestSSHEd25519RoundTrip(t *testing.T) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
}
func TestSSHEd25519FingerprintCollision(t *testing.T) {
_, targetKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
targetIdentity, err := agessh.NewEd25519Identity(targetKey)
if err != nil {
t.Fatal(err)
}
_, otherKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
otherIdentity, err := agessh.NewEd25519Identity(otherKey)
if err != nil {
t.Fatal(err)
}
fileKey := make([]byte, 16)
if _, err := rand.Read(fileKey); err != nil {
t.Fatal(err)
}
stanzas, err := targetIdentity.Recipient().Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
collision, err := otherIdentity.Recipient().Wrap(make([]byte, 16))
if err != nil {
t.Fatal(err)
}
collision[0].Args[0] = stanzas[0].Args[0]
out, err := targetIdentity.Unwrap(append(collision, stanzas...))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fileKey, out) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
}
+16 -4
View File
@@ -74,14 +74,20 @@ func (i *ClassicIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) {
return nil, fmt.Errorf("failed to compute tag: %v", err)
}
if subtle.ConstantTimeCompare(tagArg, expTag[:4]) != 1 {
return nil, age.ErrIncorrectIdentity
continue
}
r, err := hpke.NewRecipient(enc, i.k, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte("age-encryption.org/p256tag"))
if err != nil {
return nil, fmt.Errorf("failed to unwrap file key: %v", err)
}
return r.Open(nil, s.Body)
fileKey, err := r.Open(nil, s.Body)
if err != nil {
// The tag is only a short hint, and might collide with the tag of a
// different recipient.
continue
}
return fileKey, nil
}
return nil, age.ErrIncorrectIdentity
}
@@ -139,14 +145,20 @@ func (i *HybridIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) {
return nil, fmt.Errorf("failed to compute tag: %v", err)
}
if subtle.ConstantTimeCompare(tagArg, expTag[:4]) != 1 {
return nil, age.ErrIncorrectIdentity
continue
}
r, err := hpke.NewRecipient(enc, i.k, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte("age-encryption.org/mlkem768p256tag"))
if err != nil {
return nil, fmt.Errorf("failed to unwrap file key: %v", err)
}
return r.Open(nil, s.Body)
fileKey, err := r.Open(nil, s.Body)
if err != nil {
// The tag is only a short hint, and might collide with the tag of a
// different recipient.
continue
}
return fileKey, nil
}
return nil, age.ErrIncorrectIdentity
}
+62
View File
@@ -10,6 +10,7 @@ import (
"testing"
"filippo.io/age"
"filippo.io/age/internal/format"
"filippo.io/age/tag"
"filippo.io/age/tag/internal/tagtest"
)
@@ -108,6 +109,67 @@ func TestHybridRoundTrip(t *testing.T) {
}
}
func TestTagCollision(t *testing.T) {
tests := []struct {
name string
identity age.Identity
recipient *tag.Recipient
other *tag.Recipient
}{
{
name: "classic",
identity: tagtest.NewClassicIdentity("target"),
recipient: tagtest.NewClassicIdentity("target").Recipient(),
other: tagtest.NewClassicIdentity("other").Recipient(),
},
{
name: "hybrid",
identity: tagtest.NewHybridIdentity("target"),
recipient: tagtest.NewHybridIdentity("target").Recipient(),
other: tagtest.NewHybridIdentity("other").Recipient(),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fileKey := bytes.Repeat([]byte{1}, 16)
stanzas, err := test.recipient.Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
unrelated, err := test.other.Wrap(make([]byte, 16))
if err != nil {
t.Fatal(err)
}
collision := &age.Stanza{
Type: unrelated[0].Type,
Args: append([]string(nil), unrelated[0].Args...),
Body: append([]byte(nil), unrelated[0].Body...),
}
enc, err := format.DecodeString(collision.Args[1])
if err != nil {
t.Fatal(err)
}
collisionTag, err := test.recipient.Tag(enc)
if err != nil {
t.Fatal(err)
}
collision.Args[0] = format.EncodeToString(collisionTag)
allStanzas := append(unrelated, collision)
allStanzas = append(allStanzas, stanzas...)
out, err := test.identity.Unwrap(allStanzas)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fileKey, out) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
})
}
}
func TestTagHybridMixingRestrictions(t *testing.T) {
x25519, err := age.GenerateX25519Identity()
if err != nil {