Skip to content

Instantly share code, notes, and snippets.

@joostd
Last active May 26, 2025 10:39
Show Gist options
  • Save joostd/6a55084a7171214372b7ce3c5dc43dd5 to your computer and use it in GitHub Desktop.
Save joostd/6a55084a7171214372b7ce3c5dc43dd5 to your computer and use it in GitHub Desktop.
Sign a JWT using a key generated on a YubiKey
#!/bin/bash
# step 1 - generate a new key pair on a YubiKey
yubico-piv-tool -a generate -s 9c -A ECCP256 -o pub.pem
# step 2 - generate data to be signed
jo iss=issuer aud=audience > payload.json
jo alg=ES256 typ=JWT > header.json
# base64-encode header and payload
basenc --base64url header.json | tr -d '\n=' > header.b64
basenc --base64url payload.json | tr -d '\n=' > payload.b64
echo -n . > dot
cat header.b64 dot payload.b64 > datatosign
# step 3 - sign using yubikey
yubico-piv-tool -a verify-pin --sign -s 9c -H SHA256 -A ECCP256 -i datatosign -o signature.der
# step 4 - verify
# verify using openssl
openssl dgst -sha256 -verify pub.pem -signature signature.der datatosign
# convert openssl signature
cat signature.der | openssl asn1parse -inform der | egrep -o '[A-F0-9]{62,64}' | xargs printf "%064s" | xxd -r -p | basenc --base64url | tr -d '\n=' > signature.b64
# construct jwt
cat datatosign dot signature.b64 > token.jwt
# verify using step
cat token.jwt | step crypto jwt verify --key pub.pem --iss issuer --aud audience
# verify using jwt.io
echo "Open the following URL in your browser:"
echo "https://jwt.io/#debugger-io?token=$(cat token.jwt)&publicKey=$(jq -sRr @uri pub.pem)"
@joostd
Copy link
Author

joostd commented Dec 19, 2023

This script uses a number of command-line tools:

  • yubico-piv-tool for generating keys and signatures using a YubiKey
  • jo to generate JSON files
  • basenc for base64url encoding
  • openssl for converting ECDSA signatures
  • step for validating JWT tokens
  • jq for encoding URLs

On macOS, use brew to install tools not installed by default:

brew install yubico-piv-tool jo coreutils step

@swbr
Copy link

swbr commented May 22, 2025

Hi!
There is one problem:

openssl asn1parse displays the hex representation of two 32byte numbers. Those 2x32bytes need to be concatenated to a 64byte dataset, which, after base64url-encoding, are the signature of the JWT.

BUT: openssl omits any leading zero bytes from the hex representation, so if one or more leading bytes of the 32byte number is zero, the displayed hex value is shorter.

Assuming each byte has a chance of 1:256 to be zero, in about 1/128 of all cases, one of the two hex strings is too short.
It's easy to test, just sign the same data with the same key over and over again, monitoring the length of the strings. You'll find outputs like this in about 1/128 of all cases:

    0:d=0  hl=2 l=  68 cons: SEQUENCE          
    2:d=1  hl=2 l=  33 prim: INTEGER           :D09AA478D8242125A4E93040DB452D09DE46DC6059D7DE9DD20AE84267E2777A
   37:d=1  hl=2 l=  31 prim: INTEGER           :239775F514C18F388383AD85558B2FBA023A76318F3C30254BB917A09658E3

To get a valid signature, padd short strings with zeros to the left, in this case:

D09AA478D8242125A4E93040DB452D09DE46DC6059D7DE9DD20AE84267E2777A
00239775F514C18F388383AD85558B2FBA023A76318F3C30254BB917A09658E3
^^

I have no idea how to accomplish that on the command line, but the signatures I fixed that way were successfully validated by jwt.io.

@joostd
Copy link
Author

joostd commented May 22, 2025

Ah, good catch!
That command doing the signature conversion is a bit of a kludge. Fixing it requires some extra ugliness:

cat signature.der | openssl asn1parse -inform der | egrep -o '[A-F0-9]{62,64}' | xargs printf "%064s" | xxd -r -p | basenc --base64url | tr -d '\n=' > signature.b64

I'll update the gist - thanks!

@swbr
Copy link

swbr commented May 23, 2025

Well, that greatly reduced the occurance, but the problem still exists, because the string can be just 60 characters if the first two bytes are zero, and so on.

I'm using the regex INTEGER\s*:([0-9A-F]*), even :([0-9A-F]*) should do the job. The hex string is returned as group, but it seems egrep does not support to return a specific group, and returns the entire string, only. So, some more adjustment is necessary on the command line.

By the way, the credit goes to someone writing a comment(!) on StackExchange below an answer about asn1parse pointing to the sometimes too short hex strings. If I hadn't read the comments, I would wonder about the 1% failure rate at a customer, soon...

@joostd
Copy link
Author

joostd commented May 26, 2025

Another way is

cat signature.der | openssl asn1parse -inform der | tail -2 | sed -E 's/.*:([A-F0-9]+)/\1/' | xargs printf "%064s" | xxd -r -p | basenc --base64url | tr -d '\n=' > signature.b64

Still a kludge of course. This example is not intended to be used as is, just as a quick and dirty example of how to sign JWTs using YubiKeys. It should be easy to translate into something more robust in any programming language.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment