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

JSON Web EncryptionをRuby標準ライブラリで暗号化・復号化する方法

JSON Web Encryption (JWE) は、暗号化されたコンテンツをJSONベースのデータ構造で表現するための仕様です。代表的な使用例として、内容の秘匿性が求められるJSON Web Token (JWT) の暗号化が挙げられます。

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

目次

  • JWEの利点とJWSとの違い
  • JWEの構造
  • JWE暗号化の流れ
  • Ruby標準ライブラリでの暗号化実装
  • JWE複合の流れ
  • Ruby標準ライブラリでの複合実装
  • (参考) 既存gemを使った暗号化・復号
  • まとめ

JWEの利点とJWSとの違い

JWEの主な利点は、コンテンツを暗号化することで第三者による盗聴を防げることです。

似た仕様にJSON Web Signature (JWS) があります。JWSがコンテンツの完全性(改ざんされていないこと)と送信者の認証(誰が作成したか)を提供するのに対し、JWEは秘匿性(中身が覗かれないこと)と完全性を提供します。

JWSJWE
完全性
送信者の認証x
秘匿性x

JWEの構造

JWEは、Base64URLエンコードされた5つの要素をピリオド (.) で連結した構造になっています。

      BASE64URL(UTF8(JWE Protected Header)) || '.' ||
BASE64URL(JWE Encrypted Key) || '.' ||
BASE64URL(JWE Initialization Vector) || '.' ||
BASE64URL(JWE Ciphertext) || '.' ||
BASE64URL(JWE Authentication Tag)

以下にJWEの実例を示します(読みやすさのために改行を入れています)。

eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkEyNTZHQ00ifQ
.
gs6gvJQtGCQRQ4h_ZO7I0bTfbIOjBUSUXS1ruuyHXynAyOoy-3oW2cLYFRUC2NC8I7HFp42Ob_XoBFw5nE1yWbHDhV3xQSZ4D19PePpoOsvUq41ZuY22INUTl0KdeAqeLmhIbXCgN-VEF3rodT7rt-6yJNyy8jx4BZhWlToLovSPwj5aJxUnvcalXxvBioV9qwvH2d63JjMNL2NgFqRpIPtqRh83vrLlg29Ht_tivOgKqpRVAkX22YKXRsuCVh2USJW25V-uE2t4Nhq3TmMXUzjOd7kKYjBakXERsDfIJFliy5pJrSF3gOYhqNgF__EhqP2m0H7WqDCWDSwVjWmNqQ
.
ix7zPeocenlMFz2p
.
u8tWsRON6S4eszodGMnvBo6lQSGufY-yXxhby3s3ZQaPVgw5KKWiwMZRHtBi1k60fA
.
BeBaR2OzsVwQn5aZ8CPTtg

1. JWE Protected Header

JWEプロテクテッド・ヘッダーは、暗号化アルゴリズムに関する情報をJSON形式で保持します。

{
  alg: 'RSA-OAEP',
  enc: 'A256GCM'
}
  • alg: 鍵を暗号化するためのアルゴリズム(Key Encryption Algorithm)。
  • enc: 平文を暗号化するためのアルゴリズム(Content Encryption Algorithm)。

JWEでは、平文と、平文を暗号化するための鍵(共通鍵)の2段階で暗号化を行います。 平文の暗号化 (enc) にはAESなどの共通鍵暗号が使われ、その共通鍵自体の暗号化 (alg) にはRSAなどの公開鍵暗号、または別の共通鍵暗号が使われます。

JWEの二段構えの暗号化

  1. まず、平文を暗号化するための使い捨ての共通鍵 (CEK) を生成します。
  2. 次に、そのCEKencアルゴリズム (A256GCMなど) で使って平文を暗号化します。
  3. 最後に、CEK自体をalgアルゴリズム (RSA-OAEPなど) で、受信者の公開鍵を使って暗号化します。

このように、計算コストの高い公開鍵暗号をデータ本体(平文)ではなく、軽量な共通鍵の受け渡しにのみ使うことで、効率と安全性を両立しています。

2. JWE Encrypted Key

コンテンツ暗号化鍵 (Content Encryption Key, CEK) を、ヘッダーのalgパラメータで指定されたアルゴリズムで暗号化したものです。

3. JWE Initialization Vector

平文をencアルゴリズムで暗号化する際に使用する、一度きりのランダムな値(初期化ベクトル)です。

4. JWE Ciphertext

平文をCEKと初期化ベクトルを使って暗号化した暗号文です。平文には、JWTのクレームセットや、JWSトークン全体を指定することも可能です。

5. JWE Authentication Tag

認証タグは、encで指定したアルゴリズム(A256GCMなど)が認証付き暗号 (AEAD) である場合に出力されます。このタグは、復号時にデータが改ざんされていないかを検証するために不可欠です。

