diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a3c7bbb..750b687 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,8 +1,6 @@ name: Test on: push: - branches: - - master pull_request: jobs: @@ -13,12 +11,8 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-go@v4 with: - go-version: '1.18' + go-version: '1.21' - name: test & coverage report creation run: | go test common.go -mod=readonly -timeout 5m -short -race -coverprofile=coverage.txt -covermode=atomic go test common.go -mod=readonly -timeout 5m - - uses: codecov/codecov-action@v3.1.1 - with: - file: ./coverage.txt - fail_ci_if_error: true diff --git a/LICENSE b/LICENSE index 5f0220d..cde15f2 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2018 - 2022 ZondaX AG + Copyright 2018 - 2023 ZondaX AG Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 45bd224..c884f61 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,22 @@ # ledger-cosmos-go +![zondax_light](docs/zondax_light.png#gh-light-mode-only) +![zondax_dark](docs/zondax_dark.png#gh-dark-mode-only) + [![Test](https://github.com/cosmos/ledger-cosmos-go/actions/workflows/test.yml/badge.svg)](https://github.com/cosmos/ledger-cosmos-go/actions/workflows/test.yml) [![Build status](https://ci.appveyor.com/api/projects/status/ovpfx35t289n3403?svg=true)](https://ci.appveyor.com/project/cosmos/ledger-cosmos-go) -This project is work in progress. Some aspects are subject to change. +This package provides a basic client library to communicate with a Cosmos App running in a Ledger Nano S/S+/X device + +| Operation | Response | Command | +| -------------------- | --------------------------- | -------------------------------- | +| GetVersion | app version | --------------- | +| GetAddressAndPubKey | pubkey + address | HRP + HDPath + ShowInDevice | +| Sign | signature in DER format | HDPath + HRP + SignMode* + Message | + +*Available sign modes for Cosmos app: SignModeAmino (0) | SignModeTextual (1) + + +# Who we are? -# Get source -Apart from cloning, be sure you install dep dependency management tool -https://github.com/golang/dep +We are Zondax, a company pioneering blockchain services. If you want to know more about us, please visit us at [zondax.ch](https://zondax.ch) diff --git a/common.go b/common.go index debd8f7..429069c 100644 --- a/common.go +++ b/common.go @@ -1,5 +1,5 @@ /******************************************************************************* -* (c) 2018 - 2022 ZondaX AG +* (c) 2018 - 2023 ZondaX AG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,89 +18,81 @@ package ledger_cosmos_go import ( "encoding/binary" + "errors" "fmt" + "strconv" + "strings" ) -// VersionInfo contains app version information -type VersionInfo struct { - AppMode uint8 - Major uint8 - Minor uint8 - Patch uint8 +type VersionResponse struct { + AppMode uint8 // 0: release | 0xFF: debug + Major uint8 + Minor uint8 + Patch uint8 + AppLocked uint8 + TargetId uint32 } -func (c VersionInfo) String() string { - return fmt.Sprintf("%d.%d.%d", c.Major, c.Minor, c.Patch) -} - -// VersionRequiredError the command is not supported by this app -type VersionRequiredError struct { - Found VersionInfo - Required VersionInfo +type AddressResponse struct { + pubkey []byte + address string } -func (e VersionRequiredError) Error() string { - return fmt.Sprintf("App Version required %s - Version found: %s", e.Required, e.Found) +type SignatureResponse struct { + signatureDER []byte } -func NewVersionRequiredError(req VersionInfo, ver VersionInfo) error { - return &VersionRequiredError{ - Found: ver, - Required: req, - } +func (c VersionResponse) String() string { + return fmt.Sprintf("%d.%d.%d", c.Major, c.Minor, c.Patch) } -// CheckVersion compares the current version with the required version -func CheckVersion(ver VersionInfo, req VersionInfo) error { - if ver.Major != req.Major { - if ver.Major > req.Major { - return nil - } - return NewVersionRequiredError(req, ver) +// Validate HRP: Max length = 83 +// All characters must be in range [33, 126], displayable chars in Ledger devices +func serializeHRP(hrp string) (hrpBytes []byte, err error) { + if len(hrp) > HRP_MAX_LENGTH { + return nil, errors.New("HRP len should be <= 83") } - if ver.Minor != req.Minor { - if ver.Minor > req.Minor { - return nil + hrpBytes = []byte(hrp) + for _, b := range hrpBytes { + if b < MIN_DISPLAYABLE_CHAR || b > MAX_DISPLAYABLE_CHAR { + return nil, errors.New("all characters in the HRP must be in the [33, 126] range") } - return NewVersionRequiredError(req, ver) } - if ver.Patch >= req.Patch { - return nil - } - return NewVersionRequiredError(req, ver) + return hrpBytes, nil } -func GetBip32bytesv1(bip32Path []uint32, hardenCount int) ([]byte, error) { - message := make([]byte, 41) - if len(bip32Path) > 10 { - return nil, fmt.Errorf("maximum bip32 depth = 10") +func serializePath(path string) (pathBytes []byte, err error) { + if !strings.HasPrefix(path, "m/") { + return nil, errors.New(`path should start with "m/" (e.g "m/44'/118'/0'/0/3")`) } - message[0] = byte(len(bip32Path)) - for index, element := range bip32Path { - pos := 1 + index*4 - value := element - if index < hardenCount { - value = 0x80000000 | element - } - binary.LittleEndian.PutUint32(message[pos:], value) - } - return message, nil -} -func GetBip32bytesv2(bip44Path []uint32, hardenCount int) ([]byte, error) { - message := make([]byte, 20) - if len(bip44Path) != 5 { - return nil, fmt.Errorf("path should contain 5 elements") + pathArray := strings.Split(path, "/") + pathArray = pathArray[1:] // remove "m" + + if len(pathArray) != DEFAULT_PATH_LENGTH { + return nil, errors.New("invalid path: it must contain 5 elements") } - for index, element := range bip44Path { - pos := index * 4 - value := element - if index < hardenCount { - value = 0x80000000 | element + + // Reserve 20 bytes for serialized path + buffer := make([]byte, 4*len(pathArray)) + + for i, child := range pathArray { + value := 0 + if strings.HasSuffix(child, "'") { + value += HARDENED + child = strings.TrimSuffix(child, "'") + } + numChild, err := strconv.Atoi(child) + if err != nil { + return nil, fmt.Errorf("invalid path : %s is not a number (e.g \"m/44'/118'/0'/0/3\")", child) + } + if numChild >= HARDENED { + return nil, errors.New("incorrect child value (bigger or equal to 0x80000000)") } - binary.LittleEndian.PutUint32(message[pos:], value) + value += numChild + binary.LittleEndian.PutUint32(buffer[i*4:], uint32(value)) } - return message, nil + return buffer, nil } diff --git a/common_test.go b/common_test.go index 9ab487d..7ee6593 100644 --- a/common_test.go +++ b/common_test.go @@ -1,5 +1,5 @@ /******************************************************************************* -* (c) 2018 - 2022 Zondax AG +* (c) 2018 - 2023 Zondax AG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,20 +18,24 @@ package ledger_cosmos_go import ( "fmt" - "github.com/stretchr/testify/assert" "testing" + + "github.com/stretchr/testify/assert" ) func Test_PrintVersion(t *testing.T) { - reqVersion := VersionInfo{0, 1, 2, 3} + reqVersion := VersionResponse{0, 1, 2, 3, 0, 0x12345678} s := fmt.Sprintf("%v", reqVersion) assert.Equal(t, "1.2.3", s) -} -func Test_PathGeneration0(t *testing.T) { - bip32Path := []uint32{44, 100, 0, 0, 0} + reqVersion = VersionResponse{0, 0, 0, 0, 0, 0} + s = fmt.Sprintf("%v", reqVersion) + assert.Equal(t, "0.0.0", s) +} - pathBytes, err := GetBip32bytesv1(bip32Path, 0) +func Test_SerializePath0(t *testing.T) { + path := "m/44'/100'/0/0/0" + pathBytes, err := serializePath(path) if err != nil { t.Fatalf("Detected error, err: %s\n", err.Error()) @@ -41,45 +45,36 @@ func Test_PathGeneration0(t *testing.T) { assert.Equal( t, - 41, + 20, len(pathBytes), - "PathBytes has wrong length: %x, expected length: %x\n", pathBytes, 41) + "PathBytes has wrong length: %x, expected length: %x\n", pathBytes, 20) assert.Equal( t, - "052c000000640000000000000000000000000000000000000000000000000000000000000000000000", + "2c00008064000080000000000000000000000000", fmt.Sprintf("%x", pathBytes), "Unexpected PathBytes\n") } -func Test_PathGeneration2(t *testing.T) { - bip32Path := []uint32{44, 118, 0, 0, 0} - - pathBytes, err := GetBip32bytesv1(bip32Path, 2) - - if err != nil { - t.Fatalf("Detected error, err: %s\n", err.Error()) - } +func Test_SerializePath_EmptyString(t *testing.T) { + path := "" + pathBytes, err := serializePath(path) - fmt.Printf("Path: %x\n", pathBytes) + assert.NotNil(t, err, "Expected error for empty path, got nil") + assert.Nil(t, pathBytes, "Expected nil for pathBytes, got non-nil value") +} - assert.Equal( - t, - 41, - len(pathBytes), - "PathBytes has wrong length: %x, expected length: %x\n", pathBytes, 41) +func Test_SerializePath_InvalidPath(t *testing.T) { + path := "invalid_path" + pathBytes, err := serializePath(path) - assert.Equal( - t, - "052c000080760000800000000000000000000000000000000000000000000000000000000000000000", - fmt.Sprintf("%x", pathBytes), - "Unexpected PathBytes\n") + assert.NotNil(t, err, "Expected error for invalid path, got nil") + assert.Nil(t, pathBytes, "Expected nil for pathBytes, got non-nil value") } -func Test_PathGeneration3(t *testing.T) { - bip32Path := []uint32{44, 118, 0, 0, 0} - - pathBytes, err := GetBip32bytesv1(bip32Path, 3) +func Test_SerializePath1(t *testing.T) { + path := "m/44'/118'/0'/0/0" + pathBytes, err := serializePath(path) if err != nil { t.Fatalf("Detected error, err: %s\n", err.Error()) @@ -89,21 +84,20 @@ func Test_PathGeneration3(t *testing.T) { assert.Equal( t, - 41, + 20, len(pathBytes), - "PathBytes has wrong length: %x, expected length: %x\n", pathBytes, 41) + "PathBytes has wrong length: %x, expected length: %x\n", pathBytes, 20) assert.Equal( t, - "052c000080760000800000008000000000000000000000000000000000000000000000000000000000", + "2c00008076000080000000800000000000000000", fmt.Sprintf("%x", pathBytes), "Unexpected PathBytes\n") } -func Test_PathGeneration0v2(t *testing.T) { - bip32Path := []uint32{44, 100, 0, 0, 0} - - pathBytes, err := GetBip32bytesv2(bip32Path, 0) +func Test_SerializePath2(t *testing.T) { + path := "m/44'/60'/0'/0/0" + pathBytes, err := serializePath(path) if err != nil { t.Fatalf("Detected error, err: %s\n", err.Error()) @@ -113,61 +107,75 @@ func Test_PathGeneration0v2(t *testing.T) { assert.Equal( t, - 40, + 20, len(pathBytes), - "PathBytes has wrong length: %x, expected length: %x\n", pathBytes, 40) + "PathBytes has wrong length: %x, expected length: %x\n", pathBytes, 20) assert.Equal( t, - "2c000000640000000000000000000000000000000000000000000000000000000000000000000000", + "2c0000803c000080000000800000000000000000", fmt.Sprintf("%x", pathBytes), "Unexpected PathBytes\n") } -func Test_PathGeneration2v2(t *testing.T) { - bip32Path := []uint32{44, 118, 0, 0, 0} - - pathBytes, err := GetBip32bytesv2(bip32Path, 2) +func Test_SerializeHRP0(t *testing.T) { + hrp := "cosmos" + hrpBytes, err := serializeHRP(hrp) if err != nil { t.Fatalf("Detected error, err: %s\n", err.Error()) } - fmt.Printf("Path: %x\n", pathBytes) + fmt.Printf("HRP: %x\n", hrpBytes) assert.Equal( t, - 40, - len(pathBytes), - "PathBytes has wrong length: %x, expected length: %x\n", pathBytes, 40) + 6, + len(hrpBytes), + "hrpBytes has wrong length: %x, expected length: %x\n", hrpBytes, 6) assert.Equal( t, - "2c000080760000800000000000000000000000000000000000000000000000000000000000000000", - fmt.Sprintf("%x", pathBytes), - "Unexpected PathBytes\n") + "636f736d6f73", + fmt.Sprintf("%x", hrpBytes), + "Unexpected HRPBytes\n") } -func Test_PathGeneration3v2(t *testing.T) { - bip32Path := []uint32{44, 118, 0, 0, 0} +func Test_SerializeHRP_EmptyString(t *testing.T) { + hrp := "" + hrpBytes, err := serializeHRP(hrp) - pathBytes, err := GetBip32bytesv2(bip32Path, 3) + assert.NotNil(t, err, "Expected error for empty hrp, got nil") + assert.Nil(t, hrpBytes, "Expected nil for hrpBytes, got non-nil value") +} + +func Test_SerializeHRP_LongString(t *testing.T) { + hrp := "a_very_long_hrp_that_exceeds_the_maximum_length" + hrpBytes, err := serializeHRP(hrp) + + assert.NotNil(t, err, "Expected error for long hrp, got nil") + assert.Nil(t, hrpBytes, "Expected nil for hrpBytes, got non-nil value") +} + +func Test_SerializeHRP1(t *testing.T) { + hrp := "evmos" + hrpBytes, err := serializeHRP(hrp) if err != nil { t.Fatalf("Detected error, err: %s\n", err.Error()) } - fmt.Printf("Path: %x\n", pathBytes) + fmt.Printf("HRP: %x\n", hrpBytes) assert.Equal( t, - 40, - len(pathBytes), - "PathBytes has wrong length: %x, expected length: %x\n", pathBytes, 40) + 5, + len(hrpBytes), + "hrpBytes has wrong length: %x, expected length: %x\n", hrpBytes, 5) assert.Equal( t, - "2c000080760000800000008000000000000000000000000000000000000000000000000000000000", - fmt.Sprintf("%x", pathBytes), - "Unexpected PathBytes\n") + "65766d6f73", + fmt.Sprintf("%x", hrpBytes), + "Unexpected HRPBytes\n") } diff --git a/constants.go b/constants.go new file mode 100644 index 0000000..1699f05 --- /dev/null +++ b/constants.go @@ -0,0 +1,44 @@ +/******************************************************************************* +* (c) 2018 - 2023 Zondax AG +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +********************************************************************************/ + +package ledger_cosmos_go + +type TxMode byte + +const ( + SignModeAmino TxMode = 0 + SignModeTextual TxMode = 1 + SignModeUnknown TxMode = 2 +) + +const ( + CLA = 0x55 + + INSGetVersion = 0 + INSSign = 2 + INSGetAddressAndPubKey = 4 + + CHUNKSIZE = 250 + + DEFAULT_PATH_LENGTH = 5 +) + +const ( + HRP_MAX_LENGTH = 83 + MIN_DISPLAYABLE_CHAR = 33 + MAX_DISPLAYABLE_CHAR = 126 + HARDENED = 0x80000000 +) diff --git a/docs/zondax_dark.png b/docs/zondax_dark.png new file mode 100644 index 0000000..c14ba36 Binary files /dev/null and b/docs/zondax_dark.png differ diff --git a/docs/zondax_light.png b/docs/zondax_light.png new file mode 100644 index 0000000..3d25fda Binary files /dev/null and b/docs/zondax_light.png differ diff --git a/go.mod b/go.mod index 1593c87..7e1e9d0 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/cosmos/ledger-cosmos-go -go 1.18 +go 1.21 require ( github.com/btcsuite/btcd/btcec/v2 v2.3.2 diff --git a/go.sum b/go.sum index bb9c1eb..2c92d8a 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,7 @@ github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U= github.com/btcsuite/btcd/btcec/v2 v2.3.2/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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -13,6 +14,7 @@ github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/ledger_cosmos.go b/ledger_cosmos.go new file mode 100644 index 0000000..d0ca061 --- /dev/null +++ b/ledger_cosmos.go @@ -0,0 +1,248 @@ +/******************************************************************************* +* (c) 2018 - 2023 ZondaX AG +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +********************************************************************************/ + +package ledger_cosmos_go + +import ( + "errors" + "math" + + ledger_go "github.com/zondax/ledger-go" +) + +type LedgerCosmos struct { + api ledger_go.LedgerDevice + version VersionResponse +} + +// FindLedger finds a Cosmos user app running in a ledger device +func FindLedger() (_ *LedgerCosmos, rerr error) { + ledgerAdmin := ledger_go.NewLedgerAdmin() + ledgerAPI, err := ledgerAdmin.Connect(0) + if err != nil { + return nil, err + } + + defer func() { + if rerr != nil { + ledgerAPI.Close() + } + }() + + app := &LedgerCosmos{ledgerAPI, VersionResponse{}} + _, err = app.GetVersion() + if err != nil { + if err.Error() == "[APDU_CODE_CLA_NOT_SUPPORTED] Class not supported" { + err = errors.New("are you sure the Cosmos app is open?") + } + return nil, err + } + + return app, nil +} + +func (ledger *LedgerCosmos) Close() error { + return ledger.api.Close() +} + +// GetVersion returns the current version of the Ledger Cosmos app +func (ledger *LedgerCosmos) GetVersion() (*VersionResponse, error) { + message := []byte{CLA, INSGetVersion, 0, 0, 0} + response, err := ledger.api.Exchange(message) + + if err != nil { + return nil, err + } + + if len(response) != 9 { + return nil, errors.New("invalid response") + } + + ledger.version = VersionResponse{ + AppMode: response[0], + Major: response[1], + Minor: response[2], + Patch: response[3], + AppLocked: response[4], // SDK won't reply any APDU message if screensaver is active (always false) + TargetId: uint32(response[5]), + } + + return &ledger.version, nil +} + +// GetAddressAndPubKey send INSGetAddressAndPubKey APDU command +// Optional parameter to display the information on the device first + +// Response: +// | Field | Type | Content | +// | ------- | --------- | --------------------- | +// | PK | byte (33) | Compressed Public Key | +// | ADDR | byte (65) | Bech 32 addr | +// | SW1-SW2 | byte (2) | Return code | + +// Devolver Struct + err + +func (ledger *LedgerCosmos) GetAddressAndPubKey(path string, hrp string, requireConfirmation bool) (addressResponse AddressResponse, err error) { + + response := AddressResponse{ + pubkey: nil, // Compressed pubkey + address: "", + } + + // Serialize HRP + hrpBytes, err := serializeHRP(hrp) + if err != nil { + return response, err + } + + // Serialize Path + pathBytes, err := serializePath(path) + if err != nil { + return response, err + } + + p1 := byte(0) + if requireConfirmation { + p1 = byte(1) + } + + // Prepare message + // [header | hrpLen | hrp | hdpath] + header := []byte{CLA, INSGetAddressAndPubKey, p1, 0, 0} + message := append(header, byte(len(hrpBytes))) + message = append(message, hrpBytes...) + message = append(message, pathBytes...) + message[4] = byte(len(message) - len(header)) // Update payload length + + cmdResponse, err := ledger.api.Exchange(message) + + if err != nil { + return response, err + } + + // The command response must have 33 bytes from pubkey + // the HRP and the rest of the address + if 33+len(hrp) > len(cmdResponse) { + return response, errors.New("invalid response length") + } + + // Build response + response.pubkey = cmdResponse[0:33] // Compressed pubkey + response.address = string(cmdResponse[33:]) + + return response, nil +} + +func processFirstChunk(path string, hrp string, txMode TxMode) (message []byte, err error) { + // Serialize hrp + hrpBytes, err := serializeHRP(hrp) + if err != nil { + return nil, err + } + + // Serialize Path + pathBytes, err := serializePath(path) + if err != nil { + return nil, err + } + + // [header | path | hrpLen | hrp] + header := []byte{CLA, INSSign, 0, byte(txMode), 0} + message = append(header, pathBytes...) + message = append(message, byte(len(hrpBytes))) + message = append(message, hrpBytes...) + message[4] = byte(len(message) - len(header)) + + return message, nil +} + +func processErrorResponse(response []byte, responseErr error) (err error) { + // Check if we can get the error code and improve these messages + if responseErr.Error() == "[APDU_CODE_BAD_KEY_HANDLE] The parameters in the data field are incorrect" { + // In this special case, we can extract additional info + errorMsg := string(response) + switch errorMsg { + case "ERROR: JSMN_ERROR_NOMEM": + return errors.New("not enough tokens were provided") + case "PARSER ERROR: JSMN_ERROR_INVAL": + return errors.New("unexpected character in JSON string") + case "PARSER ERROR: JSMN_ERROR_PART": + return errors.New("the JSON string is not a complete") + } + return errors.New(errorMsg) + } + if responseErr.Error() == "[APDU_CODE_DATA_INVALID] Referenced data reversibly blocked (invalidated)" { + errorMsg := string(response) + return errors.New(errorMsg) + } + return responseErr +} + +func (ledger *LedgerCosmos) sign(path string, hrp string, txMode TxMode, transaction []byte) (signatureResponse SignatureResponse, err error) { + var packetCount = byte(math.Ceil(float64(len(transaction)) / float64(CHUNKSIZE))) + var message []byte + + signatureResponse = SignatureResponse{ + signatureDER: nil, + } + + if txMode >= SignModeUnknown { + return signatureResponse, errors.New("at the moment the Ledger app only works with Amino (0) and Textual(1) modes") + } + + // First chunk only contains path & HRP + message, err = processFirstChunk(path, hrp, txMode) + if err != nil { + return signatureResponse, err + } + + _, err = ledger.api.Exchange(message) + if err != nil { + return signatureResponse, err + } + + // Split the transaction in chunks + for packetIndex := byte(1); packetIndex <= packetCount; packetIndex++ { + chunk := CHUNKSIZE + if len(transaction) < CHUNKSIZE { + chunk = len(transaction) + } + + // p1 can have 3 different values: + // p1 = 0 INIT (first chunk) + // p1 = 1 ADD from chunk 1 up to packetCount - 1 + // p1 = 2 LAST indicates to the app that is the last chunk + p1 := byte(1) + if packetIndex == packetCount { + p1 = byte(2) + } + + header := []byte{CLA, INSSign, p1, byte(txMode), byte(chunk)} + message = append(header, transaction[:chunk]...) + + apduResponse, err := ledger.api.Exchange(message) + if err != nil { + return signatureResponse, processErrorResponse(apduResponse, err) + } + + // Trim sent bytes + transaction = transaction[chunk:] + signatureResponse.signatureDER = apduResponse + } + + // Ledger app returns the signature in DER format + return signatureResponse, nil +} diff --git a/ledger_cosmos_test.go b/ledger_cosmos_test.go new file mode 100644 index 0000000..0683f1c --- /dev/null +++ b/ledger_cosmos_test.go @@ -0,0 +1,210 @@ +/******************************************************************************* +* (c) 2018 - 2023 ZondaX AG +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +********************************************************************************/ + +package ledger_cosmos_go + +import ( + "crypto/sha256" + "fmt" + "strings" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func getDummyTx() []byte { + dummyTx := `{ + "account_number": 1, + "chain_id": "some_chain", + "fee": { + "amount": [{"amount": 10, "denom": "DEN"}], + "gas": 5 + }, + "memo": "MEMO", + "msgs": ["SOMETHING"], + "sequence": 3 + }` + dummyTx = strings.Replace(dummyTx, " ", "", -1) + dummyTx = strings.Replace(dummyTx, "\n", "", -1) + dummyTx = strings.Replace(dummyTx, "\t", "", -1) + + return []byte(dummyTx) +} + +// Ledger Test Mnemonic: equip will roof matter pink blind book anxiety banner elbow sun young + +func Test_UserFindLedger(t *testing.T) { + CosmosApp, err := FindLedger() + if err != nil { + t.Fatalf(err.Error()) + } + + assert.NotNil(t, CosmosApp) + defer CosmosApp.Close() +} + +func Test_UserGetVersion(t *testing.T) { + CosmosApp, err := FindLedger() + if err != nil { + t.Fatalf(err.Error()) + } + defer CosmosApp.Close() + + version, err := CosmosApp.GetVersion() + require.Nil(t, err, "Detected error") + fmt.Println("Current Cosmos app version: ", version) + + // Rework at v2.34.12 ---> Minimum required version + assert.GreaterOrEqual(t, uint8(2), version.Major) + if version.Major == 2 { + assert.GreaterOrEqual(t, uint8(34), version.Minor) + if version.Minor == 34 { + assert.GreaterOrEqual(t, uint8(12), version.Patch) + } + } +} + +// From v2.34.12 onwards, is possible to sign transactions using Ethereum derivation path (60) +// Verify addresses for Cosmos and Ethereum paths +// Ethereum path can be used only for a list of allowed HRP +// Check list here: +// https://github.com/cosmos/ledger-cosmos/blob/697dbd7e28cbfc8caa78d4c3bbc6febdaf6ae618/app/src/chain_config.c#L26-L30 +func Test_GetAddressAndPubkey(t *testing.T) { + CosmosApp, err := FindLedger() + if err != nil { + t.Fatalf(err.Error()) + } + defer CosmosApp.Close() + + // Test with Cosmos path + hrp := "cosmos" + path := "m/44'/118'/0'/0/3" + + addressResponse, err := CosmosApp.GetAddressAndPubKey(path, hrp, false) + if err != nil { + t.Fatalf("Detected error, err: %s\n", err.Error()) + } + + assert.Equal(t, 33, len(addressResponse.pubkey), + "Public key has wrong length: %x, expected length: %x\n", len(addressResponse.pubkey), 33) + fmt.Printf("PUBLIC KEY: %x\n", addressResponse.pubkey) + fmt.Printf("ADDRESS: %s\n", addressResponse.address) + + // assert.Equal(t, + // "03cb5a33c61595206294140c45efa8a817533e31aa05ea18343033a0732a677005", + // hex.EncodeToString(addressResponse.pubkey), + // "Unexpected pubkey") + + // Test with Ethereum path --> Enable expert mode + hrp = "inj" + path = "m/44'/60'/0'/0/1" + + addressResponse, err = CosmosApp.GetAddressAndPubKey(path, hrp, false) + if err != nil { + t.Fatalf("Detected error, err: %s\n", err.Error()) + } + + assert.Equal(t, 33, len(addressResponse.pubkey), + "Public key has wrong length: %x, expected length: %x\n", len(addressResponse.pubkey), 33) + fmt.Printf("PUBLIC KEY: %x\n", addressResponse.pubkey) + fmt.Printf("ADDRESS: %s\n", addressResponse.address) + + // assert.Equal(t, + // "03cb5a33c61595206294140c45efa8a817533e31aa05ea18343033a0732a677005", + // hex.EncodeToString(addressResponse.pubkey), + // "Unexpected pubkey") + + // // Take the compressed pubkey and verify that the expected address can be computed + // const uncompressPubKeyUint8Array = secp256k1.publicKeyConvert(resp.compressed_pk, false).subarray(1); + // const ethereumAddressBuffer = Buffer.from(keccak(Buffer.from(uncompressPubKeyUint8Array))).subarray(-20); + // const eth_address = bech32.encode(hrp, bech32.toWords(ethereumAddressBuffer)); // "cosmos15n2h0lzvfgc8x4fm6fdya89n78x6ee2fm7fxr3" + + // expect(resp.bech32_address).toEqual(eth_address) + // expect(resp.bech32_address).toEqual('inj15n2h0lzvfgc8x4fm6fdya89n78x6ee2f3h7z3f') +} + +func Test_UserSign(t *testing.T) { + CosmosApp, err := FindLedger() + if err != nil { + t.Fatalf(err.Error()) + } + defer CosmosApp.Close() + + hrp := "cosmos" + path := "m/44'/118'/0'/0/0" + + message := getDummyTx() + signatureResponse, err := CosmosApp.sign(path, hrp, SignModeAmino, message) + if err != nil { + t.Fatalf("[Sign] Error: %s\n", err.Error()) + } + + // Verify Signature + responseAddress, err := CosmosApp.GetAddressAndPubKey(path, hrp, false) + if err != nil { + t.Fatalf("Detected error, err: %s\n", err.Error()) + } + + if err != nil { + t.Fatalf("[GetPK] Error: " + err.Error()) + return + } + + pub2, err := btcec.ParsePubKey(responseAddress.pubkey) + if err != nil { + t.Fatalf("[ParsePK] Error: " + err.Error()) + return + } + + sig2, err := ecdsa.ParseDERSignature(signatureResponse.signatureDER) + if err != nil { + t.Fatalf("[ParseSig] Error: " + err.Error()) + return + } + + hash := sha256.Sum256(message) + verified := sig2.Verify(hash[:], pub2) + if !verified { + t.Fatalf("[VerifySig] Error verifying signature: " + err.Error()) + return + } +} + +func Test_UserSignFails(t *testing.T) { + CosmosApp, err := FindLedger() + if err != nil { + t.Fatalf(err.Error()) + } + defer CosmosApp.Close() + + hrp := "cosmos" + path := "m/44'/118'/0'/0/0" + + message := getDummyTx() + garbage := []byte{65} + message = append(garbage, message...) + + _, err = CosmosApp.sign(path, hrp, SignModeAmino, message) + assert.Error(t, err) + errMessage := err.Error() + + if errMessage != "Invalid character in JSON string" && errMessage != "Unexpected characters" { + assert.Fail(t, "Unexpected error message returned: "+errMessage) + } +} diff --git a/user_app.go b/user_app.go deleted file mode 100644 index e2beec9..0000000 --- a/user_app.go +++ /dev/null @@ -1,336 +0,0 @@ -/******************************************************************************* -* (c) 2018 - 2022 ZondaX AG -* -* Licensed under the Apache License, Version 2.0 (the "License"); -* you may not use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -* See the License for the specific language governing permissions and -* limitations under the License. -********************************************************************************/ - -package ledger_cosmos_go - -import ( - "errors" - "fmt" - "math" - - ledger_go "github.com/zondax/ledger-go" -) - -const ( - userCLA = 0x55 - - userINSGetVersion = 0 - userINSSignSECP256K1 = 2 - userINSGetAddrSecp256k1 = 4 - - userMessageChunkSize = 250 -) - -// LedgerCosmos represents a connection to the Cosmos app in a Ledger Nano S device -type LedgerCosmos struct { - api ledger_go.LedgerDevice - version VersionInfo -} - -// FindLedgerCosmosUserApp finds a Cosmos user app running in a ledger device -func FindLedgerCosmosUserApp() (_ *LedgerCosmos, rerr error) { - ledgerAdmin := ledger_go.NewLedgerAdmin() - ledgerAPI, err := ledgerAdmin.Connect(0) - if err != nil { - return nil, err - } - - defer func() { - if rerr != nil { - ledgerAPI.Close() - } - }() - - app := &LedgerCosmos{ledgerAPI, VersionInfo{}} - appVersion, err := app.GetVersion() - if err != nil { - if err.Error() == "[APDU_CODE_CLA_NOT_SUPPORTED] Class not supported" { - err = errors.New("are you sure the Cosmos app is open?") - } - return nil, err - } - - if err := app.CheckVersion(*appVersion); err != nil { - return nil, err - } - - return app, nil -} - -// Close closes a connection with the Cosmos user app -func (ledger *LedgerCosmos) Close() error { - return ledger.api.Close() -} - -// VersionIsSupported returns true if the App version is supported by this library -func (ledger *LedgerCosmos) CheckVersion(ver VersionInfo) error { - version, err := ledger.GetVersion() - if err != nil { - return err - } - - switch major := version.Major; major { - case 1: - return CheckVersion(ver, VersionInfo{0, 1, 5, 1}) - case 2: - return CheckVersion(ver, VersionInfo{0, 2, 1, 0}) - default: - return fmt.Errorf("App version %d is not supported", major) - } -} - -// GetVersion returns the current version of the Cosmos user app -func (ledger *LedgerCosmos) GetVersion() (*VersionInfo, error) { - message := []byte{userCLA, userINSGetVersion, 0, 0, 0} - response, err := ledger.api.Exchange(message) - - if err != nil { - return nil, err - } - - if len(response) < 4 { - return nil, errors.New("invalid response") - } - - ledger.version = VersionInfo{ - AppMode: response[0], - Major: response[1], - Minor: response[2], - Patch: response[3], - } - - return &ledger.version, nil -} - -// SignSECP256K1 signs a transaction using Cosmos user app. It can either use -// SIGN_MODE_LEGACY_AMINO_JSON (P2=0) or SIGN_MODE_TEXTUAL (P2=1). -// this command requires user confirmation in the device -func (ledger *LedgerCosmos) SignSECP256K1(bip32Path []uint32, transaction []byte, p2 byte) ([]byte, error) { - switch major := ledger.version.Major; major { - case 1: - return ledger.signv1(bip32Path, transaction) - case 2: - return ledger.signv2(bip32Path, transaction, p2) - default: - return nil, fmt.Errorf("App version %d is not supported", major) - } -} - -// GetPublicKeySECP256K1 retrieves the public key for the corresponding bip32 derivation path (compressed) -// this command DOES NOT require user confirmation in the device -func (ledger *LedgerCosmos) GetPublicKeySECP256K1(bip32Path []uint32) ([]byte, error) { - pubkey, _, err := ledger.getAddressPubKeySECP256K1(bip32Path, "cosmos", false) - return pubkey, err -} - -func validHRPByte(b byte) bool { - // https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki - return b >= 33 && b <= 126 -} - -// GetAddressPubKeySECP256K1 returns the pubkey (compressed) and address (bech( -// this command requires user confirmation in the device -func (ledger *LedgerCosmos) GetAddressPubKeySECP256K1(bip32Path []uint32, hrp string) (pubkey []byte, addr string, err error) { - return ledger.getAddressPubKeySECP256K1(bip32Path, hrp, true) -} - -func (ledger *LedgerCosmos) GetBip32bytes(bip32Path []uint32, hardenCount int) ([]byte, error) { - var pathBytes []byte - var err error - - switch major := ledger.version.Major; major { - case 1: - pathBytes, err = GetBip32bytesv1(bip32Path, 3) - if err != nil { - return nil, err - } - case 2: - pathBytes, err = GetBip32bytesv2(bip32Path, 3) - if err != nil { - return nil, err - } - default: - return nil, fmt.Errorf("App version %d is not supported", major) - } - - return pathBytes, nil -} - -func (ledger *LedgerCosmos) signv1(bip32Path []uint32, transaction []byte) ([]byte, error) { - var packetIndex byte = 1 - var packetCount = 1 + byte(math.Ceil(float64(len(transaction))/float64(userMessageChunkSize))) - - var finalResponse []byte - - var message []byte - - for packetIndex <= packetCount { - chunk := userMessageChunkSize - if packetIndex == 1 { - pathBytes, err := ledger.GetBip32bytes(bip32Path, 3) - if err != nil { - return nil, err - } - header := []byte{userCLA, userINSSignSECP256K1, packetIndex, packetCount, byte(len(pathBytes))} - message = append(header, pathBytes...) - } else { - if len(transaction) < userMessageChunkSize { - chunk = len(transaction) - } - header := []byte{userCLA, userINSSignSECP256K1, packetIndex, packetCount, byte(chunk)} - message = append(header, transaction[:chunk]...) - } - - response, err := ledger.api.Exchange(message) - if err != nil { - if err.Error() == "[APDU_CODE_BAD_KEY_HANDLE] The parameters in the data field are incorrect" { - // In this special case, we can extract additional info - errorMsg := string(response) - switch errorMsg { - case "ERROR: JSMN_ERROR_NOMEM": - return nil, errors.New("Not enough tokens were provided") - case "PARSER ERROR: JSMN_ERROR_INVAL": - return nil, errors.New("Unexpected character in JSON string") - case "PARSER ERROR: JSMN_ERROR_PART": - return nil, errors.New("The JSON string is not a complete.") - } - return nil, errors.New(errorMsg) - } - return nil, err - } - - finalResponse = response - if packetIndex > 1 { - transaction = transaction[chunk:] - } - packetIndex++ - - } - return finalResponse, nil -} - -func (ledger *LedgerCosmos) signv2(bip32Path []uint32, transaction []byte, p2 byte) ([]byte, error) { - var packetIndex byte = 1 - var packetCount = 1 + byte(math.Ceil(float64(len(transaction))/float64(userMessageChunkSize))) - - var finalResponse []byte - - var message []byte - - if p2 > 1 { - return nil, errors.New("only values of SIGN_MODE_LEGACY_AMINO (P2=0) and SIGN_MODE_TEXTUAL (P2=1) are allowed") - } - - for packetIndex <= packetCount { - chunk := userMessageChunkSize - if packetIndex == 1 { - pathBytes, err := ledger.GetBip32bytes(bip32Path, 3) - if err != nil { - return nil, err - } - header := []byte{userCLA, userINSSignSECP256K1, 0, p2, byte(len(pathBytes))} - message = append(header, pathBytes...) - } else { - if len(transaction) < userMessageChunkSize { - chunk = len(transaction) - } - - payloadDesc := byte(1) - if packetIndex == packetCount { - payloadDesc = byte(2) - } - - header := []byte{userCLA, userINSSignSECP256K1, payloadDesc, p2, byte(chunk)} - message = append(header, transaction[:chunk]...) - } - - response, err := ledger.api.Exchange(message) - if err != nil { - if err.Error() == "[APDU_CODE_BAD_KEY_HANDLE] The parameters in the data field are incorrect" { - // In this special case, we can extract additional info - errorMsg := string(response) - switch errorMsg { - case "ERROR: JSMN_ERROR_NOMEM": - return nil, errors.New("Not enough tokens were provided") - case "PARSER ERROR: JSMN_ERROR_INVAL": - return nil, errors.New("Unexpected character in JSON string") - case "PARSER ERROR: JSMN_ERROR_PART": - return nil, errors.New("The JSON string is not a complete.") - } - return nil, errors.New(errorMsg) - } - if err.Error() == "[APDU_CODE_DATA_INVALID] Referenced data reversibly blocked (invalidated)" { - errorMsg := string(response) - return nil, errors.New(errorMsg) - } - return nil, err - } - - finalResponse = response - if packetIndex > 1 { - transaction = transaction[chunk:] - } - packetIndex++ - - } - return finalResponse, nil -} - -// GetAddressPubKeySECP256K1 returns the pubkey (compressed) and address (bech( -// this command requires user confirmation in the device -func (ledger *LedgerCosmos) getAddressPubKeySECP256K1(bip32Path []uint32, hrp string, requireConfirmation bool) (pubkey []byte, addr string, err error) { - if len(hrp) > 83 { - return nil, "", errors.New("hrp len should be <10") - } - - hrpBytes := []byte(hrp) - for _, b := range hrpBytes { - if !validHRPByte(b) { - return nil, "", errors.New("all characters in the HRP must be in the [33, 126] range") - } - } - - pathBytes, err := ledger.GetBip32bytes(bip32Path, 3) - if err != nil { - return nil, "", err - } - - p1 := byte(0) - if requireConfirmation { - p1 = byte(1) - } - - // Prepare message - header := []byte{userCLA, userINSGetAddrSecp256k1, p1, 0, 0} - message := append(header, byte(len(hrpBytes))) - message = append(message, hrpBytes...) - message = append(message, pathBytes...) - message[4] = byte(len(message) - len(header)) // update length - - response, err := ledger.api.Exchange(message) - - if err != nil { - return nil, "", err - } - if len(response) < 35+len(hrp) { - return nil, "", errors.New("Invalid response") - } - - pubkey = response[0:33] - addr = string(response[33:len(response)]) - - return pubkey, addr, err -} diff --git a/user_app_test.go b/user_app_test.go deleted file mode 100644 index 0597c27..0000000 --- a/user_app_test.go +++ /dev/null @@ -1,266 +0,0 @@ -/******************************************************************************* -* (c) 2018 - 2022 ZondaX AG -* -* Licensed under the Apache License, Version 2.0 (the "License"); -* you may not use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -* See the License for the specific language governing permissions and -* limitations under the License. -********************************************************************************/ - -package ledger_cosmos_go - -import ( - "crypto/sha256" - "encoding/hex" - "fmt" - "strings" - "testing" - - "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/btcec/v2/ecdsa" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// Ledger Test Mnemonic: equip will roof matter pink blind book anxiety banner elbow sun young - -func Test_UserFindLedger(t *testing.T) { - userApp, err := FindLedgerCosmosUserApp() - if err != nil { - t.Fatalf(err.Error()) - } - - assert.NotNil(t, userApp) - defer userApp.Close() -} - -func Test_UserGetVersion(t *testing.T) { - userApp, err := FindLedgerCosmosUserApp() - if err != nil { - t.Fatalf(err.Error()) - } - defer userApp.Close() - - version, err := userApp.GetVersion() - require.Nil(t, err, "Detected error") - fmt.Println(version) - - assert.Equal(t, uint8(0x0), version.AppMode, "TESTING MODE ENABLED!!") - assert.Equal(t, uint8(0x2), version.Major, "Wrong Major version") - assert.Equal(t, uint8(0x1), version.Minor, "Wrong Minor version") - assert.Equal(t, uint8(0x0), version.Patch, "Wrong Patch version") -} - -func Test_UserGetPublicKey(t *testing.T) { - userApp, err := FindLedgerCosmosUserApp() - if err != nil { - t.Fatalf(err.Error()) - } - defer userApp.Close() - - path := []uint32{44, 118, 5, 0, 21} - - pubKey, err := userApp.GetPublicKeySECP256K1(path) - if err != nil { - t.Fatalf("Detected error, err: %s\n", err.Error()) - } - - assert.Equal(t, 33, len(pubKey), - "Public key has wrong length: %x, expected length: %x\n", pubKey, 65) - fmt.Printf("PUBLIC KEY: %x\n", pubKey) - - assert.Equal(t, - "03cb5a33c61595206294140c45efa8a817533e31aa05ea18343033a0732a677005", - hex.EncodeToString(pubKey), - "Unexpected pubkey") -} - -func Test_GetAddressPubKeySECP256K1_Zero(t *testing.T) { - userApp, err := FindLedgerCosmosUserApp() - if err != nil { - t.Fatalf(err.Error()) - } - defer userApp.Close() - - hrp := "cosmos" - path := []uint32{44, 118, 0, 0, 0} - - pubKey, addr, err := userApp.GetAddressPubKeySECP256K1(path, hrp) - if err != nil { - t.Fatalf("Detected error, err: %s\n", err.Error()) - } - - fmt.Printf("PUBLIC KEY : %x\n", pubKey) - fmt.Printf("BECH32 ADDR: %s\n", addr) - - assert.Equal(t, 33, len(pubKey), "Public key has wrong length: %x, expected length: %x\n", pubKey, 65) - - assert.Equal(t, "034fef9cd7c4c63588d3b03feb5281b9d232cba34d6f3d71aee59211ffbfe1fe87", hex.EncodeToString(pubKey), "Unexpected pubkey") - assert.Equal(t, "cosmos1w34k53py5v5xyluazqpq65agyajavep2rflq6h", addr, "Unexpected addr") -} - -func Test_GetAddressPubKeySECP256K1(t *testing.T) { - userApp, err := FindLedgerCosmosUserApp() - if err != nil { - t.Fatalf(err.Error()) - } - defer userApp.Close() - - hrp := "cosmos" - path := []uint32{44, 118, 5, 0, 21} - - pubKey, addr, err := userApp.GetAddressPubKeySECP256K1(path, hrp) - if err != nil { - t.Fatalf("Detected error, err: %s\n", err.Error()) - } - - fmt.Printf("PUBLIC KEY : %x\n", pubKey) - fmt.Printf("BECH32 ADDR: %s\n", addr) - - assert.Equal(t, 33, len(pubKey), "Public key has wrong length: %x, expected length: %x\n", pubKey, 65) - - assert.Equal(t, "03cb5a33c61595206294140c45efa8a817533e31aa05ea18343033a0732a677005", hex.EncodeToString(pubKey), "Unexpected pubkey") - assert.Equal(t, "cosmos162zm3k8mc685592d7vej2lxrp58mgmkcec76d6", addr, "Unexpected addr") -} - -func Test_UserPK_HDPaths(t *testing.T) { - userApp, err := FindLedgerCosmosUserApp() - if err != nil { - t.Fatalf(err.Error()) - } - defer userApp.Close() - - path := []uint32{44, 118, 0, 0, 0} - - expected := []string{ - "034fef9cd7c4c63588d3b03feb5281b9d232cba34d6f3d71aee59211ffbfe1fe87", - "0260d0487a3dfce9228eee2d0d83a40f6131f551526c8e52066fe7fe1e4a509666", - "03a2670393d02b162d0ed06a08041e80d86be36c0564335254df7462447eb69ab3", - "033222fc61795077791665544a90740e8ead638a391a3b8f9261f4a226b396c042", - "03f577473348d7b01e7af2f245e36b98d181bc935ec8b552cde5932b646dc7be04", - "0222b1a5486be0a2d5f3c5866be46e05d1bde8cda5ea1c4c77a9bc48d2fa2753bc", - "0377a1c826d3a03ca4ee94fc4dea6bccb2bac5f2ac0419a128c29f8e88f1ff295a", - "031b75c84453935ab76f8c8d0b6566c3fcc101cc5c59d7000bfc9101961e9308d9", - "038905a42433b1d677cc8afd36861430b9a8529171b0616f733659f131c3f80221", - "038be7f348902d8c20bc88d32294f4f3b819284548122229decd1adf1a7eb0848b", - } - - for i := uint32(0); i < 10; i++ { - path[4] = i - - pubKey, err := userApp.GetPublicKeySECP256K1(path) - if err != nil { - t.Fatalf("Detected error, err: %s\n", err.Error()) - } - - assert.Equal( - t, - 33, - len(pubKey), - "Public key has wrong length: %x, expected length: %x\n", pubKey, 65) - - assert.Equal( - t, - expected[i], - hex.EncodeToString(pubKey), - "Public key 44'/118'/0'/0/%d does not match\n", i) - - _, err = btcec.ParsePubKey(pubKey[:]) - require.Nil(t, err, "Error parsing public key err: %s\n", err) - - } -} - -func getDummyTx() []byte { - dummyTx := `{ - "account_number": 1, - "chain_id": "some_chain", - "fee": { - "amount": [{"amount": 10, "denom": "DEN"}], - "gas": 5 - }, - "memo": "MEMO", - "msgs": ["SOMETHING"], - "sequence": 3 - }` - dummyTx = strings.Replace(dummyTx, " ", "", -1) - dummyTx = strings.Replace(dummyTx, "\n", "", -1) - dummyTx = strings.Replace(dummyTx, "\t", "", -1) - - return []byte(dummyTx) -} - -func Test_UserSign(t *testing.T) { - userApp, err := FindLedgerCosmosUserApp() - if err != nil { - t.Fatalf(err.Error()) - } - defer userApp.Close() - - path := []uint32{44, 118, 0, 0, 5} - - message := getDummyTx() - signature, err := userApp.SignSECP256K1(path, message, 0) - if err != nil { - t.Fatalf("[Sign] Error: %s\n", err.Error()) - } - - // Verify Signature - pubKey, err := userApp.GetPublicKeySECP256K1(path) - if err != nil { - t.Fatalf("Detected error, err: %s\n", err.Error()) - } - - if err != nil { - t.Fatalf("[GetPK] Error: " + err.Error()) - return - } - - pub2, err := btcec.ParsePubKey(pubKey[:]) - if err != nil { - t.Fatalf("[ParsePK] Error: " + err.Error()) - return - } - - sig2, err := ecdsa.ParseDERSignature(signature[:]) - if err != nil { - t.Fatalf("[ParseSig] Error: " + err.Error()) - return - } - - hash := sha256.Sum256(message) - verified := sig2.Verify(hash[:], pub2) - if !verified { - t.Fatalf("[VerifySig] Error verifying signature: " + err.Error()) - return - } -} - -func Test_UserSign_Fails(t *testing.T) { - userApp, err := FindLedgerCosmosUserApp() - if err != nil { - t.Fatalf(err.Error()) - } - defer userApp.Close() - - path := []uint32{44, 118, 0, 0, 5} - - message := getDummyTx() - garbage := []byte{65} - message = append(garbage, message...) - - _, err = userApp.SignSECP256K1(path, message, 0) - assert.Error(t, err) - errMessage := err.Error() - - if errMessage != "Invalid character in JSON string" && errMessage != "Unexpected characters" { - assert.Fail(t, "Unexpected error message returned: "+errMessage) - } -} diff --git a/validator_app.go b/validator_app.go deleted file mode 100644 index 617c536..0000000 --- a/validator_app.go +++ /dev/null @@ -1,178 +0,0 @@ -/******************************************************************************* -* (c) 2018 - 2022 ZondaX AG -* -* Licensed under the Apache License, Version 2.0 (the "License"); -* you may not use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -* See the License for the specific language governing permissions and -* limitations under the License. -********************************************************************************/ - -package ledger_cosmos_go - -import ( - "errors" - "math" - - "github.com/zondax/ledger-go" -) - -const ( - validatorCLA = 0x56 - - validatorINSGetVersion = 0 - validatorINSPublicKeyED25519 = 1 - validatorINSSignED25519 = 2 - - validatorMessageChunkSize = 250 -) - -// Validator app -type LedgerTendermintValidator struct { - // Add support for this app - api ledger_go.LedgerDevice -} - -// RequiredCosmosUserAppVersion indicates the minimum required version of the Tendermint app -func RequiredTendermintValidatorAppVersion() VersionInfo { - return VersionInfo{0, 0, 5, 0} -} - -// FindLedgerCosmosValidatorApp finds a Cosmos validator app running in a ledger device -func FindLedgerTendermintValidatorApp() (_ *LedgerTendermintValidator, rerr error) { - ledgerAdmin := ledger_go.NewLedgerAdmin() - ledgerAPI, err := ledgerAdmin.Connect(0) - if err != nil { - return nil, err - } - - defer func() { - if rerr != nil { - defer ledgerAPI.Close() - } - }() - - ledgerCosmosValidatorApp := &LedgerTendermintValidator{ledgerAPI} - appVersion, err := ledgerCosmosValidatorApp.GetVersion() - if err != nil { - if err.Error() == "[APDU_CODE_CLA_NOT_SUPPORTED] Class not supported" { - err = errors.New("are you sure the Tendermint Validator app is open?") - } - return nil, err - } - - req := RequiredTendermintValidatorAppVersion() - if err := CheckVersion(*appVersion, req); err != nil { - return nil, err - } - - return ledgerCosmosValidatorApp, err -} - -// Close closes a connection with the Cosmos user app -func (ledger *LedgerTendermintValidator) Close() error { - return ledger.api.Close() -} - -// GetVersion returns the current version of the Cosmos user app -func (ledger *LedgerTendermintValidator) GetVersion() (*VersionInfo, error) { - message := []byte{validatorCLA, validatorINSGetVersion, 0, 0, 0} - response, err := ledger.api.Exchange(message) - - if err != nil { - return nil, err - } - - if len(response) < 4 { - return nil, errors.New("invalid response") - } - - return &VersionInfo{ - AppMode: response[0], - Major: response[1], - Minor: response[2], - Patch: response[3], - }, nil -} - -// GetPublicKeyED25519 retrieves the public key for the corresponding bip32 derivation path -func (ledger *LedgerTendermintValidator) GetPublicKeyED25519(bip32Path []uint32) ([]byte, error) { - pathBytes, err := GetBip32bytesv1(bip32Path, 10) - if err != nil { - return nil, err - } - - header := []byte{validatorCLA, validatorINSPublicKeyED25519, 0, 0, byte(len(pathBytes))} - message := append(header, pathBytes...) - - response, err := ledger.api.Exchange(message) - - if err != nil { - return nil, err - } - - if len(response) < 4 { - return nil, errors.New("invalid response. Too short") - } - - return response, nil -} - -// SignSECP256K1 signs a message/vote using the Tendermint validator app -func (ledger *LedgerTendermintValidator) SignED25519(bip32Path []uint32, message []byte) ([]byte, error) { - var packetIndex byte = 1 - var packetCount = 1 + byte(math.Ceil(float64(len(message))/float64(validatorMessageChunkSize))) - - var finalResponse []byte - - var apduMessage []byte - - for packetIndex <= packetCount { - chunk := validatorMessageChunkSize - if packetIndex == 1 { - pathBytes, err := GetBip32bytesv1(bip32Path, 10) - if err != nil { - return nil, err - } - header := []byte{ - validatorCLA, - validatorINSSignED25519, - packetIndex, - packetCount, - byte(len(pathBytes))} - - apduMessage = append(header, pathBytes...) - } else { - if len(message) < validatorMessageChunkSize { - chunk = len(message) - } - header := []byte{ - validatorCLA, - validatorINSSignED25519, - packetIndex, - packetCount, - byte(chunk)} - - apduMessage = append(header, message[:chunk]...) - } - - response, err := ledger.api.Exchange(apduMessage) - if err != nil { - return nil, err - } - - finalResponse = response - if packetIndex > 1 { - message = message[chunk:] - } - packetIndex++ - - } - return finalResponse, nil -} diff --git a/validator_app_test.go b/validator_app_test.go deleted file mode 100644 index c61a26c..0000000 --- a/validator_app_test.go +++ /dev/null @@ -1,64 +0,0 @@ -/******************************************************************************* -* (c) 2018 - 2022 ZondaX AG -* -* Licensed under the Apache License, Version 2.0 (the "License"); -* you may not use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -* See the License for the specific language governing permissions and -* limitations under the License. -********************************************************************************/ - -package ledger_cosmos_go - -import ( - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "testing" -) - -func Test_ValGetVersion(t *testing.T) { - validatorApp, err := FindLedgerTendermintValidatorApp() - if err != nil { - t.Fatalf(err.Error()) - } - defer validatorApp.Close() - - version, err := validatorApp.GetVersion() - require.Nil(t, err, "Detected error") - assert.Equal(t, uint8(0x0), version.AppMode, "TESTING MODE NOT ENABLED") - assert.Equal(t, uint8(0x0), version.Major, "Wrong Major version") - assert.Equal(t, uint8(0x9), version.Minor, "Wrong Minor version") - assert.Equal(t, uint8(0x0), version.Patch, "Wrong Patch version") -} - -func Test_ValGetPublicKey(t *testing.T) { - validatorApp, err := FindLedgerTendermintValidatorApp() - if err != nil { - t.Fatalf(err.Error()) - } - defer validatorApp.Close() - - path := []uint32{44, 118, 0, 0, 0} - - for i := 1; i < 10; i++ { - pubKey, err := validatorApp.GetPublicKeyED25519(path) - require.Nil(t, err, "Detected error, err: %s\n", err) - - assert.Equal( - t, - 32, - len(pubKey), - "Public key has wrong length: %x, expected length: %x\n", pubKey, 32) - } - -} - -func Test_ValSignED25519(t *testing.T) { - t.Skip("Go support is still not available. Please refer to the Rust library") -}