From 807dd553df691d05ddecaf2b28568177041b8da2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Wed, 18 Jun 2025 12:11:52 +0200 Subject: [PATCH] ed25519/x25519: simplify key implementation, add tests --- verifications/ed25519/key.go | 28 ++----- verifications/ed25519/key_test.go | 27 +++++++ verifications/internal/multibase.go | 35 +++++++++ verifications/x25519/key.go | 58 ++++++++------ verifications/x25519/key_test.go | 116 ++++++++++++++++------------ 5 files changed, 170 insertions(+), 94 deletions(-) create mode 100644 verifications/ed25519/key_test.go create mode 100644 verifications/internal/multibase.go diff --git a/verifications/ed25519/key.go b/verifications/ed25519/key.go index 0350f2c..c5502ac 100644 --- a/verifications/ed25519/key.go +++ b/verifications/ed25519/key.go @@ -5,8 +5,7 @@ import ( "crypto/rand" "fmt" - mbase "github.com/multiformats/go-multibase" - "github.com/multiformats/go-varint" + "github.com/INFURA/go-did/verifications/internal" ) type PublicKey = ed25519.PublicKey @@ -25,7 +24,7 @@ func GenerateKeyPair() (PublicKey, PrivateKey, error) { return ed25519.GenerateKey(rand.Reader) } -// PublicKeyFromBytes convert a serialized public key to a PublicKey. +// PublicKeyFromBytes converts a serialized public key to a PublicKey. // It errors if the slice is not the right size. func PublicKeyFromBytes(b []byte) (PublicKey, error) { if len(b) != PublicKeySize { @@ -36,38 +35,25 @@ func PublicKeyFromBytes(b []byte) (PublicKey, error) { // PublicKeyFromMultibase decodes the public key from its Multibase form func PublicKeyFromMultibase(multibase string) (PublicKey, error) { - baseCodec, bytes, err := mbase.Decode(multibase) - if err != nil { - return nil, err - } - // the specification enforces that encoding - if baseCodec != mbase.Base58BTC { - return nil, fmt.Errorf("not Base58BTC encoded") - } - code, read, err := varint.FromUvarint(bytes) + code, bytes, err := helpers.MultibaseDecode(multibase) if err != nil { return nil, err } if code != MultibaseCode { return nil, fmt.Errorf("invalid code") } - if read != 2 { - return nil, fmt.Errorf("unexpected multibase") - } - if len(bytes)-read != PublicKeySize { + if len(bytes) != PublicKeySize { return nil, fmt.Errorf("invalid ed25519 public key size") } - return bytes[read:], nil + return bytes, nil } // PublicKeyToMultibase encodes the public key in a suitable way for publicKeyMultibase func PublicKeyToMultibase(pub PublicKey) string { - // can only fail with an invalid encoding, but it's hardcoded - bytes, _ := mbase.Encode(mbase.Base58BTC, append(varint.ToUvarint(MultibaseCode), pub...)) - return bytes + return helpers.MultibaseEncode(MultibaseCode, pub) } -// PrivateKeyFromBytes convert a serialized public key to a PrivateKey. +// PrivateKeyFromBytes converts a serialized public key to a PrivateKey. // It errors if the slice is not the right size. func PrivateKeyFromBytes(b []byte) (PrivateKey, error) { if len(b) != ed25519.PrivateKeySize { diff --git a/verifications/ed25519/key_test.go b/verifications/ed25519/key_test.go new file mode 100644 index 0000000..6068694 --- /dev/null +++ b/verifications/ed25519/key_test.go @@ -0,0 +1,27 @@ +package ed25519_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/INFURA/go-did/verifications/ed25519" +) + +func TestGenerateKey(t *testing.T) { + pub, priv, err := ed25519.GenerateKeyPair() + require.NoError(t, err) + require.NotNil(t, pub) + require.NotNil(t, priv) + require.True(t, pub.Equal(priv.Public())) +} + +func TestMultibaseRoundTrip(t *testing.T) { + pub, _, err := ed25519.GenerateKeyPair() + require.NoError(t, err) + + mb := ed25519.PublicKeyToMultibase(pub) + rt, err := ed25519.PublicKeyFromMultibase(mb) + require.NoError(t, err) + require.Equal(t, pub, rt) +} diff --git a/verifications/internal/multibase.go b/verifications/internal/multibase.go new file mode 100644 index 0000000..a2443de --- /dev/null +++ b/verifications/internal/multibase.go @@ -0,0 +1,35 @@ +package helpers + +import ( + "fmt" + + mbase "github.com/multiformats/go-multibase" + "github.com/multiformats/go-varint" +) + +// MultibaseDecode is a helper for decoding multibase public keys. +func MultibaseDecode(multibase string) (uint64, []byte, error) { + baseCodec, bytes, err := mbase.Decode(multibase) + if err != nil { + return 0, nil, err + } + // the specification enforces that encoding + if baseCodec != mbase.Base58BTC { + return 0, nil, fmt.Errorf("not Base58BTC encoded") + } + code, read, err := varint.FromUvarint(bytes) + if err != nil { + return 0, nil, err + } + if read != 2 { + return 0, nil, fmt.Errorf("unexpected multibase") + } + return code, bytes[read:], nil +} + +// MultibaseEncode is a helper for encoding multibase public keys. +func MultibaseEncode(code uint64, bytes []byte) string { + // can only fail with an invalid encoding, but it's hardcoded + res, _ := mbase.Encode(mbase.Base58BTC, append(varint.ToUvarint(code), bytes...)) + return res +} diff --git a/verifications/x25519/key.go b/verifications/x25519/key.go index 965daef..83047ad 100644 --- a/verifications/x25519/key.go +++ b/verifications/x25519/key.go @@ -3,13 +3,12 @@ package x25519 import ( "crypto/ecdh" "crypto/rand" + "crypto/sha512" "fmt" "math/big" - mbase "github.com/multiformats/go-multibase" - "github.com/multiformats/go-varint" - "github.com/INFURA/go-did/verifications/ed25519" + helpers "github.com/INFURA/go-did/verifications/internal" ) type PublicKey = *ecdh.PublicKey @@ -32,12 +31,16 @@ func GenerateKeyPair() (PublicKey, PrivateKey, error) { return priv.Public().(PublicKey), priv, nil } -// PublicKeyFromBytes convert a serialized public key to a PublicKey. +// PublicKeyFromBytes converts a serialized public key to a PublicKey. // It errors if the slice is not the right size. func PublicKeyFromBytes(b []byte) (PublicKey, error) { return ecdh.X25519().NewPublicKey(b) } +// PublicKeyFromEd25519 converts an ed25519 public key to a x25519 public key. +// It errors if the slice is not the right size. +// +// This function is based on the algorithm described in https://datatracker.ietf.org/doc/html/draft-ietf-core-oscore-groupcomm#name-curve25519 func PublicKeyFromEd25519(pub ed25519.PublicKey) (PublicKey, error) { // Conversion formula is u = (1 + y) / (1 - y) (mod p) // See https://datatracker.ietf.org/doc/html/draft-ietf-core-oscore-groupcomm#name-ecdh-with-montgomery-coordi @@ -53,7 +56,7 @@ func PublicKeyFromEd25519(pub ed25519.PublicKey) (PublicKey, error) { copy(pubCopy, pub) pubCopy[ed25519.PublicKeySize-1] &= 0x7F - // ed25519 are little-endian, but big.Int expect big-endian + // ed25519 are little-endian, but big.Int expects big-endian // See https://www.rfc-editor.org/rfc/rfc8032 y := new(big.Int).SetBytes(reverseBytes(pubCopy)) one := big.NewInt(1) @@ -89,47 +92,56 @@ func PublicKeyFromEd25519(pub ed25519.PublicKey) (PublicKey, error) { res := make([]byte, PublicKeySize) copy(res[PublicKeySize-len(uBytes):], uBytes) - // x25519 are little-endian, but big.Int give us big-endian. + // x25519 are little-endian, but big.Int gives us big-endian. // See https://www.ietf.org/rfc/rfc7748.txt return ecdh.X25519().NewPublicKey(reverseBytes(res)) } // PublicKeyFromMultibase decodes the public key from its Multibase form func PublicKeyFromMultibase(multibase string) (PublicKey, error) { - baseCodec, bytes, err := mbase.Decode(multibase) - if err != nil { - return nil, err - } - // the specification enforces that encoding - if baseCodec != mbase.Base58BTC { - return nil, fmt.Errorf("not Base58BTC encoded") - } - code, read, err := varint.FromUvarint(bytes) + code, bytes, err := helpers.MultibaseDecode(multibase) if err != nil { return nil, err } if code != MultibaseCode { return nil, fmt.Errorf("invalid code") } - if read != 2 { - return nil, fmt.Errorf("unexpected multibase") - } - return ecdh.X25519().NewPublicKey(bytes[read:]) + return ecdh.X25519().NewPublicKey(bytes) } // PublicKeyToMultibase encodes the public key in a suitable way for publicKeyMultibase func PublicKeyToMultibase(pub PublicKey) string { - // can only fail with an invalid encoding, but it's hardcoded - bytes, _ := mbase.Encode(mbase.Base58BTC, append(varint.ToUvarint(MultibaseCode), pub.Bytes()...)) - return bytes + return helpers.MultibaseEncode(MultibaseCode, pub.Bytes()) } -// PrivateKeyFromBytes convert a serialized public key to a PrivateKey. +// PrivateKeyFromBytes converts a serialized public key to a PrivateKey. // It errors if len(privateKey) is not [PrivateKeySize]. func PrivateKeyFromBytes(b []byte) (PrivateKey, error) { return ecdh.X25519().NewPrivateKey(b) } +// PrivateKeyFromEd25519 converts an ed25519 private key to a x25519 private key. +// It errors if the slice is not the right size. +// +// This function is based on the algorithm described in https://datatracker.ietf.org/doc/html/draft-ietf-core-oscore-groupcomm#name-curve25519 +func PrivateKeyFromEd25519(priv ed25519.PrivateKey) (PrivateKey, error) { + if len(priv) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("invalid ed25519 private key size") + } + + // get the 32-byte seed (first half of the private key) + seed := priv.Seed() + + h := sha512.Sum512(seed) + + // clamp as per the X25519 spec + h[0] &= 248 + h[31] &= 127 + h[31] |= 64 + + return ecdh.X25519().NewPrivateKey(h[:32]) +} + func reverseBytes(b []byte) []byte { r := make([]byte, len(b)) for i := 0; i < len(b); i++ { diff --git a/verifications/x25519/key_test.go b/verifications/x25519/key_test.go index 81b1623..a213856 100644 --- a/verifications/x25519/key_test.go +++ b/verifications/x25519/key_test.go @@ -1,52 +1,68 @@ package x25519_test -// -// import ( -// "encoding/hex" -// "testing" -// -// "github.com/stretchr/testify/require" -// -// "github.com/INFURA/go-did/verifications/x25519" -// ) -// -// func TestGenerateKey(t *testing.T) { -// t.Run("x25519.GenerateKey()", func(t *testing.T) { -// _, _, err := x25519.GenerateKey() -// require.NoError(t, err, `x25519.GenerateKey should work`) -// }) -// t.Run("x25519.NewKeyFromSeed(wrongSeedLength)", func(t *testing.T) { -// dummy := make([]byte, x25519.SeedSize-1) -// _, err := x25519.NewKeyFromSeed(dummy) -// require.Error(t, err, `wrong seed size should result in error`) -// }) -// } -// -// func TestNewKeyFromSeed(t *testing.T) { -// // These test vectors are from RFC7748 Section 6.1 -// const alicePrivHex = `77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a` -// const alicePubHex = `8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a` -// const bobPrivHex = `5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb` -// const bobPubHex = `de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f` -// -// alicePrivSeed, err := hex.DecodeString(alicePrivHex) -// require.NoError(t, err, `alice seed decoded`) -// alicePriv, err := x25519.NewKeyFromSeed(alicePrivSeed) -// require.NoError(t, err, `alice private key`) -// -// alicePub := alicePriv.Public().(x25519.PublicKey) -// require.Equal(t, hex.EncodeToString(alicePub), alicePubHex, `alice public key`) -// -// bobPrivSeed, err := hex.DecodeString(bobPrivHex) -// require.NoError(t, err, `bob seed decoded`) -// bobPriv, err := x25519.NewKeyFromSeed(bobPrivSeed) -// require.NoError(t, err, `bob private key`) -// -// bobPub := bobPriv.Public().(x25519.PublicKey) -// require.Equal(t, hex.EncodeToString(bobPub), bobPubHex, `bob public key`) -// -// require.True(t, bobPriv.Equal(bobPriv), `bobPriv should equal bobPriv`) -// require.True(t, bobPub.Equal(bobPub), `bobPub should equal bobPub`) -// require.False(t, bobPriv.Equal(bobPub), `bobPriv should NOT equal bobPub`) -// require.False(t, bobPub.Equal(bobPriv), `bobPub should NOT equal bobPriv`) -// } +import ( + "crypto/ecdh" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/INFURA/go-did/verifications/ed25519" + "github.com/INFURA/go-did/verifications/x25519" +) + +func TestGenerateKey(t *testing.T) { + pub, priv, err := x25519.GenerateKeyPair() + require.NoError(t, err) + require.NotNil(t, pub) + require.NotNil(t, priv) + require.Equal(t, ecdh.X25519(), pub.Curve()) + require.Equal(t, ecdh.X25519(), priv.Curve()) + require.True(t, pub.Equal(priv.Public())) +} + +func TestMultibaseRoundTrip(t *testing.T) { + pub, _, err := x25519.GenerateKeyPair() + require.NoError(t, err) + + mb := x25519.PublicKeyToMultibase(pub) + rt, err := x25519.PublicKeyFromMultibase(mb) + require.NoError(t, err) + require.Equal(t, pub, rt) +} + +func TestEd25519ToX25519(t *testing.T) { + // Known pubkey ed25519 --> x25519 + for _, tc := range []struct { + pubEdMultibase string + pubXMultibase string + }{ + { + // From https://w3c-ccg.github.io/did-key-spec/#ed25519-with-x25519 + pubEdMultibase: "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + pubXMultibase: "z6LSj72tK8brWgZja8NLRwPigth2T9QRiG1uH9oKZuKjdh9p", + }, + } { + t.Run(tc.pubEdMultibase, func(t *testing.T) { + pubEd, err := ed25519.PublicKeyFromMultibase(tc.pubEdMultibase) + require.NoError(t, err) + pubX, err := x25519.PublicKeyFromEd25519(pubEd) + require.NoError(t, err) + require.Equal(t, tc.pubXMultibase, x25519.PublicKeyToMultibase(pubX)) + }) + } + + // Check that ed25519 --> x25519 match for pubkeys and privkeys + t.Run("ed25519 --> x25519 priv+pub are matching", func(t *testing.T) { + for i := 0; i < 10; i++ { + pubEd, privEd, err := ed25519.GenerateKeyPair() + require.NoError(t, err) + + pubX, err := x25519.PublicKeyFromEd25519(pubEd) + require.NoError(t, err) + privX, err := x25519.PrivateKeyFromEd25519(privEd) + require.NoError(t, err) + + require.True(t, pubX.Equal(privX.PublicKey())) + } + }) +}