Rails Webook

Web業界で働いています。Railsを中心にプログラミングや事業開発のようなことを書いていきます。

JWT(Json Web Tpken)のRuby実装のruby-jwtのコードリーディング

f:id:nipe880324:20181118151202p:plain:w420

JWT(Json Web Token)のRuby実装のruby-jwtのコードリーディングをしました。
JWTの仕様やその実装についてみまして。仕様がシンプルなので、本体のコード量は500行程度で読みやすかったです。

目的

  • JWTとは何か
  • JWTのエンコード処理の実装
  • JWTのデコード処理の実装

1. JWTとは

JWTとは、Json Web Tokenの略で、署名付きのトークン化されたJsonです。 署名がされているので中身の改ざんができないことやJSONであることから、APIでの認証の一時的なトークンとして利用されます。

JWTは、ヘッダー(署名アルゴリズム)、ペイロード(やりとりしたい内容)、署名と3つの部分からなり、.で区切られています。
Base64でエンコードされているので、中身をデコードすると確認することができます。

require 'base64'

jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlRvbSBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.lpE3tA695k_sG0wxJMsn77NJjebHF8jjKOJ3TJv_3X8'

jwt.split('.').map do |segment|
  Base64.decode64(segment)
end
#=> ["{\"alg\":\"HS256\",\"typ\":\"JWT\"}",
#    "{\"sub\":\"1234567890\",\"name\":\"Tom Doe\",\"iat\":1516239022}",
#    "\x96\x917\xB4\x0E\xBD\xE6K\x06\xD3\fI2\xC9\xFB\xEC\xD2cy\xB1\xC5\xF28\xCA8\x9D\xD3&\xFD\xD7"]

JWTのオフィシャルページでJWTのエンコード、デコードをいじいじしながら確認できるので、よくわからない場合は触ってみるといいと思います。

次に、JWTの中身の説明をしていきます。

ヘッダー部

署名アルゴリズムが入っています。

# sample
{
  "alg": "HS256",
  "typ": "JWT"
}

項目の説明は次の通りです。

項目名 説明
slg 署名アルゴリズム、HMAC, RSAなど(必須)
typ トークのタイプ。任意だがJWT指定が推奨らしい

ペイロード部

やりとりしたい内容が入っている部分です。

# sample
{
  "name": "Tom Doe",
  "exp": 1516239022
}

やりとりしたい内容以外に、現時点では7つほど予約された項目名があります。すべてOptionalです。

項目名 説明
exp トークンの有効期限の時間 (Expiration Time)
nbf トークンの開始時間 (Not Before Time)
iss トークンの発行者の識別子 (Issuer)
sub トークンのサブジェクト(Subject)
aud トークンの受取人の識別子(Audience)
jti トークンのユニークな識別子。(JWT ID)
iat トークンの発行時刻 (Issued At)

参考: JWT Claimsの仕様

電子署名部

署名に使われています。

sample
lpE3tA695k_sG0wxJMsn77NJjebHF8jjKOJ3TJv

注意点

  • ヘッダ情報の脆弱性がある。ヘッダー部のalgnoneHMAC-SHA*を指定して署名検証を回避する脆弱性があるようです。ホワイトリストでalgの内容を検証する必要があるそう。
  • JWTをBase64でデコードすると中身をみれてしまいます。そのため、JWT内に秘密鍵など他の人にみられると困る情報をJWTにいれるとセキュリティリスクになってしまいます。
  • 一度発行したJWTの無効化は難しい。

2. ruby-jwtの簡単な使い方

まずは、ruby-jwtの使い方をREADMEから確認します。

NONE, HMAC, RSASSA, ECDSA, RSASSA-PSSといったアルゴリズムのどれかで電子署名をできるようです。 ヘッダーの脆弱性があるので、アルゴリズムをハードコードして外から指定できるようにしないことを推奨しているようです。

require 'jwt'

payload = { user_id: 1 }
hmac_secret = 'YOUR-SECRET'

# payloadを、hmac_secret(秘密鍵)でHS256アルゴリズムで署名する
token = JWT.encode(payload, hmac_secret, 'HS256', { typ: 'JWT' })
#=> "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxfQ.K65DepuDQLTEi8RFG4htEHlMhVzMB3SOwo_mHfpoodM"

# tokenを、hmac_secret(秘密鍵)でHS256アルゴリズムで復号化する
decoded_token = JWT.decode(token, hmac_secret, true, { algorithm: 'HS256' })
#=> [{"user_id"=>1}, {"alg"=>"HS256", "typ"=>"JWT"}]

