Skip to content

Latest commit

 

History

History
370 lines (310 loc) · 15.7 KB

README.org

File metadata and controls

370 lines (310 loc) · 15.7 KB

#

#

#

NOTICE

This project has been superseded by a new tool I’ve written called lokey: https://github.com/jpf/lokey

lokey does everything that this code does, and more.

For example, to convert a JWKs result to PEM with lokey, you’d do one of the following:

$ lokey fetch jwk example.okta.com | lokey to pem
$ lokey fetch jwk login.salesforce.com | lokey to pem
$ lokey fetch jwk accounts.google.com | lokey to pem

Note that With lokey, you could also convert to other RSA key formats:

$ lokey fetch jwk example.okta.com | lokey to pem
$ lokey fetch jwk example.okta.com | lokey to ssh
$ lokey fetch jwk example.okta.com | lokey to pgp --name "John Doe" --email "[email protected]"

I’m keeping this project around because of how thoroughly it is documented in the hope that it’ll be useful for somebody wanting to learn about converting between JWK and PEM formats.

However, I will not be doing any further development on this project and encourage you to use lokey instead.

Joël Franusic November 6th, 2017

Introduction

This is a Python script that fetches JWKS results, and for each jwk, uses the modulus and exponent to generate a PEM encoded public key, suitable for use in tools like jwt.io

Here is an example of using this tool to get the PEM encoded public keys for the “example.okta.com” Okta org:

./jwks_to_pem.py --org example.okta.com
Fetching JWKS from example.okta.com
PEM for KID 're7eOFV6SiygSbCyYHGGdERFCJ_EoNpi9Duv0FIxllo'
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAgVdVgO4RogxtWt4XN2vO
9SG3Ekt6Qh+k6Io28dTNjEWuNxCYCtQI2dFtFs2y7OyLxQ2e3491XTDVRxtUx/Kl
RhQCcGDtLM5vTRWtGo39heg9dLWv7mqlk+jVkJrK2vAO+0bfl0x2Ouov4VS4Ixwx
lJfaec8v0cw+xjcJc29Y28WNFYhW/wpf1uEHYAf/pQ9q7S25rhK5yPv23101P7pA
bCNDyFB6PYLuXxqkE7dq7rIZXfw5xgNQBRugBrSmUEjoCFs3XowAXCk2gWhM/1Lg
mSqaaAh/Cu5vvzM0wYoaEi598LWYmtgurQ3C2Nenu8HVjI+zCSg8v7VrcTa1MHua
owIDAQAB
-----END PUBLIC KEY-----

PEM for KID 'kTP2cLZY0qA2qfnedNEgx6rs6yqIEdf4DQYYV2m4KPQ'
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjKb91FLaoZe9/5NEMZrO
1eDn4hdrhtjrvsy+qO1QIbbdhRXJIJoE+qpHmgmq1gK28OZCV51xUAwk8ugw5p7/
m2wIarykHtXuBmhcFPkWez6N/yX30qvdOPPKUGqd05AoGcrzAW6fV07CRROU+5g1
RnTdNasLEMYaq0xPlmCMDjb3usyiafGyyrwg4+tndOTry4uMtF7LeTVLZo9Tnn2x
dJiytWWh+Rq5/KAn1mJ2GgwG8tp8o7SRf65c0LYQenN1d6vXX/Iimq/mg//B5CHP
zIaUrZfoL+2sbRIyQ5AePlDyn8Neg6sIsV9nTkPAcYvvQsS+/8xnfNq6np0zKbua
dQIDAQAB
-----END PUBLIC KEY-----

Installing

This Python script depends on the cryptography and requests Python 2.7 packages

The easiest way to install these dependencies is to use the Nix package manager, which will automatically install these dependencies for you if you run the commands below.

Note: While Nix is the easiest way to install the dependencies for this script, Nix will download several hundred MiB of packages these dependencies. If you don’t want to use the Nix package manager, you can install the dependencies manually using your preferred package manager and then change the interpreter on the first line of included script from ”/usr/bin/env nix-shell” to ”/usr/env/python

Here is how to install this script on your system using the Nix package manager on GNU/Linux or Macintosh OS X systems:

  1. Install the Nix package manager:
    curl https://nixos.org/nix/install | sh
        
  2. Download the get_id_token.sh shell script to your system
    git clone [email protected]:jpf/okta-jwks-to-pem.git
        
  3. Change to the directory containing the get_id_token.sh shell script
    cd okta-jwks-to-pem
        
  4. Run the script:
    ./jwks-to-pem.py --org example.okta.org
        

How it works

The most important part of this code is the conversion of RSA public key modulus and exponent integers into a PEM encoded public key.

Thanks to the excellent Python cryptography library, the process of converting an RSA public key modulus and exponent is three step process:

  1. Convert the JWK modulus and exponent, which are Base64url encoded, into integers:
    exponent = base64_to_long(jwk['e'])
    modulus = base64_to_long(jwk['n'])
        

    Note: This conversion is actually pretty frustrating, the base64_to_long function abstracts this all away.

  2. Use the RSAPublicNumbers class to store the modulus and exponent
    numbers = RSAPublicNumbers(exponent, modulus)
    public_key = numbers.public_key(backend=default_backend())
        
  3. Serialize the RSAPublicNumbers object to PEM
    pem = public_key.public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.SubjectPublicKeyInfo
    )
        

