ITエンジニアによるITエンジニアのためのブログ

JSON Web SignatureをRuby標準ライブラリで検証する方法

JSON Web Signature (JWS) は、JSONデータに電子署名を付与する技術仕様です。その代表的な使用例として、JWT (JSON Web Token) をセキュアにするための署名や、OpenID ConnectのIDトークンが挙げられます。

JWSの検証には便利なgemが存在しますが、この記事ではその仕組みを深く理解することを目的として、あえてRubyの標準ライブラリのみを使ってJWSを検証する方法を解説します。

目次

  1. JSON Web Signatureの構造
  2. 署名検証の全体像
  3. Ruby標準ライブラリによる実装
  4. (参考) 既存gemを使った署名検証
  5. まとめ

JSON Web Signatureの構造

JWSは、ヘッダーペイロード署名の3つのパートで構成されます。各パートはBase64Urlという形式でエンコードされ、ピリオド (.) で連結されています。

JWSの形式

{ヘッダーのBase64Url}.{ペイロードのBase64Url}.{署名のBase64Url}

以下にJWSの実例を示します。本来は改行なしの1行の文字列です。

eyJ0eXAiOiJKV1QiLCJraWQiOiJoTVlDRllxcmx5VXpNTUtyeTNURU9yY25ucmdUUWRYR3pHWU9OVkx5UlpRIiwiYWxnIjoiUlMyNTYifQ
.
eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJzdWIiOiIxIiwiYXVkIjoiaU4yeko1NkNEclZvbnBRZDRVQWc5WkNCYjY5UVc4dy1zNTNNNTBDbUxLWSIsImV4cCI6MTc1NjM1NzAyOCwiaWF0IjoxNzU2MzU2OTA4LCJub25jZSI6Ijc1NjlkZDg5ZmU5MmZlOTg2MzkzMTA0OTdlYzkzM2Q4In0
.
eL9ZaMKJcFrRZbK95diM1_sed6WNEsl9GJ2qKbRWUgQdC2DfZeUdntqLGlR1nEi9YCdnd69snKa0oa1gHttoUr44-VPdBGWLFFAyIsePUenfLns_ZuSVoB2u-LaYgk0lF7ADO9O9mEgHIehbS9BLsygDSsvbUcyezV2kNaHTJ0h8RS1MoX5EsNgkfrAbao7oGOIV0vCX636cqLi8I0OevOsVO1apmn6lLV4Yiki-clSwXT_SCDJ8FrQ0W6_ja3_Q1YqJHnqhmrpseOxgl07FAuxS-cs_KTNM2cpSJjATE-B5FeS4iUnN0SMpuOGwv0tlOToe26AHocR9lNuin5V7rw

*このJWSは、OpenID Connectの記事で構築したオープンIDプロバイダーが発行したIDトークンです。

ヘッダー (Header)

ヘッダーには、署名アルゴリズムなど、このJWS自体に関する情報がJSON形式で格納されています。上記のJWSのヘッダーをデコードすると以下のようになります。

{
  "typ"=>"JWT",    
  "kid"=>"hMYCFYqrlyUzMMKry3TEOrcnnrgTQdXGzGYONVLyRZQ",
  "alg"=>"RS256"
}
  • typ: トークンのメディアタイプです。
  • kid: 署名に使われた鍵を特定するためのID (Key ID) です。
  • alg: 署名アルゴリズムです。RS256RSASSA-PKCS1-v1_5署名とSHA-256ハッシュ関数の組み合わせを意味します。検証時にはこの値を見て、使用するアルゴリズムを決定します。

ペイロード (Payload)

ペイロードには、伝達したい実際のデータ(クレーム)がJSON形式で格納されています。

{
  "iss": "https://engineerjutsu.com:3000",
  "sub": "1",
  "aud": "iN2zJ56CDrVonpQd4UAg9ZCBb69QW8w-s53M50CmLKY",
  "exp": 1756357028,
  "iat": 1756356908,
  "nonce": "7569dd89fe92fe98639310497ec933d8"
}
  • iss (Issuer): 発行者の識別子です。
  • sub (Subject): ユーザーIDなど、このトークンの主題です。
  • aud (Audience): このトークンの意図された受信者です。
  • exp (Expiration Time): トークンの有効期限(Unixタイムスタンプ)です。
  • iat (Issued At): トークンの発行日時(Unixタイムスタンプ)です。
  • nonce: リプレイ攻撃を防ぐための使い捨ての文字列です。

署名検証の計算自体には、Base64Urlエンコードされたペイロード文字列がそのまま使われます。しかし、トークンが本当に有効かを確認するには、署名検証に加えて、このペイロードをデコードしてexpなどのクレームを評価する必要があります。

署名 (Signature)

署名パートは、{ヘッダー}.{ペイロード}という文字列を、ヘッダーで指定されたアルゴリズム(例: RS256)と秘密鍵を使って計算した電子署名です。この署名を検証することで、データが改ざんされておらず、信頼できる発行者から送られたものであることを確認できます。