JWE暗号化の流れ

  1. ヘッダーの定義: 平文の暗号化アルゴリズム (enc) と、鍵の暗号化アルゴリズム (alg) を決定し、Protected Headerを作成します。
  2. CEKの生成: 平文の暗号化に使う共通鍵 (Content Encryption Key, CEK) をランダムに生成します。
  3. CEKの暗号化: 受信者の公開鍵を使い、algアルゴリズムでCEKを暗号化します。
  4. 初期化ベクトルの生成: 平文の暗号化に使う初期化ベクトル (IV) をランダムに生成します。
  5. 平文の暗号化: CEKとIVを使い、encアルゴリズムで平文を暗号化します。この際、エンコード済みのProtected Headerを追加認証データ (AAD) として入力します。これにより、ヘッダーの改ざんも検知できるようになります。出力として暗号文認証タグが得られます。
  6. JWEの生成: 5つの要素(ヘッダー、暗号化済みCEK、IV、暗号文、認証タグ)をそれぞれBase64URLエンコードし、ピリオドで連結してJWEトークンを完成させます。

Ruby標準ライブラリでの暗号化実装

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

# パディングなしBase64urlエンコーディングのヘルパー
def base64url_encode(data)
  Base64.urlsafe_encode64(data, padding: false)
end

# Base64urlデコーディングのヘルパー
def base64url_decode(str)
  Base64.urlsafe_decode64(str)
end

# 1. 設定: 鍵生成と平分の定義
# 実際のアプリケーションではJWE受信者の公開鍵を使用しますが、ここでは例として2048ビットのRSA鍵ペアを生成します。
puts "ステップ1: 鍵と平分の設定"
recipient_key_pair = OpenSSL::PKey::RSA.new(2048)
recipient_public_key = recipient_key_pair.public_key

# JWTのClaim Setを平文として使用。
jwt_claim_set = {
  "iss": "https://engineerjutsu.com:3000",
  "sub": "1",
  "aud": "iN2zJ56CDrVonpQd4UAg9ZCBb69QW8w-s53M50CmLKY",
  "exp": 1756357028,
  "iat": 1756356908,
  "nonce": "7569dd89fe92fe98639310497ec933d8"
}

plaintext = JSON.generate(jwt_claim_set)
puts "  - 平分 '#{plaintext}'"
puts

# 2. JWE Protected Headerを定義
# Protected Headerは暗号化に使われるアルゴリズムを定義します。Base64urlでエンコードされ、JWEの最初の部分になります。
puts "ステップ2: JWE Protected Headerを定義しエンコード。"
protected_header = {
  alg: 'RSA-OAEP', # 共通鍵の暗号化アルゴリズム
  enc: 'A256GCM'   # 平分の暗号化アルゴリズム
}
encoded_protected_header = base64url_encode(protected_header.to_json)
puts "  - Protected Header: #{protected_header.to_json}"
puts "  - エンコードされたヘッダー: #{encoded_protected_header}"
puts

# 3. Content Encryption Key (CEK)の生成
# 平分を暗号化するためのランダムな共通鍵
# A256GCMは256ビット(32バイト)の鍵が必要。
puts "ステップ3: Content Encryption Key (CEK)生成中"
content_encryption_key = OpenSSL::Random.random_bytes(32)
puts "  - CEK生成完了 (#{content_encryption_key.bytesize}バイト)"
puts

# 4. 受信者の公開鍵でCEKを暗号化
# これがJWEの2番目の構成要素であるJWE Encrypted Keyになります。
puts "ステップ4: CEKをRSA-OAEPで暗号化"
encrypted_key = recipient_public_key.public_encrypt(
  content_encryption_key,
  OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING
)
encoded_encrypted_key = base64url_encode(encrypted_key)
puts "  - 暗号化された鍵をエンコード完了"
puts


# 5. 平分の暗号化
# CEKをAES-256-GCMで使用します(GCMモードは秘匿性と認証性を同時に提供します)。
puts "ステップ5: 平分をAES-256-GCMで暗号化"
# A256GCMは96ビット(12バイト)の初期化ベクトル(IV)が必要
iv = OpenSSL::Random.random_bytes(12)
encoded_iv = base64url_encode(iv)
puts "  - 12バイトの初期化ベクトル生成"

# JWEでは"Additional Authenticated Data" (AAD)は
# Base64urlエンコードされたprotected headerです。ヘッダーを暗号文と紐付けることで、ヘッダーの改ざんを防ぎます。
aad = encoded_protected_header

cipher = OpenSSL::Cipher.new('aes-256-gcm')
cipher.encrypt
cipher.key = content_encryption_key
cipher.iv = iv
cipher.auth_data = aad

# 平文を暗号化
ciphertext = cipher.update(plaintext) + cipher.final
encoded_ciphertext = base64url_encode(ciphertext)
puts "  - 平文を暗号化完了"

# 認証タグ (GCMモードの重要な要素)を取得
auth_tag = cipher.auth_tag
encoded_auth_tag = base64url_encode(auth_tag)
puts "  - 認証タグを生成"
puts

