Authentication failure - Websocket feed Advanced Trading API JWT (Rust)

Hey!

I am trying to subscribe to the level2 websocket feed using a JWT from a Rust websocket client.
There is a similar question for Legacy based auth, but I felt it is best to build it using JWT to be as up to date as possible.

I have tried converting the python code JWT signing example into a Rust implementation.
Whilst I am able to genereate the JWT successfully, I appear to be getting an authentication error after sending my message and I am not sure why.

Here is my JWT generation code (mostly using the jsonwebtoken and openssl libraries):

pub fn generate_jwt(&self, timestamp: &u64) -> String {
  debug!("Generating JWT for Websocket authentication");
  let service = "public_websocket_api";
  let key_name = &self.name;
  let expiration = timestamp + 60;

  let private_key  = openssl::pkey::PKey::private_key_from_pem(&self.privateKey.as_bytes()).unwrap();
  let private_key_pem = private_key.private_key_to_pem_pkcs8().unwrap();

  let claims = CoinbaseClaims {
      sub: key_name.to_owned(),
      iss: "coinbase-cloud".to_owned(),
      nbf: timestamp.to_string().to_owned(),
      exp: expiration.to_string().to_owned(),
      aud: vec![service.to_string()],
  };

  let header = Header {
      typ: Some("JWT".to_string()),
      alg: Algorithm::ES256,
      cty: None,
      jku: None,
      jwk: None,
      kid: Some(key_name.to_string()),
      x5u: None,
      x5c: None,
      x5t: None,
      x5t_s256: None,
  };

  let jwt = encode(
      &header,
      &claims,
      &EncodingKey::from_ec_pem(&private_key_pem).unwrap(),
  )
  .unwrap();

  debug!("Generated JWT: {:?}", jwt);

  jwt
}

Note that in this case &self is a struct which is created based on the Trading API key .json file via deserialization.

The message I send looks as follows:

let timestamp = SystemTime::now()
    .duration_since(UNIX_EPOCH)
    .unwrap()
    .as_secs();


let subscribe_message = format!(
    r#"{{
    "type": "subscribe",
    "product_ids": ["{}"],
    "channel": "level2",
    "jwt": "{}",
    "timestamp": "{}"
}}"#,
    &pair,
    &credentials.generate_jwt(&timestamp),
    &timestamp.to_string()
);

What am I missing?

Also, there is no way to pass the nonce into the Header since this is not a field belonging to the jsonwebtoken defined struct, but I doubt this is the issue. If it is I would need to add some more logic accordingly.

Without nonce I get authentication failure, it is needed!

Ah that is helpful, gonna see if I can find a way to mimick encode without relying on jsonwebtoken::encode then :slight_smile: thx!

Hmm… I am now passing a nonce as well, and have edited how the jwt gets created accordingly by redoing some of the jsonwebtoken functionality:

/// A module specific for Coinbase authentication
pub(crate) mod coinbase {

    use ::base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
    use jsonwebtoken::{crypto, Algorithm, EncodingKey};
    use log::{debug, error, info};
    use serde::{Deserialize, Serialize};
    use std::{fs::File, io::Read};

    pub trait CoinbaseJwtComponent {
        fn b64_encode_part<T: Serialize>(&self) -> Result<String, jsonwebtoken::errors::Error>;

        fn b64_encode<T: AsRef<[u8]>>(input: T) -> String;
    }

    #[derive(Serialize, Debug)]
    pub struct CoinbaseClaims {
        pub iss: String,
        pub sub: String,
        pub nbf: String,
        pub exp: String,
        pub aud: Vec<String>,
    }

    impl CoinbaseJwtComponent for CoinbaseClaims {
        fn b64_encode_part<T: Serialize>(&self) -> Result<String, jsonwebtoken::errors::Error> {
            let json = serde_json::to_vec(&self)?;
            Ok(CoinbaseClaims::b64_encode(json))
        }

        fn b64_encode<T: AsRef<[u8]>>(input: T) -> String {
            URL_SAFE_NO_PAD.encode(input)
        }
    }

    #[derive(Serialize, Debug)]
    pub struct CoinbaseHeader {
        pub kid: String,
        pub nonce: String,
        pub algorithm: Algorithm,
    }

    impl CoinbaseHeader {
        pub fn new(kid: &str, nonce: &str, algorithm: Algorithm) -> Self {
            Self {
                kid: kid.to_string(),
                nonce: nonce.to_string(),
                algorithm,
            }
        }
    }

    impl CoinbaseJwtComponent for CoinbaseHeader {
        fn b64_encode_part<T: Serialize>(&self) -> Result<String, jsonwebtoken::errors::Error> {
            let json = serde_json::to_vec(&self)?;
            Ok(CoinbaseHeader::b64_encode(json))
        }

