diff --git a/refs/grpc/types.pb.go b/refs/grpc/types.pb.go index 4c19babd..bc71b57e 100644 Binary files a/refs/grpc/types.pb.go and b/refs/grpc/types.pb.go differ diff --git a/refs/types.go b/refs/types.go index 025761d4..2d4fec72 100644 --- a/refs/types.go +++ b/refs/types.go @@ -37,6 +37,7 @@ type SignatureScheme uint32 const ( ECDSA_SHA512 SignatureScheme = iota ECDSA_RFC6979_SHA256 + ECDSA_RFC6979_SHA256_WALLET_CONNECT ) type Signature struct { diff --git a/util/signature/options.go b/util/signature/options.go index 903ff66e..615b4439 100644 --- a/util/signature/options.go +++ b/util/signature/options.go @@ -2,9 +2,11 @@ package signature import ( "crypto/ecdsa" + "encoding/base64" "fmt" "github.com/nspcc-dev/neofs-api-go/v2/refs" + "github.com/nspcc-dev/neofs-api-go/v2/util/signature/walletconnect" crypto "github.com/nspcc-dev/neofs-crypto" ) @@ -24,12 +26,22 @@ func verify(cfg *cfg, data []byte, sig *refs.Signature) error { } pub := crypto.UnmarshalPublicKey(sig.GetKey()) + if pub == nil { + return crypto.ErrEmptyPublicKey + } switch cfg.scheme { case refs.ECDSA_SHA512: return crypto.Verify(pub, data, sig.GetSign()) case refs.ECDSA_RFC6979_SHA256: return crypto.VerifyRFC6979(pub, data, sig.GetSign()) + case refs.ECDSA_RFC6979_SHA256_WALLET_CONNECT: + buf := make([]byte, base64.StdEncoding.EncodedLen(len(data))) + base64.StdEncoding.Encode(buf, data) + if !walletconnect.Verify(pub, buf, sig.GetSign()) { + return crypto.ErrInvalidSignature + } + return nil default: return fmt.Errorf("unsupported signature scheme %s", cfg.scheme) } @@ -41,6 +53,10 @@ func sign(cfg *cfg, key *ecdsa.PrivateKey, data []byte) ([]byte, error) { return crypto.Sign(key, data) case refs.ECDSA_RFC6979_SHA256: return crypto.SignRFC6979(key, data) + case refs.ECDSA_RFC6979_SHA256_WALLET_CONNECT: + buf := make([]byte, base64.StdEncoding.EncodedLen(len(data))) + base64.StdEncoding.Encode(buf, data) + return walletconnect.Sign(key, buf) default: panic(fmt.Sprintf("unsupported scheme %s", cfg.scheme)) } @@ -59,3 +75,9 @@ func WithBuffer(buf []byte) SignOption { c.buffer = buf } } + +func SignWithWalletConnect() SignOption { + return func(c *cfg) { + c.scheme = refs.ECDSA_RFC6979_SHA256_WALLET_CONNECT + } +} diff --git a/util/signature/sign_test.go b/util/signature/sign_test.go new file mode 100644 index 00000000..11ed442e --- /dev/null +++ b/util/signature/sign_test.go @@ -0,0 +1,44 @@ +package signature + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + + "github.com/nspcc-dev/neofs-api-go/v2/refs" + "github.com/stretchr/testify/require" +) + +type testData struct { + data []byte + sig *refs.Signature +} + +func (t testData) SignedDataSize() int { return len(t.data) } +func (t testData) ReadSignedData(data []byte) ([]byte, error) { + n := copy(data, t.data) + return data[:n], nil +} +func (t testData) GetSignature() *refs.Signature { return t.sig } +func (t *testData) SetSignature(s *refs.Signature) { t.sig = s } + +func TestWalletConnect(t *testing.T) { + testCases := [...][]byte{ + {}, + {0}, + {1, 2}, + {3, 4, 5}, + {6, 7, 8, 9, 10, 11, 12}, + } + + pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + for _, tc := range testCases { + td := &testData{data: tc} + require.NoError(t, SignData(pk, td, SignWithWalletConnect())) + require.Equal(t, refs.ECDSA_RFC6979_SHA256_WALLET_CONNECT, td.sig.GetScheme()) + require.NoError(t, VerifyData(td)) + } +} diff --git a/util/signature/walletconnect/sign.go b/util/signature/walletconnect/sign.go new file mode 100644 index 00000000..8c49fece --- /dev/null +++ b/util/signature/walletconnect/sign.go @@ -0,0 +1,142 @@ +package walletconnect + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/binary" + "encoding/hex" + + crypto "github.com/nspcc-dev/neofs-crypto" +) + +const ( + // saltSize is the salt size added to signed message. + saltSize = 16 + // signatureLen is the length of RFC6979 signature. + signatureLen = 64 +) + +// SignedMessage contains mirrors `SignedMessage` struct from the WalletConnect API. +// https://neon.coz.io/wksdk/core/modules.html#SignedMessage +type SignedMessage struct { + Data []byte + Message []byte + PublicKey []byte + Salt []byte +} + +// Sign signs message using WalletConnect API. The returned signature +// contains RFC6979 signature and 16-byte salt. +func Sign(p *ecdsa.PrivateKey, msg []byte) ([]byte, error) { + sm, err := SignMessage(p, msg) + if err != nil { + return nil, err + } + return append(sm.Data, sm.Salt...), nil +} + +// Verify verifies message using WalletConnect API. +func Verify(p *ecdsa.PublicKey, data, sign []byte) bool { + if len(sign) != signatureLen+saltSize { + return false + } + + salt := sign[signatureLen:] + return VerifyMessage(p, SignedMessage{ + Data: sign[:signatureLen], + Message: createMessageWithSalt(data, salt), + Salt: salt, + }) +} + +// SignMessage signs message with a private key and returns structure similar to +// `signMessage` of the WalletConnect API. +// https://github.com/CityOfZion/wallet-connect-sdk/blob/89c236b/packages/wallet-connect-sdk-core/src/index.ts#L496 +// https://github.com/CityOfZion/neon-wallet/blob/1174a9388480e6bbc4f79eb13183c2a573f67ca8/app/context/WalletConnect/helpers.js#L133 +func SignMessage(p *ecdsa.PrivateKey, msg []byte) (SignedMessage, error) { + var salt [saltSize]byte + _, _ = rand.Read(salt[:]) + + msg = createMessageWithSalt(msg, salt[:]) + sign, err := crypto.SignRFC6979(p, msg) + if err != nil { + return SignedMessage{}, err + } + + return SignedMessage{ + Data: sign, + Message: msg, + PublicKey: elliptic.MarshalCompressed(p.Curve, p.X, p.Y), + Salt: salt[:], + }, nil +} + +// VerifyMessage verifies message with a private key and returns structure similar to +// `verifyMessage` of WalletConnect API. +// https://github.com/CityOfZion/wallet-connect-sdk/blob/89c236b/packages/wallet-connect-sdk-core/src/index.ts#L515 +// https://github.com/CityOfZion/neon-wallet/blob/1174a9388480e6bbc4f79eb13183c2a573f67ca8/app/context/WalletConnect/helpers.js#L147 +func VerifyMessage(p *ecdsa.PublicKey, m SignedMessage) bool { + if p == nil { + x, y := elliptic.UnmarshalCompressed(elliptic.P256(), m.PublicKey) + if x == nil || y == nil { + return false + } + p = &ecdsa.PublicKey{ + Curve: elliptic.P256(), + X: x, + Y: y, + } + } + return crypto.VerifyRFC6979(p, m.Message, m.Data) == nil +} + +func createMessageWithSalt(msg, salt []byte) []byte { + // 4 byte prefix + length of the message with salt in bytes + + // + salt + message + 2 byte postfix. + saltedLen := hex.EncodedLen(len(salt)) + len(msg) + data := make([]byte, 4+getVarIntSize(saltedLen)+saltedLen+2) + + n := copy(data, []byte{0x01, 0x00, 0x01, 0xf0}) // fixed prefix + n += putVarUint(data[n:], uint64(saltedLen)) // salt is hex encoded, double its size + n += hex.Encode(data[n:], salt[:]) // for some reason we encode salt in hex + n += copy(data[n:], msg) + copy(data[n:], []byte{0x00, 0x00}) + + return data +} + +// Following functions are copied from github.com/nspcc-dev/neo-go/pkg/io package +// to avoid having another dependency. + +// getVarIntSize returns the size in number of bytes of a variable integer. +// Reference: https://github.com/neo-project/neo/blob/26d04a642ac5a1dd1827dabf5602767e0acba25c/src/neo/IO/Helper.cs#L131 +func getVarIntSize(value int) int { + var size uintptr + + if value < 0xFD { + size = 1 // unit8 + } else if value <= 0xFFFF { + size = 3 // byte + uint16 + } else { + size = 5 // byte + uint32 + } + return int(size) +} + +// putVarUint puts val in varint form to the pre-allocated buffer. +func putVarUint(data []byte, val uint64) int { + if val < 0xfd { + data[0] = byte(val) + return 1 + } + if val <= 0xFFFF { + data[0] = byte(0xfd) + binary.LittleEndian.PutUint16(data[1:], uint16(val)) + return 3 + } + + data[0] = byte(0xfe) + binary.LittleEndian.PutUint32(data[1:], uint32(val)) + return 5 +} diff --git a/util/signature/walletconnect/sign_test.go b/util/signature/walletconnect/sign_test.go new file mode 100644 index 00000000..b83e79fe --- /dev/null +++ b/util/signature/walletconnect/sign_test.go @@ -0,0 +1,113 @@ +package walletconnect + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSignMessage(t *testing.T) { + p1, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + msg := []byte("NEO") + result, err := SignMessage(p1, msg) + require.NoError(t, err) + require.Equal(t, elliptic.MarshalCompressed(elliptic.P256(), p1.PublicKey.X, p1.PublicKey.Y), result.PublicKey) + require.Equal(t, saltSize, len(result.Salt)) + require.Equal(t, 64, len(result.Data)) + require.Equal(t, 4+1+16*2+3+2, len(result.Message)) + + require.True(t, VerifyMessage(&p1.PublicKey, result)) + + t.Run("invalid public key", func(t *testing.T) { + p2, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + require.False(t, VerifyMessage(&p2.PublicKey, result)) + }) + t.Run("invalid signature", func(t *testing.T) { + result := result + result.Data[0] ^= 0xFF + require.False(t, VerifyMessage(&p1.PublicKey, result)) + }) +} + +func TestSign(t *testing.T) { + p1, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + msg := []byte("NEO") + sign, err := Sign(p1, msg) + require.NoError(t, err) + require.True(t, Verify(&p1.PublicKey, msg, sign)) + + t.Run("invalid public key", func(t *testing.T) { + p2, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + require.False(t, Verify(&p2.PublicKey, msg, sign)) + }) + t.Run("invalid signature", func(t *testing.T) { + sign[0] ^= 0xFF + require.False(t, Verify(&p1.PublicKey, msg, sign)) + }) +} + +func TestVerifyNeonWallet(t *testing.T) { + testCases := [...]struct { + publicKey string + data string + salt string + messageHex string + messageOriginal string + }{ + { // Test values from this GIF https://github.com/CityOfZion/neon-wallet/pull/2390 . + publicKey: "02ce6228ba2cb2fc235be93aff9cd5fc0851702eb9791552f60db062f01e3d83f6", + data: "90ab1886ca0bece59b982d9ade8f5598065d651362fb9ce45ad66d0474b89c0b80913c8f0118a282acbdf200a429ba2d81bc52534a53ab41a2c6dfe2f0b4fb1b", + salt: "d41e348afccc2f3ee45cd9f5128b16dc", + messageHex: "010001f05c6434316533343861666363633266336565343563643966353132386231366463436172616c686f2c206d756c65712c206f2062616775697520656820697373756d65726d6f2074616978206c696761646f206e61206d697373e36f3f0000", + messageOriginal: "436172616c686f2c206d756c65712c206f2062616775697520656820697373756d65726d6f2074616978206c696761646f206e61206d697373e36f3f", + }, + { // Test value from wallet connect integration test + publicKey: "03bd9108c0b49f657e9eee50d1399022bd1e436118e5b7529a1b7cd606652f578f", + data: "510caa8cb6db5dedf04d215a064208d64be7496916d890df59aee132db8f2b07532e06f7ea664c4a99e3bcb74b43a35eb9653891b5f8701d2aef9e7526703eaa", + salt: "2c5b189569e92cce12e1c640f23e83ba", + messageHex: "010001f02632633562313839353639653932636365313265316336343066323365383362613132333435360000", + messageOriginal: "313233343536", // ascii string "123456" + }, + { // Test value from wallet connect integration test + publicKey: "03bd9108c0b49f657e9eee50d1399022bd1e436118e5b7529a1b7cd606652f578f", + data: "1e13f248962d8b3b60708b55ddf448d6d6a28c6b43887212a38b00bf6bab695e61261e54451c6e3d5f1f000e5534d166c7ca30f662a296d3a9aafa6d8c173c01", + salt: "58c86b2e74215b4f36b47d731236be3b", + messageHex: "010001f02035386338366232653734323135623466333662343764373331323336626533620000", + messageOriginal: "", // empty string + }, + } + + for _, testCase := range testCases { + rawPub, err := hex.DecodeString(testCase.publicKey) + require.NoError(t, err) + data, err := hex.DecodeString(testCase.data) + require.NoError(t, err) + salt, err := hex.DecodeString(testCase.salt) + require.NoError(t, err) + msg, err := hex.DecodeString(testCase.messageHex) + require.NoError(t, err) + orig, err := hex.DecodeString(testCase.messageOriginal) + require.NoError(t, err) + + require.Equal(t, msg, createMessageWithSalt(orig, salt)) + + sm := SignedMessage{ + Data: data, + Message: msg, + PublicKey: rawPub, + Salt: salt, + } + require.True(t, VerifyMessage(nil, sm)) + } + +}