diff --git a/CHANGELOG.md b/CHANGELOG.md index c95a9f2..c1ca550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ adheres to the format set out by [Keep a Changelog](https://keepachangelog.com/e # Unreleased +- [`Breaking`] Change from the `go-ethereum/crypto` to `decred/dcrd` for secp256k1 signing +- [`Breaking`] Add checks for malleability to prevent duplicate secp256k1 signatures in verification and to ensure + correct on-chain behavior +- Adds functionality to recover public keys from secp256k1 signatures + # v1.2.0 (11/15/2024) - [`Fix`][`Breaking`] Fix MultiKey implementation to be more consistent with the rest of the SDKs diff --git a/crypto/secp256k1.go b/crypto/secp256k1.go index 1dc4868..2803094 100644 --- a/crypto/secp256k1.go +++ b/crypto/secp256k1.go @@ -1,11 +1,11 @@ package crypto import ( - "crypto/ecdsa" "fmt" "github.com/aptos-labs/aptos-go-sdk/bcs" "github.com/aptos-labs/aptos-go-sdk/internal/util" - ethCrypto "github.com/ethereum/go-ethereum/crypto" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" ) //region Secp256k1PrivateKey @@ -17,7 +17,7 @@ const Secp256k1PrivateKeyLength = 32 const Secp256k1PublicKeyLength = 65 // Secp256k1SignatureLength is the [Secp256k1Signature] length in bytes. It is a signature without the recovery bit. -const Secp256k1SignatureLength = ethCrypto.SignatureLength - 1 +const Secp256k1SignatureLength = 64 // Secp256k1PrivateKey is a private key that can be used with [SingleSigner]. It cannot stand on its own. // @@ -28,12 +28,12 @@ const Secp256k1SignatureLength = ethCrypto.SignatureLength - 1 // - [bcs.Unmarshaler] // - [bcs.Struct] type Secp256k1PrivateKey struct { - Inner *ecdsa.PrivateKey // Inner is the actual private key + Inner *secp256k1.PrivateKey // Inner is the actual private key } // GenerateSecp256k1Key generates a new [Secp256k1PrivateKey] func GenerateSecp256k1Key() (*Secp256k1PrivateKey, error) { - priv, err := ethCrypto.GenerateKey() + priv, err := secp256k1.GeneratePrivateKey() if err != nil { return nil, err } @@ -49,7 +49,7 @@ func GenerateSecp256k1Key() (*Secp256k1PrivateKey, error) { // - [MessageSigner] func (key *Secp256k1PrivateKey) VerifyingKey() VerifyingKey { return &Secp256k1PublicKey{ - &key.Inner.PublicKey, + key.Inner.PubKey(), } } @@ -67,16 +67,8 @@ func (key *Secp256k1PrivateKey) EmptySignature() Signature { // - [MessageSigner] func (key *Secp256k1PrivateKey) SignMessage(msg []byte) (sig Signature, err error) { hash := util.Sha3256Hash([][]byte{msg}) - // TODO: The eth library doesn't protect against malleability issues, so we need to handle those. - signature, err := ethCrypto.Sign(hash, key.Inner) - if err != nil { - return nil, err - } - - // Strip the recovery bit - secpSig := &Secp256k1Signature{} - copy(secpSig.Inner[:], signature[:Secp256k1SignatureLength]) - return secpSig, nil + signature := ecdsa.Sign(key.Inner, hash) + return &Secp256k1Signature{signature}, nil } //endregion @@ -88,7 +80,7 @@ func (key *Secp256k1PrivateKey) SignMessage(msg []byte) (sig Signature, err erro // Implements: // - [CryptoMaterial] func (key *Secp256k1PrivateKey) Bytes() []byte { - return ethCrypto.FromECDSA(key.Inner) + return key.Inner.Serialize() } // FromBytes populates the [Secp256k1PrivateKey] from bytes @@ -101,11 +93,7 @@ func (key *Secp256k1PrivateKey) FromBytes(bytes []byte) (err error) { if len(bytes) != Secp256k1PrivateKeyLength { return fmt.Errorf("invalid secp256k1 private key size %d", len(bytes)) } - newKey, err := ethCrypto.ToECDSA(bytes) - if err != nil { - return err - } - key.Inner = newKey + key.Inner = secp256k1.PrivKeyFromBytes(bytes) return nil } @@ -148,7 +136,7 @@ func (key *Secp256k1PrivateKey) FromHex(hexStr string) (err error) { // - [bcs.Unmarshaler] // - [bcs.Struct] type Secp256k1PublicKey struct { - Inner *ecdsa.PublicKey // Inner is the actual public key + Inner *secp256k1.PublicKey // Inner is the actual public key } //region Secp256k1PublicKey VerifyingKey @@ -163,8 +151,8 @@ func (key *Secp256k1PublicKey) Verify(msg []byte, sig Signature) bool { switch sig := sig.(type) { case *Secp256k1Signature: // Verification requires to pass the SHA-256 hash of the message - msg = util.Sha3256Hash([][]byte{msg}) - return ethCrypto.VerifySignature(key.Bytes(), msg, sig.Bytes()) + hash := util.Sha3256Hash([][]byte{msg}) + return sig.Inner.Verify(hash, key.Inner) default: return false } @@ -179,7 +167,7 @@ func (key *Secp256k1PublicKey) Verify(msg []byte, sig Signature) bool { // Implements: // - [CryptoMaterial] func (key *Secp256k1PublicKey) Bytes() []byte { - return ethCrypto.FromECDSAPub(key.Inner) + return key.Inner.SerializeUncompressed() } // FromBytes sets the [Secp256k1PublicKey] to the given bytes @@ -187,7 +175,7 @@ func (key *Secp256k1PublicKey) Bytes() []byte { // Implements: // - [CryptoMaterial] func (key *Secp256k1PublicKey) FromBytes(bytes []byte) (err error) { - newKey, err := ethCrypto.UnmarshalPubkey(bytes) + newKey, err := secp256k1.ParsePubKey(bytes) if err != nil { return err } @@ -233,7 +221,8 @@ func (key *Secp256k1PublicKey) MarshalBCS(ser *bcs.Serializer) { // - [bcs.Unmarshaler] func (key *Secp256k1PublicKey) UnmarshalBCS(des *bcs.Deserializer) { kb := des.ReadBytes() - pubKey, err := ethCrypto.UnmarshalPubkey(kb) + pubKey, err := secp256k1.ParsePubKey(kb) + if err != nil { des.SetError(err) return @@ -326,17 +315,71 @@ func (ea *Secp256k1Authenticator) UnmarshalBCS(des *bcs.Deserializer) { // - [bcs.Unmarshaler] // - [bcs.Struct] type Secp256k1Signature struct { - Inner [Secp256k1SignatureLength]byte // Inner is the actual signature + Inner *ecdsa.Signature // Inner is the actual signature +} + +// RecoverPublicKey recovers the public key from the signature and message +// +// If you know the recovery bit (0-4), please provide it, otherwise, use [RecoverSecp256k1PublicKeyWithAuthenticationKey] +// +// Note that this only applies to an [Secp256k1Signature], all other signatures are not recoverable +func (e *Secp256k1Signature) RecoverPublicKey(message []byte, recoveryBit byte) (pubKey *Secp256k1PublicKey, err error) { + hash := util.Sha3256Hash([][]byte{message}) + return e.recoverSecp256k1PublicKey(hash, recoveryBit) +} + +// RecoverSecp256k1PublicKeyWithAuthenticationKey recovers the public key from the signature and message, and checks if it matches the authentication key +// +// Note that, the authentication key may be an address, but if the authentication key was rotated it will differ from the address +func (e *Secp256k1Signature) RecoverSecp256k1PublicKeyWithAuthenticationKey(message []byte, authKey *AuthenticationKey) (pubKey *Secp256k1PublicKey, err error) { + hash := util.Sha3256Hash([][]byte{message}) + + for i := byte(0); i < byte(4); i++ { + key, err := e.recoverSecp256k1PublicKey(hash, i) + if err != nil { + continue + } + + // Check if the public key matches the authentication key + anyPubKey, err := ToAnyPublicKey(key) + if err != nil { + continue + } + + if *anyPubKey.AuthKey() == *authKey { + return key, nil + } + } + + return nil, fmt.Errorf("unable to recover public key from signature") +} + +// / recoverSecp256k1PublicKey recovers the public key from the signature and message by building up the magic byte +func (e *Secp256k1Signature) recoverSecp256k1PublicKey(messageHash []byte, recoveryBit byte) (pubKey *Secp256k1PublicKey, err error) { + // Append magic 27 because of bitcoin, and the recovery byte in front + sigWithRecovery := append([]byte{byte(recoveryBit) + 27}, e.Bytes()...) + publicKey, _, err := ecdsa.RecoverCompact(sigWithRecovery, messageHash) + if err != nil { + return nil, err + } + + return &Secp256k1PublicKey{Inner: publicKey}, nil } //region Secp256k1Signature CryptoMaterial -// Bytes returns the raw bytes of the [Secp256k1Signature] +// Bytes returns the raw bytes of the [Secp256k1Signature] without a recovery bit. +// It's used for signing and verification. // // Implements: // - [CryptoMaterial] func (e *Secp256k1Signature) Bytes() []byte { - return e.Inner[:] + r := e.Inner.R() + s := e.Inner.S() + rBytes := r.Bytes() + sBytes := s.Bytes() + // Strips the recovery bit + return append(rBytes[:], sBytes[:]...) } // FromBytes sets the [Secp256k1Signature] to the given bytes @@ -349,7 +392,24 @@ func (e *Secp256k1Signature) FromBytes(bytes []byte) (err error) { if len(bytes) != Secp256k1SignatureLength { return fmt.Errorf("invalid secp256k1 signature size %d, expected %d", len(bytes), Secp256k1SignatureLength) } - copy(e.Inner[:], bytes) + var rBytes [32]byte + copy(rBytes[:], bytes[0:32]) + var sBytes [32]byte + copy(sBytes[:], bytes[32:64]) + + r := &secp256k1.ModNScalar{} + r.SetBytes(&rBytes) + s := &secp256k1.ModNScalar{} + s.SetBytes(&sBytes) + + signature := ecdsa.NewSignature(r, s) + + // Checks order of s to be low + sTyped := signature.S() + if sTyped.IsOverHalfOrder() { + return fmt.Errorf("invalid secp256k1 signature: s is over half order") + } + e.Inner = signature return nil } diff --git a/crypto/secp256k1_test.go b/crypto/secp256k1_test.go index 0d8322f..26b73a8 100644 --- a/crypto/secp256k1_test.go +++ b/crypto/secp256k1_test.go @@ -122,3 +122,80 @@ func TestGenerateSecp256k1Key(t *testing.T) { assert.True(t, privateKey.VerifyingKey().Verify(msg, sig)) } + +func TestSecp256k1Signature_RecoverPublicKey(t *testing.T) { + privateKey := &Secp256k1PrivateKey{} + err := privateKey.FromHex(testSecp256k1PrivateKey) + assert.NoError(t, err) + message := []byte("hello") + + signature, err := privateKey.SignMessage(message) + assert.NoError(t, err) + + // Recover the public key + recoveredKey, err := signature.(*Secp256k1Signature).RecoverPublicKey(message, 1) + assert.NoError(t, err) + + // Verify the signature with the key + assert.True(t, recoveredKey.Verify(message, signature)) + assert.Equal(t, privateKey.VerifyingKey().ToHex(), recoveredKey.ToHex()) + + // Also try with the recovery bit attached to the signature + anyPubKey, err := ToAnyPublicKey(privateKey.VerifyingKey()) + assert.NoError(t, err) + recoveredKey2, err := signature.(*Secp256k1Signature).RecoverSecp256k1PublicKeyWithAuthenticationKey(message, anyPubKey.AuthKey()) + assert.NoError(t, err) + assert.Equal(t, privateKey.VerifyingKey().ToHex(), recoveredKey2.ToHex()) +} + +func TestSecp256k1Signature_RecoverPublicKeyFromSignature(t *testing.T) { + privateKey := &Secp256k1PrivateKey{} + err := privateKey.FromHex(testSecp256k1PrivateKey) + assert.NoError(t, err) + publicKey := &Secp256k1PublicKey{} + err = publicKey.FromHex(testSecp256k1PublicKey) + assert.NoError(t, err) + message, err := util.ParseHex(testSecp256k1MessageEncoded) + assert.NoError(t, err) + + assert.Equal(t, publicKey.ToHex(), privateKey.VerifyingKey().ToHex()) + + signature := &Secp256k1Signature{} + err = signature.FromHex(testSecp256k1Signature) + assert.NoError(t, err) + + // Recover the public key + recoveryBit := byte(0) + recoveredKey, err := signature.RecoverPublicKey(message, recoveryBit) + assert.NoError(t, err) + + // Verify the signature with the key + assert.True(t, recoveredKey.Verify(message, signature)) + assert.Equal(t, publicKey.ToHex(), recoveredKey.ToHex()) +} + +func TestSecp256k1Signature_RecoverPublicKeyFromSignatureWithRecoveryBit(t *testing.T) { + privateKey := &Secp256k1PrivateKey{} + err := privateKey.FromHex(testSecp256k1PrivateKey) + assert.NoError(t, err) + publicKey := &Secp256k1PublicKey{} + err = publicKey.FromHex(testSecp256k1PublicKey) + assert.NoError(t, err) + message, err := util.ParseHex(testSecp256k1MessageEncoded) + assert.NoError(t, err) + + assert.Equal(t, publicKey.ToHex(), privateKey.VerifyingKey().ToHex()) + + signature := &Secp256k1Signature{} + err = signature.FromHex(testSecp256k1Signature) + assert.NoError(t, err) + + // Recover the public key + recoveryBit := byte(0) + recoveredKey, err := signature.RecoverPublicKey(message, recoveryBit) + assert.NoError(t, err) + + // Verify the signature with the key + assert.True(t, recoveredKey.Verify(message, signature)) + assert.Equal(t, publicKey.ToHex(), recoveredKey.ToHex()) +} diff --git a/go.mod b/go.mod index d002800..d48c9c2 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.22 require ( github.com/cucumber/godog v0.14.1 - github.com/ethereum/go-ethereum v1.14.5 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 github.com/hasura/go-graphql-client v0.12.1 github.com/hdevalence/ed25519consensus v0.2.0 github.com/stretchr/testify v1.9.0 @@ -13,17 +13,15 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect - github.com/btcsuite/btcd/btcec/v2 v2.3.3 // indirect github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect github.com/cucumber/messages/go/v21 v21.0.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect github.com/gofrs/uuid v4.3.1+incompatible // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-memdb v1.3.4 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect - github.com/holiman/uint256 v1.2.4 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/spf13/pflag v1.0.5 // indirect diff --git a/go.sum b/go.sum index 988307e..630ebc6 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,7 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/btcsuite/btcd/btcec/v2 v2.3.3 h1:6+iXlDKE8RMtKsvK0gshlXIuPbyWM/h84Ensb7o3sC0= -github.com/btcsuite/btcd/btcec/v2 v2.3.3/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= -github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U= -github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI= github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0= github.com/cucumber/godog v0.14.1 h1:HGZhcOyyfaKclHjJ+r/q93iaTJZLKYW6Tv3HkmUE6+M= @@ -20,8 +17,6 @@ github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5il github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= -github.com/ethereum/go-ethereum v1.14.5 h1:szuFzO1MhJmweXjoM5nSAeDvjNUH3vIQoMzzQnfvjpw= -github.com/ethereum/go-ethereum v1.14.5/go.mod h1:VEDGGhSxY7IEjn98hJRFXl/uFvpRgbIIf2PpXiyGGgc= github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI= github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= @@ -42,8 +37,6 @@ github.com/hasura/go-graphql-client v0.12.1 h1:tL+BCoyubkYYyaQ+tJz+oPe/pSxYwOJHw github.com/hasura/go-graphql-client v0.12.1/go.mod h1:F4N4kR6vY8amio3gEu3tjSZr8GPOXJr3zj72DKixfLE= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= -github.com/holiman/uint256 v1.2.4 h1:jUc4Nk8fm9jZabQuqr2JzednajVmBpC+oiTiXZJEApU= -github.com/holiman/uint256 v1.2.4/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -52,9 +45,11 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=