0

I need to get JWT with EdDSA algorithm to be able to use an API. I have the private key to sign the message and I could do that with PHP with the next library: https://github.com/firebase/php-jwt (you can see the example with EdDSA at README). Now I need to do the same in JS but I didn't find the way to get JWT with a given secret key (encoded base 64) like that (only an example is not the real secretKey):

const secretKey = Dm2xriMD6riJagld4WCA6zWqtuWh40UzT/ZKO0pZgtHATOt0pGw90jG8BQHCE3EOjiCkFR2/gaW6JWi+3nZp8A==

I tried a lot of libraries like jose, js-nacl, crypto, libsodium, etc. And I am really close to get the JWT with libsodium library, now I attach the code:

const base64url = require("base64url");
const _sodium = require("libsodium-wrappers");
const moment = require("moment");

const getJWT = async () => {
  await _sodium.ready;
  const sodium = _sodium;

  const privateKey =
    "Dm2xriMD6riJagld4WCA6zWqtuWh40UzT/ZKO0pZgtHATOt0pGw90jG8BQHCE3EOjiCkFR2/gaW6JWi+3nZp8A==";
  const payload = {
    iss: "test",
    aud: "test.com",
    iat: 1650101178,
    exp: 1650101278,
    sub: "12345678-1234-1234-1234-123456789123"
  };
  const { msg, keyAscii} = encode(payload, privateKey, "EdDSA");
  const signature = sodium.crypto_sign_detached(msg, keyDecoded); //returns Uint8Array(64)
  //Here is the problem.
};
const encode = (payload, key, alg) => {
  const header = {
    typ: "JWT",
    alg //'EdDSA'
  };
  const headerBase64URL = base64url(JSON.stringify(header));
  const payloadBase64URL = base64url(JSON.stringify(payload));
  const headerAndPayloadBase64URL = `${headerBase64URL}.${payloadBase64URL}`;
  const keyAscii= Buffer.from(key, "base64").toString("ascii");
  return {headerAndPayloadBase64URL , keyAscii}
};

The problem is in the sodium.crypto_sign_detached function because it returns an Uint8Array(64) signature and and I need the JWT like that:

eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJpc3MiOiJ0ZXN0IiwiYXVkIjoidGVzdC5jb20iLCJpYXQiOjE2NTAxMDExNzgsImV4cCI6MTY1MDEwMTI3OCwic3ViIjoiMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MTIzIn0.f7WG_02UKljrMeVVOTNNBAGxtLXJUT_8QAnujNhomV18Pn5cU-0lHRgVlmRttOlqI7Iol_fHut3C4AOXxDGnAQ

How can I change the Uint8Array(64) to get the signature in a right format to get the JWT? I tried with base64, base64url, hex, text, ascii, etc and the final JWT is not valid (because the signature is wrong). If you compare my code with the code that I mentioned with PHP is very similar but the function sodium.crypto_sign_detached returns Uint8Array(64) at JS library and the same function in PHP returns an string and I can get the token. Or maybe there a way to adapt my given private key for use in other library (like crypto or jose where I received an error for the private key format) Thank you!