署名はBase64Urlデコードするとバイナリデータになり、検証計算に使用されます。

署名検証の全体像

署名検証は、以下のステップで進められます。

  1. JWSの分割: JWS文字列をピリオド (.) で3つのパート(ヘッダー、ペイロード、署名)に分割します。
  2. 署名対象データの準備: 1番目のパート(ヘッダー)と2番目のパート(ペイロード)をピリオドで連結し、署名検証の元データ (data_to_verify) を作成します。
  3. 署名のデコード: 3番目のパート(署名)をBase64Urlでデコードし、バイナリデータに戻します。
  4. 公開鍵の取得: 署名に使われた秘密鍵とペアになる公開鍵を取得します。通常、発行者のJWK Setエンドポイント (/.well-known/jwks.json) から入手し、検証ライブラリで扱える形式に変換します。
  5. アルゴリズムの確認: デコードしたヘッダーのalgパラメータを確認し、検証に使うべき署名アルゴリズムを特定します。
  6. 検証の実行: 準備した署名対象データデコード済み署名公開鍵、そしてアルゴリズムを使い、署名が正当であるかを検証します。

Ruby標準ライブラリでの実装

上記の流れをRubyの標準ライブラリだけで実装すると、以下のようになります。

require 'base64'
require 'json'
require 'openssl'

# 検証対象のJWS
jws = "eyJ0eXAiOiJKV1QiLCJraWQiOiJoTVlDRllxcmx5VXpNTUtyeTNURU9yY25ucmdUUWRYR3pHWU9OVkx5UlpRIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJzdWIiOiIxIiwiYXVkIjoiaU4yeko1NkNEclZvbnBRZDRVQWc5WkNCYjY5UVc4dy1zNTNNNTBDbUxLWSIsImV4cCI6MTc1NjM1NzAyOCwiaWF0IjoxNzU2MzU2OTA4LCJub25jZSI6Ijc1NjlkZDg5ZmU5MmZlOTg2MzkzMTA0OTdlYzkzM2Q4In0.eL9ZaMKJcFrRZbK95diM1_sed6WNEsl9GJ2qKbRWUgQdC2DfZeUdntqLGlR1nEi9YCdnd69snKa0oa1gHttoUr44-VPdBGWLFFAyIsePUenfLns_ZuSVoB2u-LaYgk0lF7ADO9O9mEgHIehbS9BLsygDSsvbUcyezV2kNaHTJ0h8RS1MoX5EsNgkfrAbao7oGOIV0vCX636cqLi8I0OevOsVO1apmn6lLV4Yiki-clSwXT_SCDJ8FrQ0W6_ja3_Q1YqJHnqhmrpseOxgl07FAuxS-cs_KTNM2cpSJjATE-B5FeS4iUnN0SMpuOGwv0tlOToe26AHocR9lNuin5V7rw"

# 1. JWSを3つのパートに分割
header_base64, payload_base64, signature_base64 = jws.split('.')

# 2. 署名対象のデータを作成
data_to_verify = "#{header_base64}.#{payload_base64}"

# 3. 署名をBase64Urlデコードしてバイナリに戻す
signature = Base64.urlsafe_decode64(signature_base64)

# 4. 公開鍵オブジェクトを作成
# 発行者のJWK Setエンドポイント (例: https://[issuer]/.well-known/jwks.json) から
# 取得した公開鍵。
jwks = JSON.parse '{"keys":[{"kty":"RSA","n":"s5QKPGABtgO0tpbDJfRyWVvU2Q8etnOWP-lWrf0KGk8MWtuEMKWUT9Pvd7NaWTK3tWMYjZ-rz6O1xryLfIKwZpepXY1LQj-ZhEvbM7KZx-S4wYizLV4mAswGYpsaFKfRuPdtwy5bFO9YbXzlXeLg3DQav36MrL03aE082jzzFfi7Zks8cu_gmPwm_QZJDMQw9RfTHu7HaYDK_NcFCnBWbzhwCDbviwwDIZtDrNbP_g8M0OUeL8ng_qPehkySlLguJP_0De98l4xKBY_tugR7QV25u1bQ-7lx2jBM740j6KjDxL7YpxIjlMkHtUFlCtewE1ExegRORHH9iAaY8zWMnw","e":"AQAB","kid":"hMYCFYqrlyUzMMKry3TEOrcnnrgTQdXGzGYONVLyRZQ","use":"sig","alg":"RS256"}]}'

jwk = jwks['keys'][0]

n_int = OpenSSL::BN.new(Base64.urlsafe_decode64(jwk['n']), 2)
e_int = OpenSSL::BN.new(Base64.urlsafe_decode64(jwk['e']), 2)
public_key = OpenSSL::PKey::RSA.new
public_key.set_key(n_int, e_int, nil)

