Skip to content

Instantly share code, notes, and snippets.

@timmc
Last active April 26, 2021 08:51
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save timmc/d2814d7da19521dda1883dd3cc046217 to your computer and use it in GitHub Desktop.
Save timmc/d2814d7da19521dda1883dd3cc046217 to your computer and use it in GitHub Desktop.
#!/bin/bash
echo "DO NOT USE -- incorrect signature format, see comments on gist."
exit 1
# Create and sign a JWT token with ES256 given the path to an ECDSA
# private key and a JSON payload.
# $0 path/to/keypair.der '{"JSON": "payload"}'
# Example keypair creation:
# openssl ecparam -name prime256v1 -genkey -noout -outform DER > example-keypair.der
# A few tips for generating the payload:
# - Pipe raw strings through `jq --raw-input .` to encode them as
# JSON strings. https://stedolan.github.io/jq/
# - GNU date is great for generating the iat, nbf, and exp time
# fields: `date --date="15 minutes" +"%s"`
set -eu -o pipefail
keypair_path="$1"
payload="$2"
function base64_urlsafe {
# Implement own URL-safe Base64 based on standard version. Delete
# padding, undo wrapping, and swap out chars 62 and 63. Not all
# versions of `base64` have the `--wrap=0` that GNU coreutils has.
base64 | tr -d '\r\n=' | tr '+/' '-_'
}
header_enc="$(echo -n '{"typ":"JWT","alg":"ES256"}' | base64_urlsafe)"
payload_enc="$(echo -n "$payload" | base64_urlsafe)"
message="$header_enc.$payload_enc"
# If you're on a Mac, you might have a really old version of openssl
# that doesn't support ECDSA signing this way.
sig="$(echo -n "$message" | openssl dgst -sha256 -sign "$keypair_path" -keyform DER | base64_urlsafe)"
echo -n "$message.$sig"
@bric3
Copy link

bric3 commented Mar 27, 2020

Hi could you be more specific about the issue about signing ?

My macOs has version LibreSSL 2.8.3, and installing LibreSSL 3.0.2, did not yield a signature that appear valid on jwt.io. By the way my input is a p8 file, which is encoded as a PEM.

@timmc
Copy link
Author

timmc commented Mar 27, 2020

No idea, sorry. I don't have access to a Mac.

@bric3
Copy link

bric3 commented Mar 29, 2020

@timmc thx for replying that fast, I also tried on Fedora with OpenSSL 1.1.1d FIPS 10 Sep 2019, and the jwt signature is also invalid according to jwt.io I'm nost sure what's wrong as openssl does not report any error.

@bric3
Copy link

bric3 commented Mar 30, 2020

For reference I was able to do what I wanted using python

#!/usr/bin/env python3

# Run with
#   python3 siwa_client_secret.py a.code
#
# requires to install
#   pip3 install cryptography
#   pip3 install pyjwt

import sys
import jwt
import time
import urllib
from urllib.parse import urlencode
from urllib.request import Request, urlopen

authorization_code = sys.argv[1]

bundle_id = "..."
key_id = "..."
team_id = "..."
key = """
-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----
"""


issued_at = int(round(time.time()))
expiration = issued_at + 3600

claims = {
    'aud': 'https://appleid.apple.com',
    'sub': bundle_id,
    'iss': team_id,
    'exp': expiration,
    'iat': issued_at,
}


client_secret = jwt.encode(claims, key, algorithm='ES256', headers={'kid': key_id}).decode("utf-8")
print("using secret: %s\n" % client_secret)

url = 'https://appleid.apple.com/auth/token'
payload = {
    "grant_type": "authorization_code",
    "client_secret": client_secret,
    "code": authorization_code,
    "client_id": bundle_id
}
headers = {
    "Content-type": "application/x-www-form-urlencoded"
}

print("calling apple...")

try:
    response = urlopen(Request(url, urlencode(payload).encode()))

    print("\033[1;32;48mcode: %d => %s \033[1;37;0m" % (response.code, response.read.decode("utf8", "ignore")))
except urllib.error.HTTPError as e:
    print("\033[1;31;48mcode: %d => %s \033[1;37;0m" % (e.code, e.read().decode("utf8", 'ignore')))

@madaster97
Copy link

@bric3, @timmc, I think this is wrong. See this Stack Overflow for details, but effectively openssl is outputting a specific format of signature that isn't actually a valid JWS signature (not even a base64url decoded one). It's wrapping the two integers (R and S) in a DER format that can be read by the ASN1 module within openssl. This SO article goes into detail about that encoding/wrapper.

@timmc
Copy link
Author

timmc commented Feb 21, 2021

Hmm, thanks! That's unfortunate.

Luckily, I only ever used this script to generate values for a test suite, or something similar. We did have trouble with it not working on everyone's computer, so I wonder if there was a difference in output for certain openssl versions.

I'll put a giant disclaimer at the top. :-)

@madaster97
Copy link

I posted my take on the procedure here.

I think you can fix this by making the following switch:

# Current signature construction
sig="$(echo -n "$message" | openssl dgst -sha256 -sign "$keypair_path" -keyform DER | base64_urlsafe)"

# Replacement
sig ="$(echo -n "$message" | openssl dgst -sha256 -sign "$keypair_path" -keyform DER | openssl asn1parse -inform DER | perl -n -e'/INTEGER           :([0-9A-Z]*)$/ && print $1' | xxd -p -r | base64_urlsafe)"

I've just added a couple extra processing steps:

  1. To let openssl parse the signature
  2. A regex to extract the R and S integers from that output
  3. xxd to parse the strings as hex (that's how asn1parse outputs them) and converts them to binary before passing to your base64_urlsafe alg.

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