diff --git a/Gopkg.lock b/Gopkg.lock index 4a1bbef..cc6323a 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -17,6 +17,14 @@ revision = "8991bc29aa16c548c550c7ff78260e27b9ab7c73" version = "v1.1.1" +[[projects]] + branch = "master" + digest = "1:447562773a19dc1719359c2cd70d275c62c0b89f79d763f41d5deedb0e69873f" + name = "github.com/karalabe/hid" + packages = ["."] + pruneopts = "T" + revision = "d815e0c1a2e2082a287a2806bc90bc8fc7b276a9" + [[projects]] digest = "1:40e195917a951a8bf867cd05de2a46aaf1806c50cf92eebf4c16f78cd196f747" name = "github.com/pkg/errors" @@ -46,6 +54,7 @@ analyzer-version = 1 input-imports = [ "github.com/ZondaX/hid-go", + "github.com/karalabe/hid", "github.com/pkg/errors", "github.com/stretchr/testify/assert", ] diff --git a/Gopkg.toml b/Gopkg.toml index a25132c..b5557d4 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -1,6 +1,6 @@ [[constraint]] - name = "github.com/ZondaX/hid-go" - version = "v0.4.0" + name = "github.com/karalabe/hid" + branch = "master" [[constraint]] name = "github.com/pkg/errors" @@ -13,3 +13,6 @@ [prune] go-tests = true unused-packages = true + [[prune.project]] + name = "github.com/karalabe/hid" + unused-packages = false diff --git a/README.md b/README.md index ab9e6cd..ad8a635 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ # ledger-go -[![CircleCI](https://circleci.com/gh/ZondaX/ledger-goclient.svg?style=svg)](https://circleci.com/gh/ZondaX/ledger-goclient) -[![Build status](https://ci.appveyor.com/api/projects/status/aqv23r898rqegxqv/branch/master?svg=true)](https://ci.appveyor.com/project/zondax/ledger-goclient/branch/master) -[![Build Status](https://travis-ci.org/ZondaX/ledger-goclient.svg?branch=master)](https://travis-ci.org/ZondaX/ledger-goclient) +[![CircleCI](https://circleci.com/gh/ZondaX/ledger-go.svg?style=svg)](https://circleci.com/gh/ZondaX/ledger-go) +[![Build status](https://ci.appveyor.com/api/projects/status/m4wn7kuuuu98b3uh/branch/master?svg=true)](https://ci.appveyor.com/project/zondax/ledger-go/branch/master) +[![Build Status](https://travis-ci.org/ZondaX/ledger-goclient.svg?branch=master)](https://travis-ci.org/ZondaX/ledger-go) -This project is work in progress. Some aspects are subject to change. +This project provides a library to connect to ledger devices. + +It handles USB (HID) communication and APDU encapsulation. + +Linux, OSX and Windows are supported. # Get source Apart from cloning, be sure you install dep dependency management tool @@ -18,11 +22,5 @@ dep ensure # Building ``` -go build ledger.go +go build ``` - -# Running -./ledger - -Make sure that the app is launched in the Ledger before starting this command and Ledger is connected to the USB port. -This command line tool will try to send a simple json transaction and will return a signature when user agrees to sign. diff --git a/apduWrapper.go b/apduWrapper.go index 3743159..3fc21fb 100644 --- a/apduWrapper.go +++ b/apduWrapper.go @@ -17,8 +17,8 @@ package ledger_go import ( - "github.com/pkg/errors" "encoding/binary" + "github.com/pkg/errors" ) var codec = binary.BigEndian @@ -27,8 +27,7 @@ func SerializePacket( channel uint16, command []byte, packetSize int, - sequenceIdx uint16, - ble bool) (result []byte, offset int, err error) { + sequenceIdx uint16) (result []byte, offset int, err error) { if packetSize < 3 { return nil, 0, errors.New("Packet size must be at least 3") @@ -40,10 +39,8 @@ func SerializePacket( var buffer = result // Insert channel (2 bytes) - if !ble { - codec.PutUint16(buffer, channel) - headerOffset += 2 - } + codec.PutUint16(buffer, channel) + headerOffset += 2 // Insert tag (1 byte) buffer[headerOffset] = 0x05 @@ -71,8 +68,7 @@ func SerializePacket( func DeserializePacket( channel uint16, buffer []byte, - sequenceIdx uint16, - ble bool) (result []byte, totalResponseLength uint16, err error) { + sequenceIdx uint16) (result []byte, totalResponseLength uint16, err error) { if (sequenceIdx == 0 && len(buffer) < 7) || (sequenceIdx > 0 && len(buffer) < 5) { return nil, 0, errors.New("Cannot deserialize the packet. Header information is missing.") @@ -80,12 +76,11 @@ func DeserializePacket( var headerOffset uint8 - if !ble { - if codec.Uint16(buffer) != channel { - return nil, 0, errors.New("Invalid channel") - } - headerOffset += 2 + if codec.Uint16(buffer) != channel { + return nil, 0, errors.New("Invalid channel") } + headerOffset += 2 + if buffer[headerOffset] != 0x05 { return nil, 0, errors.New("Invalid tag") } @@ -101,7 +96,7 @@ func DeserializePacket( headerOffset += 2 } - result = make([]byte, len(buffer) - int(headerOffset)) + result = make([]byte, len(buffer)-int(headerOffset)) copy(result, buffer[headerOffset:]) return result, totalResponseLength, nil @@ -111,14 +106,14 @@ func DeserializePacket( func WrapCommandAPDU( channel uint16, command []byte, - packetSize int, - ble bool) (result []byte, err error) { + packetSize int) (result []byte, err error) { var offset int var totalResult []byte var sequenceIdx uint16 + for len(command) > 0 { - result, offset, err = SerializePacket(channel, command, packetSize, sequenceIdx, ble) + result, offset, err = SerializePacket(channel, command, packetSize, sequenceIdx) if err != nil { return nil, err } @@ -126,21 +121,23 @@ func WrapCommandAPDU( totalResult = append(totalResult, result...) sequenceIdx++ } + return totalResult, nil } // UnwrapResponseAPDU parses a response of 64 byte packets into the real data -func UnwrapResponseAPDU(channel uint16, pipe <- chan []byte, packetSize int, ble bool) ([]byte, error) { +func UnwrapResponseAPDU(channel uint16, pipe <- chan []byte, packetSize int) ([]byte, error) { var sequenceIdx uint16 var totalResult []byte var totalSize uint16 - var finished = false - for !finished { + var done = false + for !done { // Read next packet from the channel buffer := <- pipe - result, responseSize, err := DeserializePacket(channel, buffer, sequenceIdx, ble) + + result, responseSize, err := DeserializePacket(channel, buffer, sequenceIdx) if err != nil { return nil, err } @@ -153,7 +150,7 @@ func UnwrapResponseAPDU(channel uint16, pipe <- chan []byte, packetSize int, ble sequenceIdx++ if len(totalResult) >= int(totalSize) { - finished = true + done = true } } diff --git a/apduWrapper_test.go b/apduWrapper_test.go index ac51091..9f82bd3 100644 --- a/apduWrapper_test.go +++ b/apduWrapper_test.go @@ -17,18 +17,18 @@ package ledger_go import ( - "testing" - "github.com/stretchr/testify/assert" - "unsafe" "bytes" - "time" + "fmt" + "github.com/stretchr/testify/assert" "math" + "testing" + "unsafe" ) func Test_SerializePacket_EmptyCommand(t *testing.T) { var command= make([]byte, 1) - _, _, err := SerializePacket(0x0101, command, 64, 0, false) + _, _, err := SerializePacket(0x0101, command, 64, 0) assert.Nil(t, err, "Commands smaller than 3 bytes should return error") } @@ -50,8 +50,7 @@ func Test_SerializePacket_PacketSize(t *testing.T) { h.channel, command, packetSize, - h.sequenceIdx, - false) + h.sequenceIdx) assert.Equal(t, len(result), packetSize, "Packet size is wrong") } @@ -74,8 +73,7 @@ func Test_SerializePacket_Header(t *testing.T) { h.channel, command, packetSize, - h.sequenceIdx, - false) + h.sequenceIdx) assert.Equal(t, codec.Uint16(result), h.channel, "Channel not properly serialized") assert.Equal(t, result[2], h.tag, "Tag not properly serialized") @@ -101,8 +99,7 @@ func Test_SerializePacket_Offset(t *testing.T) { h.channel, command, packetSize, - h.sequenceIdx, - false) + h.sequenceIdx) assert.Equal(t, packetSize - int(unsafe.Sizeof(h))+1, offset, "Wrong offset returned. Offset must point to the next comamnd byte that needs to be packet-ized.") } @@ -129,8 +126,7 @@ func Test_WrapCommandAPDU_NumberOfPackets(t *testing.T) { result, _ := WrapCommandAPDU( h1.channel, command, - packetSize, - false) + packetSize) assert.Equal(t, packetSize*2, len(result), "Result buffer size is not correct") } @@ -157,8 +153,7 @@ func Test_WrapCommandAPDU_CheckHeaders(t *testing.T) { result, _ := WrapCommandAPDU( h1.channel, command, - packetSize, - false) + packetSize) assert.Equal(t, h1.channel, codec.Uint16(result), "Channel not properly serialized") assert.Equal(t, h1.tag, result[2], "Tag not properly serialized") @@ -197,8 +192,7 @@ func Test_WrapCommandAPDU_CheckData(t *testing.T) { result, _ := WrapCommandAPDU( h1.channel, command, - packetSize, - false) + packetSize) // Check data in the first packet assert.True(t, bytes.Compare(command[0:64-7], result[7:64]) == 0) @@ -230,9 +224,9 @@ func Test_DeserializePacket_FirstPacket(t *testing.T) { var packetSize int = 64 var firstPacketHeaderSize int = 7 - packet, _, _ := SerializePacket(0x0101, sampleCommand, packetSize, 0, false) + packet, _, _ := SerializePacket(0x0101, sampleCommand, packetSize, 0) - output, totalSize, err := DeserializePacket(0x0101, packet, 0, false) + output, totalSize, err := DeserializePacket(0x0101, packet, 0) assert.Nil(t,err, "Simple deserialize should not have errors") assert.Equal(t, len(sampleCommand), int(totalSize), "TotalSize is incorrect") @@ -241,14 +235,13 @@ func Test_DeserializePacket_FirstPacket(t *testing.T) { } func Test_DeserializePacket_SecondMessage(t *testing.T) { - var sampleCommand = []byte{'H', 'e', 'l', 'l', 'o', 0} var packetSize int = 64 var firstPacketHeaderSize int = 5 // second packet does not have responseLegth (uint16) in the header - packet, _, _ := SerializePacket(0x0101, sampleCommand, packetSize, 1, false) + packet, _, _ := SerializePacket(0x0101, sampleCommand, packetSize, 1) - output, totalSize, err := DeserializePacket(0x0101, packet, 1, false) + output, totalSize, err := DeserializePacket(0x0101, packet, 1) assert.Nil(t,err, "Simple deserialize should not have errors") assert.Equal(t, 0, int(totalSize), "TotalSize should not be returned from deserialization of non-first packet") @@ -256,37 +249,36 @@ func Test_DeserializePacket_SecondMessage(t *testing.T) { assert.True(t, bytes.Compare(output[:len(sampleCommand)], sampleCommand) == 0, "Deserialized message does not match the original") } -func WriteBuffer(pipe chan<- []byte, buffer []byte) { - time.Sleep(1 * time.Second) - pipe <- buffer -} - func Test_UnwrapApdu_SmokeTest(t *testing.T) { + const channel uint16 = 0x8002 inputSize := 200 var packetSize int = 64 - var input= make([]byte, inputSize) - var channel uint16 = 0x8002 + // Initialize some dummy input + var input= make([]byte, inputSize) for i := range input { input[i] = byte(i % 256) } - serialized, _ := WrapCommandAPDU( - channel, - input, - packetSize, - false) + + serialized, _ := WrapCommandAPDU(channel, input, packetSize) // Allocate enough buffers to keep all the packets pipe := make(chan []byte, int(math.Ceil(float64(inputSize) / float64(packetSize)))) - // Send all the packets to the pipe for len(serialized) > 0 { pipe <- serialized[:packetSize] serialized = serialized[packetSize:] } - output, _ := UnwrapResponseAPDU(channel, pipe, packetSize, false) + output, _ := UnwrapResponseAPDU(channel, pipe, packetSize) + + fmt.Printf("INPUT : %x\n", input) + fmt.Printf("SERIALIZED: %x\n", serialized) + fmt.Printf("OUTPUT : %x\n", output) + assert.Equal(t, len(input), len(output), "Input and output messages have different size") - assert.True(t, bytes.Compare(input, output) == 0, "Input message does not match message which was serialized and then deserialized") + assert.True(t, + bytes.Compare(input, output) == 0, + "Input message does not match message which was serialized and then deserialized") } diff --git a/ledger.go b/ledger.go index 6f34a23..5c605fc 100644 --- a/ledger.go +++ b/ledger.go @@ -19,7 +19,8 @@ package ledger_go import ( "errors" "fmt" - "github.com/ZondaX/hid-go" + "github.com/karalabe/hid" + "sync" ) const ( @@ -31,22 +32,21 @@ const ( ) type Ledger struct { - device Device - Logging bool + device hid.Device + readCo sync.Once + readChannel chan [] byte + Logging bool } -func NewLedger(dev Device) *Ledger { +func NewLedger(dev *hid.Device) *Ledger { return &Ledger{ - device: dev, + device: *dev, Logging: false, } } func ListDevices() { - devices, err := hid.Devices() - if err != nil { - fmt.Printf("Error: %s", err) - } + devices := hid.Enumerate(0, 0) if len(devices) == 0 { fmt.Printf("No devices") @@ -54,11 +54,12 @@ func ListDevices() { for _, d := range devices { fmt.Printf("============ %s\n", d.Path) + fmt.Printf("VendorID : %x\n", d.VendorID) + fmt.Printf("ProductID : %x\n", d.ProductID) + fmt.Printf("Release : %x\n", d.Release) + fmt.Printf("Serial : %x\n", d.Serial) fmt.Printf("Manufacturer : %s\n", d.Manufacturer) fmt.Printf("Product : %s\n", d.Product) - fmt.Printf("ProductID : %x\n", d.ProductID) - fmt.Printf("VendorID : %x\n", d.VendorID) - fmt.Printf("VersionNumber : %x\n", d.VersionNumber) fmt.Printf("UsagePage : %x\n", d.UsagePage) fmt.Printf("Usage : %x\n", d.Usage) fmt.Printf("\n") @@ -66,10 +67,8 @@ func ListDevices() { } func FindLedger() (*Ledger, error) { - devices, err := hid.Devices() - if err != nil { - return nil, err - } + devices := hid.Enumerate(VendorLedger, 0) + for _, d := range devices { if d.VendorID == VendorLedger && d.UsagePage == UsagePageLedger { device, err := d.Open() @@ -82,23 +81,90 @@ func FindLedger() (*Ledger, error) { return nil, errors.New("no ledger connected") } -// A Device provides access to a HID device. -type Device interface { - // Close closes the device and associated resources. - Close() +func ErrorMessage(errorCode uint16) string { + switch errorCode { + // FIXME: Code and description don't match for 0x6982 and 0x6983 based on + // apdu spec: https://www.eftlab.co.uk/index.php/site-map/knowledge-base/118-apdu-response-list + + case 0x6400: + return "[APDU_CODE_EXECUTION_ERROR] No information given (NV-Ram not changed)" + case 0x6700: + return "[APDU_CODE_WRONG_LENGTH] Wrong length" + case 0x6982: + return "[APDU_CODE_EMPTY_BUFFER] Security condition not satisfied" + case 0x6983: + return "[APDU_CODE_OUTPUT_BUFFER_TOO_SMALL] Authentication method blocked" + case 0x6984: + return "[APDU_CODE_DATA_INVALID] Referenced data reversibly blocked (invalidated)" + case 0x6985: + return "[APDU_CODE_CONDITIONS_NOT_SATISFIED] Conditions of use not satisfied" + case 0x6986: + return "[APDU_CODE_COMMAND_NOT_ALLOWED] Command not allowed (no current EF)" + case 0x6A80: + return "[APDU_CODE_BAD_KEY_HANDLE] The parameters in the data field are incorrect" + case 0x6B00: + return "[APDU_CODE_INVALIDP1P2] Wrong parameter(s) P1-P2" + case 0x6D00: + return "[APDU_CODE_INS_NOT_SUPPORTED] Instruction code not supported or invalid" + case 0x6E00: + return "[APDU_CODE_CLA_NOT_SUPPORTED] Class not supported" + case 0x6F00: + return "APDU_CODE_UNKNOWN" + case 0x6F01: + return "APDU_CODE_SIGN_VERIFY_ERROR" + default: + return fmt.Sprintf("Error code: %04x", errorCode) + } +} + +func (ledger *Ledger) Write(buffer []byte) (int, error) { + totalBytes := len(buffer) + totalWrittenBytes := 0 + for totalBytes > totalWrittenBytes { + writtenBytes, err := ledger.device.Write(buffer) - // Write writes an output report to device. The first byte must be the - // report number to write, zero if the device does not use numbered reports. - Write([]byte) error + if ledger.Logging { + fmt.Printf("[%3d] =) %x\n", writtenBytes, buffer[:writtenBytes]) + } - // ReadCh returns a channel that will be sent input reports from the device. - // If the device uses numbered reports, the first byte will be the report - // number. - ReadCh() <-chan []byte + if err != nil { + return totalWrittenBytes, err + } + buffer = buffer[writtenBytes:] + totalWrittenBytes += writtenBytes + } + return totalWrittenBytes, nil +} - // ReadError returns the read error, if any after the channel returned from - // ReadCh has been closed. - ReadError() error +func (ledger *Ledger) Read() <-chan []byte { + ledger.readCo.Do(ledger.initReadChannel) + return ledger.readChannel +} + +func (ledger *Ledger) initReadChannel(){ + ledger.readChannel = make(chan []byte, 30) + go ledger.readThread() +} + +func (ledger *Ledger) readThread() { + defer close(ledger.readChannel) + + for { + buffer := make([]byte, PacketSize) + readBytes, err := ledger.device.Read(buffer) + + if ledger.Logging { + fmt.Printf("[%3d] (= %x\n", readBytes, buffer[:readBytes]) + } + + if err != nil { + return + } + select { + case ledger.readChannel <- buffer[:readBytes]: + default: + } + } } func (ledger *Ledger) Exchange(command []byte) ([]byte, error) { @@ -114,30 +180,23 @@ func (ledger *Ledger) Exchange(command []byte) ([]byte, error) { return nil, fmt.Errorf("APDU[data length] mismatch") } - serializedCommand, err := WrapCommandAPDU(Channel, command, PacketSize, false) - + serializedCommand, err := WrapCommandAPDU(Channel, command, PacketSize) if err != nil { return nil, err } // Write all the packets - err = ledger.device.Write(serializedCommand[:PacketSize]) + _, err = ledger.Write(serializedCommand) if err != nil { return nil, err } - for len(serializedCommand) > PacketSize { - serializedCommand = serializedCommand[PacketSize:] - err = ledger.device.Write(serializedCommand[:PacketSize]) - if err != nil { - return nil, err - } - } - input := ledger.device.ReadCh() - response, err := UnwrapResponseAPDU(Channel, input, PacketSize, false) + readChannel := ledger.Read() + + response, err := UnwrapResponseAPDU(Channel, readChannel, PacketSize) if len(response) < 2 { - return nil, fmt.Errorf("lost connection") + return nil, fmt.Errorf("len(response) < 2") } swOffset := len(response) - 2 @@ -146,38 +205,8 @@ func (ledger *Ledger) Exchange(command []byte) ([]byte, error) { if ledger.Logging { fmt.Printf("Response: [%3d]<= %x [%#x]\n", len(response[:swOffset]), response[:swOffset], sw) } - // FIXME: Code and description don't match for 0x6982 and 0x6983 based on - // apdu spec: https://www.eftlab.co.uk/index.php/site-map/knowledge-base/118-apdu-response-list if sw != 0x9000 { - switch sw { - case 0x6400: - return nil, errors.New("[APDU_CODE_EXECUTION_ERROR] No information given (NV-Ram not changed)") - case 0x6700: - return nil, errors.New("[APDU_CODE_WRONG_LENGTH] Wrong length") - case 0x6982: - return nil, errors.New("[APDU_CODE_EMPTY_BUFFER] Security condition not satisfied") - case 0x6983: - return nil, errors.New("[APDU_CODE_OUTPUT_BUFFER_TOO_SMALL] Authentication method blocked") - case 0x6984: - return nil, errors.New("[APDU_CODE_DATA_INVALID] Referenced data reversibly blocked (invalidated)") - case 0x6985: - return nil, errors.New("[APDU_CODE_CONDITIONS_NOT_SATISFIED] Conditions of use not satisfied") - case 0x6986: - return nil, errors.New("[APDU_CODE_COMMAND_NOT_ALLOWED] Command not allowed (no current EF)") - case 0x6A80: - return nil, errors.New("[APDU_CODE_BAD_KEY_HANDLE] The parameters in the data field are incorrect") - case 0x6B00: - return nil, errors.New("[APDU_CODE_INVALIDP1P2] Wrong parameter(s) P1-P2") - case 0x6D00: - return nil, errors.New("[APDU_CODE_INS_NOT_SUPPORTED] Instruction code not supported or invalid") - case 0x6E00: - return nil, errors.New("[APDU_CODE_CLA_NOT_SUPPORTED] Class not supported") - case 0x6F00: - return nil, errors.New("APDU_CODE_UNKNOWN") - case 0x6F01: - return nil, errors.New("APDU_CODE_SIGN_VERIFY_ERROR") - } - return nil, fmt.Errorf("invalid status %04x", sw) + return nil, errors.New(ErrorMessage(sw)) } return response[:swOffset], nil diff --git a/ledger_test.go b/ledger_test.go new file mode 100644 index 0000000..b43d157 --- /dev/null +++ b/ledger_test.go @@ -0,0 +1,103 @@ +// +build ledger_device + +/******************************************************************************* +* (c) 2018 ZondaX GmbH +* +* 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_go + +import ( + "encoding/hex" + "fmt" + "github.com/ZondaX/hid-go" + "github.com/stretchr/testify/assert" + "testing" +) + +func Test_ThereAreDevices(t *testing.T) { + devices, err := hid.Devices() + if err != nil { + fmt.Printf("Error: %s", err) + } + + assert.NotEqual(t, 0, len(devices)) +} + +func Test_ListDevices(t *testing.T) { + ListDevices() +} + +func Test_FindLedger(t *testing.T) { + ledger, err := FindLedger() + if err != nil { + fmt.Println("\n*********************************") + fmt.Println("Did you enter the password??") + fmt.Println("*********************************") + t.Fatalf( "Error: %s", err.Error()) + } + assert.NotNil(t, ledger) +} + +func Test_BasicExchange(t *testing.T) { + ledger, err := FindLedger() + if err != nil { + fmt.Println("\n*********************************") + fmt.Println("Did you enter the password??") + fmt.Println("*********************************") + t.Fatalf( "Error: %s", err.Error()) + } + assert.NotNil(t, ledger) + + message := []byte{0x55, 0, 0, 0, 0} + + for i := 0; i < 10; i++ { + response, err := ledger.Exchange(message) + + if err != nil { + fmt.Printf("iteration %d\n", i) + t.Fatalf( "Error: %s", err.Error()) + } + + assert.Equal(t, 4, len(response)) + } +} + +func Test_LongExchange(t *testing.T) { + ledger, err := FindLedger() + if err != nil { + fmt.Println("\n*********************************") + fmt.Println("Did you enter the password??") + fmt.Println("*********************************") + t.Fatalf( "Error: %s", err.Error()) + } + assert.NotNil(t, ledger) + + path := "052c000080760000800000008000000000000000000000000000000000000000000000000000000000"; + pathBytes, err := hex.DecodeString(path) + if err != nil { + t.Fatalf("invalid path in test") + } + + header := []byte { 0x55, 1, 0, 0, byte(len(pathBytes))} + message := append(header, pathBytes...) + + response, err := ledger.Exchange(message) + + if err != nil { + t.Fatalf( "Error: %s", err.Error()) + } + + assert.Equal(t, 65, len(response)) +}