3

Problem Statement:

When authenticating with the Coinbase API I receive this response:

body: "{\"errors\":[{\"id\":\"authentication_error\",\"message\":\"invalid signature\"}]}"

Source as it currently stands:

(Feedback on general Elixir style also appreciated, this is my first project in the language)

defmodule Request do
  defstruct(
    method: "",
    path: "",
    base: "",
    body: "",
    timestamp: nil,
    key: nil,
    secret: nil,
    signature: nil
  )

  require HTTPotion
  require Poison

  def new(method, path, body, key, secret, server_time) do
    if !(Enum.member? [:GET, :POST, :PUT, :PATCH, :DELETE], method), do: raise ArgumentError, message: "Unsupported HTTP method #{method}"
    base_url      = "https://api.coinbase.com/v2"
    request       =
      %Request{
        method:    method,
        path:      path,
        body:      body,
        base:      base_url,
        key:       key,
        secret:    secret,
        timestamp: server_time,
        signature: nil,
    }

    Request.sign(request)
  end

  def sign(request) do ## See https://docs.pro.coinbase.com/?ruby#signing-a-message
    pre_hash =
      Integer.to_string(request.timestamp) <>
      Atom.to_string(request.method)       <>
      request.base <> request.path         <> ## I've tried both with the path ("/accounts"), with the API version "/v2/accounts", and the full path ("https://")
      request.body

    ## See note on what I've tried for variations on this bit:
    decoded_secret = Base.decode64!(request.secret) ## Says to do this in the pro docs, but not in the normal ones.  I've tried both ways.
    signature      = :crypto.hmac(:sha256, decoded_secret, pre_hash) |> 
                                         Base.encode16(case: :lower) |> ## Suggested in linked question.  I've tried both with and without.
                                         Base.encode64

    %Request{request | signature: signature}
  end

  def send!(request) do
    payload = [
      body:             request.body,
      follow_redirects: true,
      headers:
      [
        "CB-ACCESS-KEY": request.key,
        "CB-ACCESS-SIGN": request.signature,
        "CB-ACCESS-TIMESTAMP": request.timestamp,
        "CB-VERSION": "2019-09-18",
        "Content-Type": "application/json",
      ]
    ]

    case request.method do
      :GET ->
        HTTPotion.get request.base <> request.path, payload
      ## ...
      _ ->
        raise "Unrecognized HTTP verb '#{request.method}'"
    end
  end

  def server_time do
    response = Poison.decode! HTTPotion.get("https://api.coinbase.com/v2/time").body
    response["data"]["epoch"]
  end
end

Which I call using:

iex(#)> request = Request.new(:GET, "/accounts", "", key, secret, Request.server_time)
iex(#)> request |> Request.send!
... 
...
...
  status_code: 401
}
iex(#)> request
%Request{
  base: "https://api.coinbase.com/v2",
  body: "",
  key: "MY-KEY",
  method: :GET,
  path: "/accounts",
  secret: "MY-SECRET",
  signature: "ZTNjYWzEZjVjNTMxDOgzZjA5NGNjNzZkMWFiTKkwOIG0NGM1MzBjYmNmNzNhYzcyZGIxMmFhMTA0NTRjMWJjYg==", ## Not the real signature
  timestamp: 1571800107
}

So far I've tried:

  • Base64 decoding the secret (as suggested in the pro docs)
  • Base16 encoding (and lowercasing) the signature before Base64 encoding it as suggested in this this answer
  • Using the full path "https://api.coinbase.com/v2/accounts"
  • Using just the resource path: /accounts
  • (Edit based on comments): Also tried /v2/accounts and /v2/accounts/
  • Numerous variations on the path etc.

What am I doing wrong?

Edit:

From the pro docs:

Remember to first base64-decode the alphanumeric secret string (resulting in 64 bytes) before using it as the key for HMAC. Also, base64-encode the > digest output before sending in the header.

(Emphasis mine)

I notice that the byte_size/1 of my decoded_secret ends up with only 24 bytes:

decoded_secret = Base.decode64!(request.secret)
IO.puts byte_size(decoded_secret) # => 24

Not 64 as the docs specify. Still digging into this.

kingsfoil
  • 3,795
  • 7
  • 32
  • 56

2 Answers2

1

To produce a valid signature:

  def sign(method, path, body \\ "", timestamp, secret) do
    message    = generate_message(timestamp, method, path, body)

    _signature =
      :crypto.mac(:hmac, :sha256, secret, message)
      |> Base.encode16(case: :lower)
  end

  def generate_message(timestamp, method, path, body \\ "") do
    "#{timestamp}#{method}#{path}#{body}"
  end

Valid verifiable signature data can be produced by generating a variety of signatures from the offical ruby and python clients.

Using the data provided there, the following tests can be generated to ensure that the signature is correct:

describe "sign/5" do

    test "produces the correct hash with the test data" do
      secret     = "bar"
      timestamp  = 1636971273
      method     = "GET"
      path       = "/zork"
      body       = "{'quux': 'zyzx'}"
    
      signature  = Sign.sign(method, path, body, timestamp, secret)

      assert signature == "6aed30a898d9c87ef9f652d81e49464c65ff9406801e7edd238febe959f58dca"
    end

    test "produces the correct signature length with the test data" do
      secret_fun = "bar"
      timestamp  = 1636971273
      method     = "GET"
      path       = "/zork"
      body       = "{'quux': 'zyzx'}"
    
      signature  = Sign.sign(method, path, body, timestamp, secret)
    
      assert byte_size(signature) == 64
    end

  end

The hash above is correct given the secret, timestamp, method, path, and body defined in the test data.

A working implmentation is published on hex.

kingsfoil
  • 3,795
  • 7
  • 32
  • 56
  • any idea why the docs say you need to decode the secret from base64 and also encode the signature in base64 (you do base16) https://docs.cloud.coinbase.com/exchange/docs/authorization-and-authentication – Luke Belbina Jan 08 '22 at 06:15
  • I'm not sure. The docs have proved to be a little inconsistent within themselves so far, and several of the client libraries provided to interact with the API only worked after I modified them a bit. I'd say do whatever works for you, the above is what I got working after trying a multitude of different encodings, concatenations etc. Since I originally started the project both the Coinbase API and the `:crypto` library have changed a little bit. – kingsfoil Jan 08 '22 at 20:23
  • The way I finally got it working was to utilize another client library to produce a valid hash, and then try different combinations of encoding/decoding in different bases until I produced the same string. Which is contained in the unit tests above. – kingsfoil Jan 08 '22 at 20:25
1

The following worked for me:

def sign(secret, timestamp, method, path, body) do
  message = "#{timestamp}#{method}#{path}#{body}"

  _signature =
    :crypto.mac(:hmac, :sha256, Base.decode64!(secret), message)
    |> Base.encode64()
end
Luke Belbina
  • 5,708
  • 12
  • 52
  • 75