A Journey to offline JWT Authentication
JWT is a pretty common way of securely transmitting information between two parties. This post is not a deep dive into JWT tokens and their specifications, so I’ll spare the details. Jwt.io would be a great place to start if you wish to understand JWT.
Basically the idea is that sender will sign a json payload (along with appropriate header) with a secret or RSA private key and receiver can then verify the data against the same secret (in case of HMAC) or sender’s RSA public key (in case of asymmetric key pair).
In this post, we’ll mostly be looking into the tokens signed by asymmetric RSA key pair and how we can strengthen the verification process on receiver side.
For validation tokens on the receiver’s side in most common scenarios:
- We get the token and RSA public key from a trusted site over a secure channel.
- Use an out of the box library in any programming language to validate the token signature against the public key.
For illustrations in this post, we’ll use bash scripts because they’re readily usable and don’t actually need any prior setup. To simplify the process of JWT tampering and validation I have created this repo (jwt-offline-validation)
with useful scripts that will come handy during the process.
NOTE: The commands and scripts used in this post are only for demonstration purposes and are not intended to be used in production systems.
As a prerequisite, let’s install a couple of dependencies that will come handy during the process
sudo apt install jq basez
git clone https://github.com/rhythmize/jwt-offline-validation
Let’s get a fresh RS256 encoded token and corresponding Public Key from https://jwt.io/
and validate it locally
source /path/to/repo/scripts/token_helper.sh
# Create directory structure
mkdir -p valid_token
cd valid_token
# echo -n to drop newline at eof for encoded token
echo -n <encoded token> > token
echo <RSA public key> > public.pem
validate_token `cat token` public.pem
In the console you’ll see that token is valid.
For JWT authentication to be effective, we need to establish a root of trust on the receiving side, which in the above case is provided by underlying https
connection to https://jwt.io/
. And since we are fetching the token and public key directly from the server, we can safely assume that there was no tampering of the JWT or RSA Public Key.
Things get little more interesting when some constraints are added in the above scenario. Specifically when the receiving side has no direct access to the sender, it’s an offline device which relies on some intermediary to fetch the token and the public key from remote server and pass it on to the device.
In this case, we would have no way to establish root of trust on receiving side because token and public key are received over an insecure channel. So there’s a high chance that they were tampered.
To illustrate this, let’s take an expired token from the URI below and try to play around with it.
https://jwt.io/#debugger-io?token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiVGVzdCBVc2VyIiwiaXNzIjoidGVzdF9pc3N1ZXIiLCJpYXQiOjE2NDA5NzU0MDAsImV4cCI6MTY3MjU5NzgwMCwic2NvcGUiOiJ0ZXN0In0.ldru_GBr2Smpzk9ofc8fQ0-3H4xpgTmpDxSYjb01qoKbREGFnB7gs1aXGNcXUK4gj5XmuoFckPhOQ0CEazEoq9kpFVqMUD-pLCdzLGQiK7foCJAY4uADcKjcLm3BFxNMBG6bkNl0RqZRiq773gxTD0RNpORkk7UwJflHgBrRB5gwpyQ3c4GP0yrgj-FQS-aMzuFq7Fs3y5ID88d_9RL-lasYBSsGJYkXkf1PUr-aA8nyD6zxdP3SreRvYbEGkvd_U6pLFfDTYB61MX96bldVsFBdf60fqzkYEdUfPwpEzyeL0_LbsnrP35PF61gNA4L-A10_sDIvtFCH8p5XEJ61vw&publicKey=-----BEGIN%20PUBLIC%20KEY-----%0AMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo%0A4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0%2FIzW7yWR7QkrmBL7jTKEn5u%0A%2BqKhbwKfBstIs%2BbMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyeh%0Akd3qqGElvW%2FVDL5AaWTg0nLVkjRo9z%2B40RQzuVaE8AkAFmxZzow3x%2BVJYKdjykkJ%0A0iT9wCS0DRTXu269V264Vf%2F3jvredZiKRkgwlL9xNAwxXFg0x%2FXFw005UWVRIkdg%0AcKWTjpBP2dPwVZ4WWC%2B9aGVd%2BGyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbc%0AmwIDAQAB%0A-----END%20PUBLIC%20KEY-----
Please copy and paste the link into the browser, creating a hyperlink was modifying the HTML entities in the URI, rendering the token invalid.
mkdir -p ../original_token
cd ../original_token
# echo -n to drop newline at eof for encoded token
echo -n <encoded token> token
echo <RSA public key> public.pem
validate_token `cat token` public.pem
For now this token has valid signature but it is expired (as exptected)
Currently, the token signature is valid i.e. it’s untampered but the token is expired, which is expected. Now, the intermediary can always modify this expired token, sign it with it’s own RSA Key Pair and provide the receiver with modified token and it’s own public part of the RSA Key (instead of the one received from the actual server).
mkdir -p ../rsa_keypair
cd ../rsa_keypair
# Create new RSA key pair
openssl genrsa -out private.pem 4096
openssl rsa -in private.pem -outform PEM -pubout -out public.pem
# Modify original token (update exp claim) and sign with own private key
modify_token `cat ../original_token/token` private.pem > updated_token
# Validate updated token against public.pem
validate_token `cat updated_token` public.pem
NOTE: In case you see the expired token error with
validate_token
command, feel free to re-runmodify_token
because tokens are set to expire after 2 mins.
And the token is valid now, we converted an invalid token into a valid one. In the real world scenario, the intermediary will have all the opportunities to tamper the token, sign it with it’s own private key and send the updated token and it’s own public key to the receiver. And since receiver has no way to validate the authenticity of public key, it’ll treat the token as valid and behave accordingly.
Now it gets more important to validate the authenticity of RSA public key itself before validating the token.
One way to ensure the authenticity of RSA public key is that we provide it to the receiver beforehand over a secure channel, like embed it into the firmware of the system.
NOTE: Here of course we’re assuming that the firmware itself will be locked and secured against any type of tampering, otherwise everything falls apart, already!
So now we only rely on intermediary for the token and not the public key. We’ll use the already known public key to validate the token.
# Validate updated token against original_token/public.pem
validate_token `cat updated_token` ../original_token/public.pem
And if we validate the modified token against the original public key (which is supposed to be communicated over a secure channel), the token verification fails with invalid signature and only the original untampered token issued by the remote server will have a valid signature. Any kind of tampered token will be rendered invalid.
Okay, looks like we found a way to establish root of trust on an offline device. But there’s another catch, what happens when sender decides to rotate it’s Key Pair. After all, it’s a pretty common practice in cryptography to rotate the keys after a certain period.
So once the sender rotates it’s key pair and starts signing the payload with it’s new Private Key, the receiver would render all these tokens as invalid because it’s still using the old public key for validation. So we need to make a firmware update with the new public key (or somehow communicate the new Public Key over the secure channel) and get it updated on the receiver. Until that is done, the receiver would be rendered useless because it cannot validate even the valid token.
In the next post, we’ll try to bridge this gap in the overall flow and build on top of the setup we established until now to make this whole flow rotation agnostic. Please check it out here.