# トークンを手に入れたとして、オレオレ秘密鍵で復号化してみるとエラーが発生します
decoded_token = JWT.decode(token, 'oreore-secret', true, { algorithm: 'HS256' })
#=> JWT::VerificationError: Signature verification raised

3. JWTのエンコード処理の実装

JWTの公開メソッドのJWT.encodeJWT.decodeが定義されています。
それぞれ、JWT::EncodeJWT::Decodeというクラスに処理を委譲しています。 まずは、JWTを作成するエンコード部分を読んでみます。

# lib/jwt.rb

# frozen_string_literal: true

require 'base64'
require 'jwt/decode'
require 'jwt/default_options'
require 'jwt/encode'
require 'jwt/error'

# JSON Web Token implementation
#
# Should be up to date with the latest spec:
# https://tools.ietf.org/html/rfc7519
module JWT
  include JWT::DefaultOptions

  # 引数なしでmodule_functionメソッドを呼び出すと、それ以降はインスタンスメソッドが
  # 定義されるたびに、同名で同じ内容の特異メソッドが自動的に定義される
  module_function

  def encode(payload, key, algorithm = 'HS256', header_fields = {})
    encoder = Encode.new payload, key, algorithm, header_fields
    encoder.segments
  end

  def decode(jwt, key = nil, verify = true, options = {}, &keyfinder)
    decoder = Decode.new(jwt, key, verify, DEFAULT_OPTIONS.merge(options), &keyfinder)
    decoder.decode_segments
  end
end

JWT::Encodeの中身をみていきます。JWTのアルゴリズム通りに丁寧に実装していてとても読みやすいです。
ヘッダーは、ペイロード、電子署名とそれぞれ別々のメソッドで作成し、encode_segmentsメソッドでJWTトークンを.で結合しています。

# lib/jwt/encode.rb

# frozen_string_literal: true

require 'json'

# JWT::Encode module
module JWT
  # Encoding logic for JWT
  class Encode
    attr_reader :payload, :key, :algorithm, :header_fields, :segments

    def self.base64url_encode(str)
      Base64.encode64(str).tr('+/', '-_').gsub(/[\n=]/, '')
    end

    def initialize(payload, key, algorithm, header_fields)
      @payload = payload
      @key = key
      @algorithm = algorithm
      @header_fields = header_fields
      @segments = encode_segments
    end

    private

    # JWTのヘッダー部の作成
    # `alg`は必須。`typ`はオプショナルで、`header_fields`で指定できるようにしている
    def encoded_header
      header = { 'alg' => @algorithm }.merge(@header_fields)
      Encode.base64url_encode(JSON.generate(header))
    end

    # JWTのペイロード部
    # expだけ値が数値がチェックをしているのが気になる。nbf(利用開始可能時刻), iat(発行時刻)なので同じくもチェックした方がよさそう
    def encoded_payload
      raise InvalidPayload, 'exp claim must be an integer' if @payload && @payload.is_a?(Hash) && @payload.key?('exp') && !@payload['exp'].is_a?(Integer)
      Encode.base64url_encode(JSON.generate(@payload))
    end

    # JWTの電子署名部分
    # 署名自身の処理は、`JWT::Signature`に任せている
    def encoded_signature(signing_input)
      if @algorithm == 'none'
        ''
      else
        signature = JWT::Signature.sign(@algorithm, signing_input, @key)
        Encode.base64url_encode(signature)
      end
    end

    # 各部分を`.`で結合
    def encode_segments
      header = encoded_header
      payload = encoded_payload
      signature = encoded_signature([header, payload].join('.'))
      [header, payload, signature].join('.')
    end
  end
end

電子署名を行なっている、JWT::Signatureをみると、アルゴリズムを選択し、実際の署名処理は各アルゴリズムのクラスに委譲しています。
このSignatureクラスがあることでアルゴリズムの追加がしやすくなってい流と思います。

# lib/jwt/encode.rb

# frozen_string_literal: true

require 'jwt/security_utils'
require 'openssl'
require 'jwt/algos/hmac'
require 'jwt/algos/eddsa'
require 'jwt/algos/ecdsa'
require 'jwt/algos/rsa'
require 'jwt/algos/ps'
require 'jwt/algos/unsupported'
begin
  require 'rbnacl'
rescue LoadError
  raise if defined?(RbNaCl)
end