        fn b64_encode<T: AsRef<[u8]>>(input: T) -> String {
            URL_SAFE_NO_PAD.encode(input)
        }
    }

    #[derive(Deserialize, Debug)]
    #[serde(rename_all = "camelCase")]
    pub struct CoinbaseCredentials {
        pub name: String,
        pub principal: String,
        pub principal_type: String,
        pub public_key: String,
        pub private_key: String,
        pub create_time: String,
        pub project_id: String,
        pub nickname: String,
        pub scopes: Vec<String>,
        pub allowed_ips: Vec<String>,
        pub key_type: String,
        pub enabled: bool,
    }

    impl CoinbaseCredentials {
        pub fn new(filepath: &str) -> Self {
            info!("Loading CoinbaseCredentials from JSON file: {}", filepath);

            let mut file = File::open(&filepath).unwrap();

            let mut contents = String::new();
            file.read_to_string(&mut contents).unwrap();

            match serde_json::from_str(&contents) {
                Ok(creds) => creds,
                Err(e) => {
                    error!("Deserialization error: {:?}", e);
                    std::process::exit(0x0500);
                }
            }
        }

        /// Generates the JWT for websocket authentication.
        /// The implementation uses an EC private key, and the ES256 algorithm.
        pub fn generate_jwt(&self, timestamp: &u64) -> String {
            debug!("Generating JWT for Websocket authentication");
            let service = "public_websocket_api";
            let key_name = &self.name;
            let expiration = timestamp + 60;

            let private_key =
                openssl::pkey::PKey::private_key_from_pem(&self.private_key.as_bytes()).unwrap();
            let private_key_pem = private_key.private_key_to_pem_pkcs8().unwrap();
            let encoding_key = EncodingKey::from_ec_pem(&private_key_pem).unwrap();

            let claims = CoinbaseClaims {
                sub: key_name.to_owned(),
                iss: "coinbase-cloud".to_owned(),
                nbf: timestamp.to_string().to_owned(),
                exp: expiration.to_string().to_owned(),
                aud: vec![service.to_string().to_owned()],
            };

            let header = CoinbaseHeader::new(
                &key_name,
                &jsonwebtoken::get_current_timestamp().to_string(),
                jsonwebtoken::Algorithm::ES256,
            );

            let enc_header = header.b64_encode_part::<CoinbaseHeader>().unwrap();
            let enc_claims = claims.b64_encode_part::<CoinbaseClaims>().unwrap();
            let message = [enc_header, enc_claims].join(".");
            let signature =
                crypto::sign(message.as_bytes(), &encoding_key, header.algorithm).unwrap();

            let jwt = [message, signature].join(".");

            debug!("Generated JWT: {:?}", jwt);

            jwt
        }
    }
}

However, I am still getting authentication failure :roll_eyes:.

Does anyone have an idea of whats up here?

Use https://jwt.io/ to test your generated JWT.

1 Like

Seems the signature is invalid :confused:

I am not quite sure why as the contents seems to be correct in the header and message according to the Advanced Trading API Authentication documentation.

There has to be an error somewhere though, so if someone has an idea what the issue could be then that would be very useful to know.

Did you remove EC part for public key? I think otherwise it did not accept my public key…

Otherwise did that tool only tell you that signature is invalid? If I remember correctly it did say that I was not properly base64 encoding.

You have one b64_encode function that does not use URL_SAFE_NO_PAD.encode, maybe that is problem? Sorry, I don’t recognize language, can not help more… But if validation fails in that debug tool you will get authentication problems.

Maybe try to use same input in example they/Coinbase provided and compare generated output? Maybe that will help finding where problem is.

Which EC part are you referring to wrt. the public key?

Yeah, the tool only mentioned the signature was invalid.
Actually, URL_SAFE_NO_PAD.encode call is used for both the message (actually twice, once for the header, and once for the claims), and for the signature. The b64_encode of the signature looks like this:

/// The actual ECDSA signing + encoding
/// The key needs to be in PKCS8 format
pub fn sign(
    alg: &'static signature::EcdsaSigningAlgorithm,
    key: &[u8],
    message: &[u8],
) -> Result<String> {
    let rng = rand::SystemRandom::new();
    let signing_key = signature::EcdsaKeyPair::from_pkcs8(alg, key, &rng)?;
    let out = signing_key.sign(&rng, message)?;
    Ok(b64_encode(out))
}

So even the signature is b64_encoded actually.

I am suspicious that it has something to do with the way I create the private_key. First of all, the private key has some \n in it from the .json, and secondly, because I use the openssl library as a workaround. The jsonwebtoken library simply did not let me use the ES256 algorithm in the encode function, or let me load the private key from jsonwebtoken::from_ec_pem directly when using the key from the .json for some reason.