# 5. ヘッダーをデコードし、署名アルゴリズムを特定
header = JSON.parse(Base64.urlsafe_decode64(header_base64))
algorithm = case header['alg']
            when 'RS256'
              OpenSSL::Digest::SHA256.new
            when 'RS384'
              OpenSSL::Digest::SHA384.new
            when 'RS512'
              OpenSSL::Digest::SHA512.new
            else
              raise "Unsupported algorithm: #{header['alg']}"
            end

# 6. 署名を検証する。検証に成功するとtrueが返る。
is_valid = public_key.verify(algorithm, signature, data_to_verify)

puts is_valid ? "署名は正当です。" : "署名が無効です。"

このように標準ライブラリの機能を組み合わせることで、JWSの署名検証を実装できます。これにより、JWSの内部構造と検証ロジックを明確に理解できたかと思います。

(参考) 既存gemを使った署名検証

仕組みの理解は重要ですが、実際のアプリケーションでは、堅牢でセキュリティが考慮されたjwtのようなgemを利用するのが一般的です。

# Gemfile
# gem 'jwt'

require 'jwt'
require 'openssl'

jws = "eyJ0eXAiOiJKV1QiLCJraWQiOiJoTVlDRllxcmx5VXpNTUtyeTNURU9yY25ucmdUUWRYR3pHWU9OVkx5UlpRIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJzdWIiOiIxIiwiYXVkIjoiaU4yeko1NkNEclZvbnBRZDRVQWc5WkNCYjY5UVc4dy1zNTNNNTBDbUxLWSIsImV4cCI6MTc1NjM1NzAyOCwiaWF0IjoxNzU2MzUzOTA4LCJub25jZSI6Ijc1NjlkZDg5ZmU5MmZlOTg2MzkzMTA0OTdlYzkzM2Q4In0.eL9ZaMKJcFrRZbK95diM1_sed6WNEsl9GJ2qKbRWUgQdC2DfZeUdntqLGlR1nEi9YCdnd69snKa0oa1gHttoUr44-VPdBGWLFFAyIsePUenfLns_ZuSVoB2u-LaYgk0lF7ADO9O9mEgHIehbS9BLsygDSsvbUcyezV2kNaHTJ0h8RS1MoX5EsNgkfrAbao7oGOIV0vCX636cqLi8I0OevOsVO1apmn6lLV4Yiki-clSwXT_SCDJ8FrQ0W6_ja3_Q1YqJHnqhmrpseOxgl07FAuxS-cs_KTNM2cpSJjATE-B5FeS4iUnN0SMpuOGwv0tlOToe26AHocR9lNuin5V7rw"

jwks = JSON.parse '{"keys":[{"kty":"RSA","n":"s5QKPGABtgO0tpbDJfRyWVvU2Q8etnOWP-lWrf0KGk8MWtuEMKWUT9Pvd7NaWTK3tWMYjZ-rz6O1xryLfIKwZpepXY1LQj-ZhEvbM7KZx-S4wYizLV4mAswGYpsaFKfRuPdtwy5bFO9YbXzlXeLg3DQav36MrL03aE082jzzFfi7Zks8cu_gmPwm_QZJDMQw9RfTHu7HaYDK_NcFCnBWbzhwCDbviwwDIZtDrNbP_g8M0OUeL8ng_qPehkySlLguJP_0De98l4xKBY_tugR7QV25u1bQ-7lx2jBM740j6KjDxL7YpxIjlMkHtUFlCtewE1ExegRORHH9iAaY8zWMnw","e":"AQAB","kid":"hMYCFYqrlyUzMMKry3TEOrcnnrgTQdXGzGYONVLyRZQ","use":"sig","alg":"RS256"}]}'

begin
  # JWT.decodeで署名を検証します。
  # `algorithms`オプションで検証を許可するアルゴリズムを明示的に指定するのがセキュアな方法です。
  # トークンのヘッダー(`alg`)がこのリストに含まれていない場合、検証は失敗します。
  payload, header = JWT.decode(jws, nil, true, { algorithms: ['RS256'], jwks: jwks })
  
  puts "署名は正当です。"
  p payload
rescue JWT::DecodeError => e
  puts "署名が無効、またはトークンが不正です: #{e.message}"
end

JWT.decodeは署名検証だけでなく、有効期限(exp)などの標準的なクレームの検証も自動で行い、問題があれば例外を発生させます。

まとめ

JWSの署名検証は、jwtのようなgemを使えば簡単に実装できますし、プロダクション環境ではその利用が強く推奨されます。

しかし、その内部で何が行われているかを理解しているかどうかで、エンジニアとしての応用力や、問題発生時のトラブルシューティング能力に大きな差が生まれます。

ぜひこの記事を参考に、一度ご自身の手で標準ライブラリを使った実装を試し、JWSの構造と検証の流れを体感してみてください。

参考URL