From 20f9e8574f32c58c834579ab0afa9ea129452ea6 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sat, 29 Aug 2026 15:28:39 +0200 Subject: [PATCH] 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. --- agessh/agessh.go | 8 +++- agessh/agessh_test.go | 84 +++++++++++++++++++++++++++++++++ tag/internal/tagtest/tagtest.go | 20 ++++++-- tag/tag_test.go | 62 ++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 6 deletions(-) diff --git a/agessh/agessh.go b/agessh/agessh.go index 9af9a7c..dc3a2a3 100644 --- a/agessh/agessh.go +++ b/agessh/agessh.go @@ -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 } diff --git a/agessh/agessh_test.go b/agessh/agessh_test.go index 912e111..5c5060f 100644 --- a/agessh/agessh_test.go +++ b/agessh/agessh_test.go @@ -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) + } +} diff --git a/tag/internal/tagtest/tagtest.go b/tag/internal/tagtest/tagtest.go index ed9d11b..413def1 100644 --- a/tag/internal/tagtest/tagtest.go +++ b/tag/internal/tagtest/tagtest.go @@ -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 } diff --git a/tag/tag_test.go b/tag/tag_test.go index a77e4a6..6d83a41 100644 --- a/tag/tag_test.go +++ b/tag/tag_test.go @@ -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 {