I tested the jwt from the Python snippet they added to the docs, and even that gives invalid signature when evaluating from the jwt.io tool.

“-----BEGIN EC PUBLIC KEY-----\nREMOVED\n-----END EC PUBLIC KEY-----\n”

I had to remove EC part and replace newlines with actual newlines. Otherwise that tool says “Invalid Signature”. Removing EC make sure to remove also extra space after it.

Should look something like this before you copy it in that tool:

-----BEGIN PUBLIC KEY-----
LINE
LINE
-----END PUBLIC KEY-----

That should not be problem unless your code does not treat that as actual newline.

Thanks, but still get Invalid signature. Not quite sure what is missing here, so will need to experiment a bit to see what I find.

Hi @br3damatt! Can you just try this:

-----BEGIN ECDSA Private Key-----\n paste your key here \n-----END ECDSA Private Key-----\n

Note: Please just copy paste it as it is once (replacing paste your key here)

Rather than EC, please try ECDSA.

Let us know if it works.

Sure, will give it a spin now.

This threw an error:

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: ErrorStack([Error { code: 503841036, library: "DECODER routines", function: "OSSL_DECODER_from_bio", reason: "unsupported", file: "crypto/encode_decode/decoder_lib.c", line: 102, data: "No supported data to decode. Input type: PEM" }])', src/credentials.rs:155:89

on this line in particular:

let private_key =
                openssl::pkey::PKey::private_key_from_pem(&self.private_key.as_bytes()).unwrap();

Where self.private_key is the private key from the .json (now formatted with ECDSA rather than EC).

Try python code here. Adding your key name and secret you should get your account list.

Then hardcode timestamp in that example and in your code. Make sure that headers and payload with same input gives equal encoded parts. If all good you will need to find why signature is not correctly generated.

If you can provide minimal working code to reproduce your problem I might try it.

Hmm, already tried with the following Python snippet with no luck:

import jwt
from cryptography.hazmat.primitives import serialization
import time

key_name     = ""
key_secret   = ""
service_name = "public_websocket_api"

def build_jwt(service):
    private_key_bytes = key_secret.encode('utf-8')
    private_key = serialization.load_pem_private_key(private_key_bytes, password=None)

    jwt_payload = {
        'sub': key_name,
        'iss': "coinbase-cloud",
        'nbf': int(time.time()),
        'exp': int(time.time()) + 60,
        'aud': [service],
    }

    jwt_token = jwt.encode(
        jwt_payload,
        private_key,
        algorithm='ES256',
        headers={'kid': key_name, 'nonce': str(int(time.time()))},
    )

    return jwt_token

def main():
    jwt_token = build_jwt(service_name)

    #print(f"export JWT={jwt_token}")
    print(jwt_token)

if __name__ == "__main__":
    main()

That still gives me invalid signature in the jwt.io tool.

I have also tried testing with a GENERAL_API_KEY rather than a TRADING_API_KEY, but I still get authentication failure.

I have even gone so far as to try to use Legacy authentication, but it seems that whenever I create an API key on Coinbase.com, I am unable to see the secret key when creating a new key, even when I have deleted all keys, cleared my browser cache, and history, opened a new window, and tried a new browser.

Wth is going on here? :confused:

Hey @br3damatt! Can you try in Incognito mode once? May be change the browser if possible because we have seen that some extensions are causing issues with key generation. Also, please do not use Safari. Previously Safari users have shared that they were not able to see the secret key.

Hi, thanks.

I downloaded Chrome, and that worked. However, I cannot enable the API key for another 47.5 hours, or so :roll_eyes:

Thanks though!

First, does example I linked works with your API key & secret? Can you get accounts list? I tested that example and it should work, otherwise there is some problem with your API key and/or secret.

Now when you get that working, try to that jwt.io tool. As I said - you need to “modify” your public key before pasting it in that tool. You need to change BEGIN EC PUBLIC KEY to BEGIN PUBLIC KEY and END EC PUBLIC KEY to END PUBLIC KEY and probably also replace newlines with actual lines. You should end up with something like this:

-----BEGIN PUBLIC KEY-----
LINE1
LINE2
-----END PUBLIC KEY-----

Then select ES256 algorithm and paste “modified” public key into public key field. Now pasting your generated JWT for accounts should hopefully show that it is valid.

Now when you know that tool can really validate your generated JWT you can try generate one for websocket with python. From quick look it probably should be correctly generated.

You might try to search this forum, there might be python examples also for websocket. That way you can test/validate that token is generated correctly also for websocket.

If all above works you can return to your rust implementation. If header and payload is generated in same way and you still get invalid signature you will know that there is some problem in how you handle secret in your implementation.

Hey @br3damatt We understand, that is an inconvenience. However this is a security feature. But glad to hear that it worked!