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 {