Topaco
  • 40,594
  • 4
  • 35
  • 62
  • Can't you just import the `Uint8Array` with `Buffer.from()` into a buffer and convert the buffer with `base64url()` into a Base64url string? This string would then only have to be concatenated with `headerAndPayloadBase64URL` (with a dot as separator). – Topaco Apr 15 '22 at 20:47
  • I tried with const newKey = base64url(Buffer.from(keyDecoded)); const signature = sodium.crypto_sign_detached(msg, newKey); But I received an error: TypeError: invalid privateKey length – Matias Wajnman Apr 15 '22 at 21:38
  • Why do you create a new key? Your problem was the signature as `Uint8Array`, at least according to your description. My suggestion was to convert this signature to Base64url and concatenate `headerAndPayloadBase64URL` with this Base64url signature to get a JWT in the desired format, s. [here](https://jwt.io/introduction), sec. *What is the JSON Web Token structure?* – Topaco Apr 15 '22 at 22:19
  • Now I get a signature that looks like the signature I got from PHP function (is not the same), but the final JWT is invalid. – Matias Wajnman Apr 16 '22 at 06:10
  • I changed the moment() value for moment().unix() and now it's ok that. I can check the token because I have an API when I set the token the API must work if the token is valid. I saw that when I decoded the key here: Buffer.from(key, "base64").toString("ascii") it returned a different value than PHP function (base64_encode) so I think that is the problem. With PHP base64_encode function I received: .3K��yd-}ڟv7qr7vgd> and with JS I received a different string h↑nz-6+cj<.$d3K-▲]@6yd6-☼[}→Z▼vgvgd.> . How is it possible? – Matias Wajnman Apr 16 '22 at 08:29
  • These are consistent keys for the NodeJS side (Base64): private: Dm2xriMD6riJagld4WCA6zWqtuWh40UzT/ZKO0pZgtHATOt0pGw90jG8BQHCE3EOjiCkFR2/gaW6JWi+3nZp8A== and public: wEzrdKRsPdIxvAUBwhNxDo4gpBUdv4GluiVovt52afA=. Post the JWT of the NodeJS and PHP code for this. You should use a constant value for `iat` in *both* codes. Analogously for `exp`. This makes a comparison of both signatures easier (Ed25519 is deterministic). – Topaco Apr 16 '22 at 09:01
  • @Topaco I edited the original post with the payload test and private key, thank you – Matias Wajnman Apr 16 '22 at 09:52
  • If I changed Buffer.from(key, "base64").toString("ascii") for Buffer.from(key, "base64").toString("binary") I received an error in sodium.crypto_sign_detached function: TypeError: invalid privateKey length, with those values that you sent me I have this JWT in PHP: eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJpc3MiOiJ0ZXN0IiwiYXVkIjoidGVzdC5jb20iLCJpYXQiOjE2NTAxMDExNzgsImV4cCI6MTY1MDEwMTI3OCwic3ViIjoiMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MTIzIn0.f7WG_02UKljrMeVVOTNNBAGxtLXJUT_8QAnujNhomV18Pn5cU-0lHRgVlmRttOlqI7Iol_fHut3C4AOXxDGnAQ – Matias Wajnman Apr 16 '22 at 09:53

1 Answers1

1

In the posted NodeJS code there are the following issues:

  • crypto_sign_detached() returns the signature as a Uint8Array, which can be imported with Buffer.from() and converted to a Base64 string with base64url().
  • Concatenating headerAndPayloadBase64URL and the Base64url encoded signature with a . as separator gives the JWT you are looking for.
  • The raw private key must not be decoded with 'ascii', as this generally corrupts the data. Instead, it should simply be handled as buffer. Note: If for some reason a conversion to a string is required, use 'binary' as encoding, which produces a byte string (however, this is not an option with crypto_sign_detached() as this function expects a buffer).

With these changes, the following NodeJS code results:

const _sodium = require('libsodium-wrappers');
const base64url = require("base64url");

const getJWT = async () => {  
    await _sodium.ready;
    const sodium = _sodium;
    const privateKey = "Dm2xriMD6riJagld4WCA6zWqtuWh40UzT/ZKO0pZgtHATOt0pGw90jG8BQHCE3EOjiCkFR2/gaW6JWi+3nZp8A==";
    const payload = {
        iss: "test",
        aud: "test.com",
        iat: 1650101178,
        exp: 1650101278,
        sub: "12345678-1234-1234-1234-123456789123"
     };  
     const {headerAndPayloadBase64URL, keyBuf} = encode(payload, privateKey, "EdDSA");
     const signature = sodium.crypto_sign_detached(headerAndPayloadBase64URL, keyBuf); 
     const signatureBase64url = base64url(Buffer.from(signature));
     console.log(`${headerAndPayloadBase64URL}.${signatureBase64url}`) // eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJpc3MiOiJ0ZXN0IiwiYXVkIjoidGVzdC5jb20iLCJpYXQiOjE2NTAxMDExNzgsImV4cCI6MTY1MDEwMTI3OCwic3ViIjoiMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MTIzIn0.f7WG_02UKljrMeVVOTNNBAGxtLXJUT_8QAnujNhomV18Pn5cU-0lHRgVlmRttOlqI7Iol_fHut3C4AOXxDGnAQ
};

const encode = (payload, key, alg) => {
    const header = {
        typ: "JWT",
        alg //'EdDSA'
    };
    const headerBase64URL = base64url(JSON.stringify(header));
    const payloadBase64URL = base64url(JSON.stringify(payload));
    const headerAndPayloadBase64URL = `${headerBase64URL}.${payloadBase64URL}`;
    const keyBuf = Buffer.from(key, "base64");
    return {headerAndPayloadBase64URL, keyBuf};
};

getJWT();

Test:
Since Ed25519 is deterministic, the NodeJS code can be checked by comparing both JWTs: If, as in the above NodeJS code, the same header and payload are used as in the PHP code, the same signature and thus the same JWT is generated as by the PHP code, namely:

eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJpc3MiOiJ0ZXN0IiwiYXVkIjoidGVzdC5jb20iLCJpYXQiOjE2NTAxMDExNzgsImV4cCI6MTY1MDEwMTI3OCwic3ViIjoiMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MTIzIn0.f7WG_02UKljrMeVVOTNNBAGxtLXJUT_8QAnujNhomV18Pn5cU-0lHRgVlmRttOlqI7Iol_fHut3C4AOXxDGnAQ

which shows that the NodeJS code works.


Note that instead of the moment package, Date.now() could be used. This will return the time in milliseconds, so the value has to be divided by 1000, e.g. Math.round(Date.now()/1000), but saves a dependency.

Topaco
  • 40,594
  • 4
  • 35
  • 62