# 6. JWEを作成
# 最終的なJWEはピリオドで連結された5つの要素で構成されます。
puts "ステップ6: 最終的なJWEトークンを生成"
jwe_token = [
  encoded_protected_header,
  encoded_encrypted_key,
  encoded_iv,
  encoded_ciphertext,
  encoded_auth_tag
].join('.')

puts "\n✅ 最終的なJWEトークン:\n#{jwe_token}\n"

JWE複合の流れ

  1. 分割とデコード: JWEトークンをピリオドで5つの要素に分割し、それぞれをBase64URLデコードします。
  2. CEKの復号: 受信者の秘密鍵を使い、暗号化されたCEKを復号します。
  3. 平文の復号: 復号したCEK、初期化ベクトル、認証タグ、そしてエンコード済みのProtected Header(AADとして使用)を使って、暗号文を復号し、元の平文を取り出します。認証タグが一致しない場合、処理は失敗し、データが改ざんされた可能性があることを示します。

Ruby標準ライブラリでの複合化実装

# (暗号化のコードからの続き)

# 1. JWEトークンを分解
puts "\n--- 復号化開始 ---"
puts "ステップ1: JWEトークンをパース"
header_part, enc_key_part, iv_part, ciphertext_part, tag_part = jwe_token.split('.')
puts "  - トークンを5つの要素に分解完了"
puts

# 2. Content Encryption Key (CEK)を複合
# JWE受信者の秘密鍵で暗号化されたCEKを複合。
puts "ステップ2: CEKを秘密鍵で複合"
encrypted_key_decoded = base64url_decode(enc_key_part)
decrypted_cek = recipient_key_pair.private_decrypt(
  encrypted_key_decoded,
  OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING
)
puts "  - CEKの複合完了"
puts


# 3. 暗号文を複合
# 複合されたCEKを使って暗号文を複合します。
# 暗号化の際と全く同じ初期化ベクトル、AAD、 認証タグを使う必要があります。
puts "ステップ3: 暗号文を複合"
iv_decoded = base64url_decode(iv_part)
ciphertext_decoded = base64url_decode(ciphertext_part)
auth_tag_decoded = base64url_decode(tag_part)
aad_for_decryption = header_part # AADは元のエンコード済みヘッダー

decipher = OpenSSL::Cipher.new('aes-256-gcm')
decipher.decrypt
decipher.key = decrypted_cek
decipher.iv = iv_decoded
decipher.auth_tag = auth_tag_decoded # 認証タグを設定
decipher.auth_data = aad_for_decryption

# データを複合。もしauth_tagが不正な場合、 OpenSSLは改ざん防止のために
# OpenSSL::Cipher::CipherError例外を発生させます。
begin
  decrypted_plaintext = decipher.update(ciphertext_decoded) + decipher.final
  puts "\n✅ 複合成功!"
  puts "平文: 「#{decrypted_plaintext.force_encoding('UTF-8')}」"
rescue OpenSSL::Cipher::CipherError
  puts "\n❌ 複合失敗!認証タグが不正です。データが改ざんされた可能性があります。"
end

(参考) 既存gemを使った暗号化・復号化

仕組みの理解は重要ですが、実際のアプリケーションでは、堅牢でセキュリティが考慮されたjson-jwtのようなgemを利用するのが一般的です。(もう一つの有名なjwt gemは、本記事執筆時点ではJWEをサポートしていません。)

require 'json/jwt'
require 'openssl'

# --- JWE暗号化 ---

# 1. 鍵と平分の定義
puts "--- gemを使った暗号化 ---"
recipient_private_key = OpenSSL::PKey::RSA.new(2048)
recipient_public_key = recipient_private_key.public_key

puts 'JWTオブジェクト作成'
jwt_claim_set = {
  "iss": "https://engineerjutsu.com:3000",
  "sub": "1",
  "exp": 1756357028,
  "iat": 1756356908,
}
puts "元のペイロード:\n#{jwt_claim_set.to_json}"

# 2. JWTオブジェクトを作成し、暗号化
jwt = JSON::JWT.new(jwt_claim_set)
jwe = jwt.encrypt(recipient_public_key, :'RSA-OAEP', :'A256GCM')
jwe_token = jwe.to_s
puts "\n✅JWE暗号化成功!"
puts "JWEトークン: #{jwe_token}"

# --- JWE復号 ---
puts "\n--- gemを使った復号 ---"


puts '複合開始'
jwe = JSON::JWT.decode jwe_token, recipient_private_key, 'RSA-OAEP', 'A256GCM'
puts "\n✅複合成功!"

# 2. 複合された平文をJWTオブジェクトに変換
jwt = JSON::JWT.decode jwe.plain_text

# 3. 元のペイロードを表示
puts "元のペイロード #{jwt.to_json}"

まとめ

JWEの暗号化と復号は、既存のgemを使えば簡単かつ安全に実装できます。

しかし、その内部でヘッダー、CEK、IV、認証タグがどのように機能し、なぜ二段構えの暗号化が行われるのかを理解しているかどうかで、エンジニアとしての応用力や、問題発生時のトラブルシューティング能力に大きな差が生まれます。

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

参考URL