# JWT::Signature module
module JWT
  # Signature logic for JWT
  module Signature
    extend self
    ALGOS = [
      Algos::Hmac,
      Algos::Ecdsa,
      Algos::Rsa,
      Algos::Eddsa,
      Algos::Ps,
      Algos::Unsupported
    ].freeze
    ToSign = Struct.new(:algorithm, :msg, :key)
    ToVerify = Struct.new(:algorithm, :public_key, :signing_input, :signature)

    def sign(algorithm, msg, key)
      algo = ALGOS.find do |alg|
        # SUPPORTEDは、HMACでも実施に利用するアルゴリズム名の配列 %w[HS256 HS512256 HS384 HS512]
        alg.const_get(:SUPPORTED).include? algorithm
      end
      algo.sign ToSign.new(algorithm, msg, key)
    end

    ...

  end
end

HMACの署名の実装をみると、rbnaclのいうRubyの暗号化のgemを利用しています。

# lib/jwt/algos/hmac.rb

module JWT
  module Algos
    module Hmac
      module_function

      SUPPORTED = %w[HS256 HS512256 HS384 HS512].freeze

      def sign(to_sign)
        algorithm, msg, key = to_sign.values
        authenticator, padded_key = SecurityUtils.rbnacl_fixup(algorithm, key)
        if authenticator && padded_key
          authenticator.auth(padded_key, msg.encode('binary'))
        else
          OpenSSL::HMAC.digest(OpenSSL::Digest.new(algorithm.sub('HS', 'sha')), key, msg)
        end
      end

      ...
    end
  end
end

4. JWTのデコード処理の実装

エンコード処理がアルゴリズム通り、ヘッダー、ペイロード、電子署名ごとにBase64でエンコードし、結合していました。
今度は、JWTのデコード部分をみていきます。

改めて、公開メソッドのJWT.decodeをみます。トークンを受け取り、JWT::Decodeクラスに処理を委譲しています。

# lib/jwt.rb

# JSON Web Token implementation
#
# Should be up to date with the latest spec:
# https://tools.ietf.org/html/rfc7519
module JWT
  include JWT::DefaultOptions
  module_function

  ...

  # デコード処理
  # @param jwt       [String]  JWTのトークン
  # @param key       [String]  秘密鍵
  # @param verify    [Boolean] トークンの検証するか否か
  # @param options   [Hash]    オプション(アルゴリズムやペイロードのexpなどの予約項目を検証するか否かなど)
  # @param keyfinder
  def decode(jwt, key = nil, verify = true, options = {}, &keyfinder)
    decoder = Decode.new(jwt, key, verify, DEFAULT_OPTIONS.merge(options), &keyfinder)
    decoder.decode_segments
  end
end

JWT::Decodeクラスをみると、JWT:Encodeクラスと逆のこと(トークンを分割し、それぞれをBase64でデコードし、電子署名の署名検証)が実装されているのがわかります。

# lib/jwt/decode.rb
# frozen_string_literal: true

require 'json'

require 'jwt/signature'
require 'jwt/verify'
# JWT::Decode module
module JWT
  # Decoding logic for JWT
  class Decode
    def self.base64url_decode(str)
      str += '=' * (4 - str.length.modulo(4))
      Base64.decode64(str.tr('-_', '+/'))
    end

    def initialize(jwt, key, verify, options, &keyfinder)
      raise(JWT::DecodeError, 'Nil JSON web token') unless jwt
      @jwt = jwt
      @key = key
      @options = options
      @segments = jwt.split('.')
      @verify = verify
      @signature = ''
      @keyfinder = keyfinder
    end

    def decode_segments
      # ヘッダー、ペイロード、電子署名などのセグメントの数が正しいか確認
      validate_segment_count
      if @verify
        # 電子署名部分をBase64でデコード
        decode_crypto
        # 署名の検証、署名処理自体は`JWT::Signature`に委譲
        verify_signature
        # ペイロードの予約項目(exp(有効期限),nbf(利用開始可能時間)など)の検証
        verify_claims
      end
      # headerとpayload自体はBase64でデコードし、JSON.parseしているだけ
      raise(JWT::DecodeError, 'Not enough or too many segments') unless header && payload
      [payload, header]
    end

    private

    def validate_segment_count
      raise(JWT::DecodeError, 'Not enough or too many segments') unless
        (@verify && segment_length != 3) ||
            (segment_length != 3 || segment_length != 2)
    end

    def decode_crypto
      @signature = Decode.base64url_decode(@segments[2])
    end

    # 電子署名の検証は、電子署名の作成時と同じく、`JWT::Signature`クラスに委譲している
    # `JWT::Signature`では`JWT::Algos::Hmac`などの各アルゴリズムクラスを選択し、実際の処理をしている。
    def verify_signature
      raise(JWT::IncorrectAlgorithm, 'An algorithm must be specified') if allowed_algorithms.empty?
      raise(JWT::IncorrectAlgorithm, 'Expected a different algorithm') unless options_includes_algo_in_header?

      Signature.verify(header['alg'], @key, signing_input, @signature)
    end

    def verify_claims
      Verify.verify_claims(payload, @options)
    end

    ...

    def header
      @header ||= parse_and_decode @segments[0]
    end

    def payload
      @payload ||= parse_and_decode @segments[1]
    end

    def parse_and_decode(segment)
      JSON.parse(Decode.base64url_decode(segment))
    rescue JSON::ParserError
      raise JWT::DecodeError, 'Invalid segment encoding'
    end
  end