We cover the rest of the script below.

First, we import the libraries that we’ll need:

  • argparse: For handling the --org= command line argument and giving help when it isn’t present.
  • base64, six, struct: Used to properly decode the Base64url encoded modulus and exponent.
  • cryptography: For conversion of the modulus and exponent to PEM
  • requests: To fetch the JWKS URI

Here is how we import the dependencies listed above:

import argparse
import base64
import six
import struct

from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
import requests

Next, we set up ArgumentParser to handle the --org command line argument. The required=true option will cause ArgumentParser to give help text if the --org argument isn’t present.

arg_parser = argparse.ArgumentParser(
    description='JWK to PEM conversion tool')
arg_parser.add_argument('--org',
                        dest='org',
                        help='Domain for Okta org',
                        required=True)
args = arg_parser.parse_args()

Next up is the the code that handles the ugly job of decoding and properly padding the base64url encoded strings that are used in a JWK.

This is easily the most frustrating part of dealing with a JWK. Particularly annoying is the fact that the keys are not Base64 encoded, the are Base64url encoded. Which means that we need to take special precautions for padding and decoding. Thankfully, I was able to find some code that already does this, written by the prolific and talented Roland Hedberg. The functions below come from: https://github.com/rohe/pyjwkest/blob/master/src/jwkest/__init__.py

def intarr2long(arr):
    return int(''.join(["%02x" % byte for byte in arr]), 16)


def base64_to_long(data):
    if isinstance(data, six.text_type):
        data = data.encode("ascii")

    # urlsafe_b64decode will happily convert b64encoded data
    _d = base64.urlsafe_b64decode(bytes(data) + b'==')
    return intarr2long(struct.unpack('%sB' % len(_d), _d))

Here we fetch and decode the JSON from an Okta jwks_uri endpoint:

print("Fetching JWKS from {}".format(args.org))
r = requests.get("https://{}/oauth2/v1/keys".format(args.org))
jwks = r.json()

Finally, we process each key, and print out the PEM encoded key for each JWK Key ID (kid) that we find:

for jwk in jwks['keys']:
    <<convert-base64url-to-long>>
    <<create-rsapublicnumbers-object>>
    <<serialize-rsapublicnumbers-to-pem>>

    print "PEM for KID '{}'".format(jwk['kid'])
    print pem

Requirements

(I’m getting an error when I try to run this in a more modern version of org-babel, I don’t know how to fix it, so I just exported the NOTICE section by hand)

nameequalityversiondescriptionurllicenselicense_url
requests>=2.11.1HTTP Requests for Humanshttps://cryptography.io/Apache 2.0 or BSDhttps://github.com/pyca/cryptography/blob/master/LICENSE
cryptography>=1.5.2Exposes cryptographic recipes and primitiveshttp://docs.python-requests.org/en/latest/Apache 2.0https://github.com/kennethreitz/requests/blob/master/LICENSE

How to turn the table above into a requirements.txt file

To turn the table above into a requirements.txt file, we need to do the following:

  1. Convert the table into an array of dictionaries.
  2. Iterate through the array of dictionaries and construct output in the requirements.txt format, the output must be annotated so that it is written to requirements.txt when M-x org-bable-tangle is run on this file.

Converting the table to an array of dictionaries

The code below is taken verbatim from a StackOverflow answer by John La Rooy. This code takes the table above as an array of arrays and returns an array of dictionaries, where the key is the column name and the value is the column content. This array of dictionaries is used below to generate the requirements.txt file.

headers = table[0]
values = table[1:]

from functools import partial
from itertools import izip, imap
data = map(dict, imap(partial(izip, headers), values))

return data

#+RESULTS[7f787f8512ce1f4160f632055852f2b43fb5dbed]: table-to-dict

[{'equality': '>=', 'name': 'requests', 'license': 'Apache 2.0 or BSD', 'url': 'https://cryptography.io/', 'version': '2.11.1', 'license_url': 'https://github.com/pyca/cryptography/blob/master/LICENSE', 'description': 'HTTP Requests for Humans'}, {'equality': '>=', 'name': 'cryptography', 'license': 'Apache 2.0', 'url': 'http://docs.python-requests.org/en/latest/', 'version': '1.5.2', 'license_url': 'https://github.com/kennethreitz/requests/blob/master/LICENSE', 'description': 'Exposes cryptographic recipes and primitives'}]

Creating the requirements.txt file

This code takes the requirements table above and turns it into a requirements.txt file that will get exported when M-x org-bable-tangle is run.

   data = <<table-to-dict(requirements-table)>>
   output = ''
   for package in data:
	if not package['name']:
	    continue
	output += "{name}{equality}{version}\n".format(**package)
   return output

#+RESULTS[c159eb05f7c3ed9feb34f214884ca276a9cc10aa]:

Dependencies

This script depends on the command line tools listed below. These requirements should be automatically included via the nix-shell directives in the script, but are listed below for the sake of completeness.

#+RESULTS[2f1bc451b8a6bfd5e67a98ad8965a7823fac6016]:

License information

Copyright © 2016, Okta, Inc.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

  http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

To do

Add “dependencies” list

Generate requirements.txt file from dependencies

Add license