/ adafruit_jwt.py
adafruit_jwt.py
  1  # The MIT License (MIT)
  2  #
  3  # Copyright (c) 2019 Brent Rubell for Adafruit Industries
  4  #
  5  # Permission is hereby granted, free of charge, to any person obtaining a copy
  6  # of this software and associated documentation files (the "Software"), to deal
  7  # in the Software without restriction, including without limitation the rights
  8  # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  9  # copies of the Software, and to permit persons to whom the Software is
 10  # furnished to do so, subject to the following conditions:
 11  #
 12  # The above copyright notice and this permission notice shall be included in
 13  # all copies or substantial portions of the Software.
 14  #
 15  # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 16  # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 17  # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 18  # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 19  # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 20  # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 21  # THE SOFTWARE.
 22  """
 23  `adafruit_jwt`
 24  ================================================================================
 25  
 26  JSON Web Token Authentication
 27  
 28  * Author(s): Brent Rubell
 29  
 30  Implementation Notes
 31  --------------------
 32  
 33  **Hardware:**
 34  
 35  **Software and Dependencies:**
 36  
 37  * Adafruit CircuitPython firmware for the supported boards:
 38    https://github.com/adafruit/circuitpython/releases
 39  
 40  * Adafruit's RSA library:
 41    https://github.com/adafruit/Adafruit_CircuitPython_RSA
 42  
 43  * Adafruit's binascii library:
 44    https://github.com/adafruit/Adafruit_CircuitPython_RSA
 45  
 46  """
 47  import io
 48  import json
 49  from adafruit_rsa import PrivateKey, sign
 50  
 51  from adafruit_binascii import b2a_base64, a2b_base64
 52  
 53  
 54  __version__ = "0.0.0-auto.0"
 55  __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_JWT.git"
 56  
 57  # pylint: disable=no-member
 58  class JWT:
 59      """JSON Web Token helper for CircuitPython. Warning: JWTs are
 60      credentials, which can grant access to resources. Be careful
 61      where you paste them!
 62      :param str algo: Encryption algorithm used for claims. Can be None.
 63  
 64      """
 65  
 66      @staticmethod
 67      def validate(jwt):
 68          """Validates a provided JWT. Does not support validating
 69          nested signing. Returns JOSE Header and claim set.
 70          :param str jwt: JSON Web Token.
 71          :returns: The message's decoded JOSE header and claims.
 72          :rtype: tuple
 73          """
 74          # Verify JWT contains at least one period ('.')
 75          if jwt.find(".") == -1:
 76              raise ValueError("ProvidedJWT must have at least one period")
 77          # Attempt to decode JOSE header
 78          try:
 79              jose_header = STRING_TOOLS.urlsafe_b64decode(jwt.split(".")[0])
 80          except UnicodeError:
 81              raise UnicodeError("Unable to decode JOSE header.")
 82          # Check for typ and alg in decoded JOSE header
 83          if "typ" not in jose_header:
 84              raise TypeError("JOSE Header does not contain required type key.")
 85          if "alg" not in jose_header:
 86              raise TypeError("Jose Header does not contain an alg key.")
 87          # Attempt to decode claim set
 88          try:
 89              claims = json.loads(STRING_TOOLS.urlsafe_b64decode(jwt.split(".")[1]))
 90          except UnicodeError:
 91              raise UnicodeError("Invalid claims encoding.")
 92          if not hasattr(claims, "keys"):
 93              raise TypeError("Provided claims is not a JSON dict. object")
 94          return (jose_header, claims)
 95  
 96      @staticmethod
 97      def generate(claims, private_key_data=None, algo=None):
 98          """Generates and returns a new JSON Web Token.
 99          :param dict claims: JWT claims set
100          :param str private_key_data: Decoded RSA private key data.
101          :rtype: str
102          """
103          # Allow for unencrypted JWTs
104          if algo is not None:
105              priv_key = PrivateKey(*private_key_data)
106          else:
107              algo = "none"
108          # Create the JOSE Header
109          # https://tools.ietf.org/html/rfc7519#section-5
110          jose_header = {"typ": "JWT", "alg": algo}
111          payload = "{}.{}".format(
112              STRING_TOOLS.urlsafe_b64encode(json.dumps(jose_header).encode("utf-8")),
113              STRING_TOOLS.urlsafe_b64encode(json.dumps(claims).encode("utf-8")),
114          )
115          # Compute the signature
116          if algo == "none":
117              jwt = "{}.{}".format(jose_header, claims)
118              return jwt
119          if algo == "RS256":
120              signature = STRING_TOOLS.urlsafe_b64encode(
121                  sign(payload, priv_key, "SHA-256")
122              )
123          elif algo == "RS384":
124              signature = STRING_TOOLS.urlsafe_b64encode(
125                  sign(payload, priv_key, "SHA-384")
126              )
127          elif algo == "RS512":
128              signature = STRING_TOOLS.urlsafe_b64encode(
129                  sign(payload, priv_key, "SHA-512")
130              )
131          else:
132              raise TypeError(
133                  "Adafruit_JWT is currently only compatible with algorithms within"
134                  "the Adafruit_RSA module."
135              )
136          jwt = payload + "." + signature
137          return jwt
138  
139  
140  # pylint: disable=invalid-name
141  class STRING_TOOLS:
142      """Tools and helpers for URL-safe string encoding.
143      """
144  
145      # Some strings for ctype-style character classification
146      whitespace = " \t\n\r\v\f"
147      ascii_lowercase = "abcdefghijklmnopqrstuvwxyz"
148      ascii_uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
149      ascii_letters = ascii_lowercase + ascii_uppercase
150      digits = "0123456789"
151      hexdigits = digits + "abcdef" + "ABCDEF"
152      octdigits = "01234567"
153      punctuation = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"""
154      printable = digits + ascii_letters + punctuation + whitespace
155  
156      @staticmethod
157      def urlsafe_b64encode(payload):
158          """Encode bytes-like object using the URL- and filesystem-safe alphabet,
159          which substitutes - instead of + and _ instead of / in
160          the standard Base64 alphabet, and return the encoded bytes.
161          :param bytes payload: bytes-like object.
162          """
163          return STRING_TOOLS.translate(
164              b2a_base64(payload)[:-1].decode("utf-8"), {ord("+"): "-", ord("/"): "_"}
165          )
166  
167      @staticmethod
168      def urlsafe_b64decode(payload):
169          """Decode bytes-like object or ASCII string using the URL
170          and filesystem-safe alphabet
171          :param bytes payload: bytes-like object or ASCII string
172          """
173          return a2b_base64(STRING_TOOLS._bytes_from_decode_data(payload)).decode("utf-8")
174  
175      @staticmethod
176      def _bytes_from_decode_data(str_data):
177          # Types acceptable as binary data
178          bit_types = (bytes, bytearray)
179          if isinstance(str_data, str):
180              try:
181                  return str_data.encode("ascii")
182              except:
183                  raise ValueError("string argument should contain only ASCII characters")
184          elif isinstance(str_data, bit_types):
185              return str_data
186          else:
187              raise TypeError(
188                  "argument should be bytes or ASCII string, not %s"
189                  % str_data.__class__.__name__
190              )
191  
192      # Port of CPython str.translate to Pure-Python by Johan Brichau, 2019
193      # https://github.com/jbrichau/TrackingPrototype/blob/master/Device/lib/string.py
194      @staticmethod
195      def translate(s, table):
196          """Return a copy of the string in which each character
197          has been mapped through the given translation table.
198          :param string s: String to-be-character-table.
199          :param dict table: Translation table.
200          """
201          sb = io.StringIO()
202          for c in s:
203              v = ord(c)
204              if v in table:
205                  v = table[v]
206                  if isinstance(v, int):
207                      sb.write(chr(v))
208                  elif v is not None:
209                      sb.write(v)
210              else:
211                  sb.write(c)
212          return sb.getvalue()