mirror of
https://github.com/FiloSottile/age.git
synced 2026-09-04 07:07:16 +00:00
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:
+6
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user