end

電子署名の検証は署名処理の逆でほぼ同じなので、ペイロードの検証部分のJWT::Verifyの処理をみます。
各項目ごとに検証用のメソッドがありますが、「有効期限の項目expverify_expirationメソッド」と「開始時刻の項目nbfverify_not_beforeメソッド」をみます。

# lib/jwt/verify.rb

require 'jwt/error'

module JWT
  # JWT verify methods
  class Verify
    class << self
      # ペイロードの予約項目の検証
      # 
      # pyaload: { "name" => "John Doe", "exp": 1516239022 }
      # options: { vefiry_expiration: true, verify_not_before: true, verify_iss: false, ... }
      def verify_claims(payload, options)
        options.each do |key, val|
          next unless key.to_s =~ /verify/
          Verify.send(key, payload, options) if val
        end
      end

      # 各項目の検証メソッドごとにクラスメソッドを定義
      %w[verify_aud verify_expiration verify_iat verify_iss verify_jti verify_not_before verify_sub].each do |method_name|
        define_method method_name do |payload, options|
          new(payload, options).send(method_name)
        end
      end
    end

    def initialize(payload, options)
      @payload = payload
      @options = DEFAULTS.merge(options)
    end

    # expの検証
    # 有効期限を過ぎている場合は`ExpiredSignature`を発生
    # leewayはゆとりという意味で少しだけ期限に猶予をもたせれるようになっています。デフォルト値は0ですが、
    def verify_expiration
      return unless @payload.include?('exp')
      raise(JWT::ExpiredSignature, 'Signature has expired') if @payload['exp'].to_i <= (Time.now.to_i - exp_leeway)
    end

    # nbfの検証
    # 現在時刻を超えている場合は、`ImmatureSignature`を発生
    def verify_not_before
      return unless @payload.include?('nbf')
      raise(JWT::ImmatureSignature, 'Signature nbf has not been reached') if @payload['nbf'].to_i > (Time.now.to_i + nbf_leeway)
    end

    private

    def global_leeway
      @options[:leeway]
    end

    def exp_leeway
      @options[:exp_leeway] || global_leeway
    end

    def nbf_leeway
      @options[:nbf_leeway] || global_leeway
    end
  end
end

おまけで、エラークラスの定義についてみます。
エラークラスの実装場所は色々あると思いますが、lib/jwt/error.rbにエラークラスを一覧で定義しており、JWTの利用時にどのようなエラーがおきるかがわかりやすいです。
※そもそも、主な機能としてエンコードとデコードしかないのでしんぷるなのですが、、

# lib/jwt/error.rb

# frozen_string_literal: true

module JWT
  EncodeError             = Class.new(StandardError)
  DecodeError             = Class.new(StandardError)
  RequiredDependencyError = Class.new(StandardError)

  VerificationError  = Class.new(DecodeError)
  ExpiredSignature   = Class.new(DecodeError)
  IncorrectAlgorithm = Class.new(DecodeError)
  ImmatureSignature  = Class.new(DecodeError)
  InvalidIssuerError = Class.new(DecodeError)
  InvalidIatError    = Class.new(DecodeError)
  InvalidAudError    = Class.new(DecodeError)
  InvalidSubError    = Class.new(DecodeError)
  InvalidJtiError    = Class.new(DecodeError)
  InvalidPayload     = Class.new(DecodeError)
end

まとめ

  • JWTは署名付きのURLセーフなJSONで、改ざんができないこととURLセーフなのでWebの認証トークンとして使われています。
  • JWTはヘッダー、ペイロード、電子署名部分の3つに区切られている。
  • ヘッダーではアルゴリズムを指定できるが、署名の検証をスキップできるので動的にアルゴリズムを指定できるようにすると事故る可能性があるので注意。
  • ペイロードは、Base64で復号化できて中身が見えるので見れれたくない情報は乗せると危ない。expsubなど7項目のキーが定義されており、それぞれ検証することができる。

参考

以上です。