Skip to content

Commit

Permalink
[secp256k1] Add ability to recover public key from signature
Browse files Browse the repository at this point in the history
  • Loading branch information
gregnazario committed Nov 23, 2024
1 parent 5c28851 commit ed18c1e
Show file tree
Hide file tree
Showing 5 changed files with 179 additions and 44 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
124 changes: 92 additions & 32 deletions crypto/secp256k1.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
//
Expand All @@ -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
}
Expand All @@ -49,7 +49,7 @@ func GenerateSecp256k1Key() (*Secp256k1PrivateKey, error) {
// - [MessageSigner]
func (key *Secp256k1PrivateKey) VerifyingKey() VerifyingKey {
return &Secp256k1PublicKey{
&key.Inner.PublicKey,
key.Inner.PubKey(),
}
}

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
}

Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -179,15 +167,15 @@ 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
//
// 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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand Down
77 changes: 77 additions & 0 deletions crypto/secp256k1_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
6 changes: 2 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
11 changes: 3 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand Down

0 comments on commit ed18c1e

Please sign in to comment.