diff --git a/CHANGELOG.md b/CHANGELOG.md index fc8df5c..3e95109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased](https://github.com/jwt/ruby-jwe/tree/HEAD) + +**Features:** + +- Add JSON Serialization support (General and Flattened) per RFC 7516 Section 7.2 + ## [v1.1.1](https://github.com/jwt/ruby-jwe/tree/v1.1.1) (2025-08-07) [Full Changelog](https://github.com/jwt/ruby-jwe/compare/v1.1.0...v1.1.1) diff --git a/lib/jwe.rb b/lib/jwe.rb index be8f36b..70aa459 100644 --- a/lib/jwe.rb +++ b/lib/jwe.rb @@ -7,12 +7,15 @@ require 'jwe/base64' require 'jwe/serialization/compact' +require 'jwe/serialization/json' require 'jwe/alg' require 'jwe/enc' require 'jwe/zip' +require 'jwe/recipient' +require 'jwe/decryption_result' # A ruby implementation of the RFC 7516 JSON Web Encryption (JWE) standard. -module JWE +module JWE # rubocop:disable Metrics/ModuleLength class DecodeError < RuntimeError; end class NotImplementedError < RuntimeError; end class BadCEK < RuntimeError; end @@ -22,7 +25,7 @@ class InvalidData < RuntimeError; end VALID_ENC = %w[A128CBC-HS256 A192CBC-HS384 A256CBC-HS512 A128GCM A192GCM A256GCM].freeze VALID_ZIP = ['DEF'].freeze - class << self + class << self # rubocop:disable Metrics/ClassLength def encrypt(payload, key, alg: 'RSA-OAEP', enc: 'A128GCM', **more_headers) header = generate_header(alg, enc, more_headers) check_params(header, key) @@ -97,5 +100,183 @@ def generate_header(alg, enc, more) def generate_serialization(hdr, cek, content, cipher) Serialization::Compact.encode(hdr, cek, cipher.iv, content, cipher.tag) end + + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists + def encrypt_json(payload, recipients, protected_header: {}, unprotected_header: nil, aad: nil, format: :general) + raise ArgumentError, 'At least one recipient is required' if recipients.empty? + raise ArgumentError, 'Flattened serialization supports only one recipient' if format == :flattened && recipients.length > 1 + + enc = protected_header[:enc] || protected_header['enc'] + raise ArgumentError, 'enc is required in protected_header' unless enc + + check_enc(enc) + + cipher = Enc.for(enc) + cek = cipher.cek + + recipient_data = recipients.map do |recipient| + jose_header = build_jose_header(protected_header, unprotected_header, recipient.header) + alg = jose_header['alg'] || jose_header[:alg] + raise ArgumentError, 'alg is required for each recipient' unless alg + + check_alg(alg) + check_key(recipient.key) + + encrypted_key = Alg.encrypt_cek(alg, recipient.key, cek) + { header: recipient.header, encrypted_key: encrypted_key } + end + + payload = apply_zip(protected_header, payload, :compress) + + protected_header_json = protected_header.transform_keys(&:to_s).to_json + encoded_protected = Base64.jwe_encode(protected_header_json) + + aad_for_encryption = if aad + "#{encoded_protected}.#{Base64.jwe_encode(aad)}" + else + encoded_protected + end + + ciphertext = cipher.encrypt(payload, aad_for_encryption) + + if format == :flattened + Serialization::Json::Flattened.encode( + protected_header: encoded_protected, + unprotected_header: unprotected_header, + header: recipient_data[0][:header], + encrypted_key: recipient_data[0][:encrypted_key], + iv: cipher.iv, + ciphertext: ciphertext, + tag: cipher.tag, + aad: aad + ) + else + Serialization::Json::General.encode( + protected_header: encoded_protected, + unprotected_header: unprotected_header, + recipients: recipient_data, + iv: cipher.iv, + ciphertext: ciphertext, + tag: cipher.tag, + aad: aad + ) + end + end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists + + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + def decrypt_json(payload, keys) + data = JSON.parse(payload) + + decoded = if data['recipients'] + Serialization::Json::General.decode(data) + else + Serialization::Json::Flattened.decode(data) + end + + protected_header = if decoded[:protected_header] + JSON.parse(Base64.jwe_decode(decoded[:protected_header])) + else + {} + end + unprotected_header = decoded[:unprotected_header] || {} + + key_map = normalize_keys(keys) + + successful_recipients = [] + failed_recipients = [] + plaintext = nil + + decoded[:recipients].each_with_index do |recipient, index| + recipient_header = recipient[:header] || {} + jose_header = protected_header.merge(unprotected_header).merge(recipient_header) + + validate_header_no_duplicates!(protected_header, unprotected_header, recipient_header) + + alg = jose_header['alg'] + enc = jose_header['enc'] + + raise DecodeError, 'Missing alg in JOSE header' unless alg + raise DecodeError, 'Missing enc in JOSE header' unless enc + + check_alg(alg) + check_enc(enc) + + key = select_key(key_map, jose_header) + next unless key + + begin + cek = Alg.decrypt_cek(alg, key, recipient[:encrypted_key]) + + aad_for_decryption = if decoded[:aad] + "#{decoded[:protected_header]}.#{data['aad']}" + else + decoded[:protected_header] || '' + end + + cipher = Enc.for(enc, cek, decoded[:iv], decoded[:tag]) + decrypted = cipher.decrypt(decoded[:ciphertext], aad_for_decryption) + + plaintext = apply_zip(jose_header, decrypted, :decompress) + successful_recipients << index + break + rescue StandardError + failed_recipients << index + end + end + + raise InvalidData, 'No recipient could decrypt the message' unless plaintext + + DecryptionResult.new( + plaintext: plaintext, + successful_recipients: successful_recipients, + failed_recipients: failed_recipients + ) + end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + + private + + def build_jose_header(protected_header, unprotected_header, recipient_header) + result = {} + result.merge!(protected_header.transform_keys(&:to_s)) if protected_header + result.merge!(unprotected_header.transform_keys(&:to_s)) if unprotected_header + result.merge!(recipient_header.transform_keys(&:to_s)) if recipient_header + result + end + + def normalize_keys(keys) + case keys + when Hash + keys.transform_keys(&:to_s) + when Array + keys.each_with_index.to_h { |k, i| [i.to_s, k] } + else + { nil => keys } + end + end + + def select_key(key_map, jose_header) + kid = jose_header['kid'] + if kid && key_map.key?(kid) + key_map[kid] + elsif key_map.key?(nil) + key_map[nil] + else + key_map.values.first + end + end + + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def validate_header_no_duplicates!(protected_header, unprotected_header, recipient_header) + all_keys = [] + all_keys.concat(protected_header.keys.map(&:to_s)) if protected_header + all_keys.concat(unprotected_header.keys.map(&:to_s)) if unprotected_header + all_keys.concat(recipient_header.keys.map(&:to_s)) if recipient_header + + duplicates = all_keys.group_by(&:itself).select { |_, v| v.size > 1 }.keys + raise DecodeError, "Duplicate header parameters: #{duplicates.join(', ')}" if duplicates.any? + end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity end end diff --git a/lib/jwe/decryption_result.rb b/lib/jwe/decryption_result.rb new file mode 100644 index 0000000..d14e19d --- /dev/null +++ b/lib/jwe/decryption_result.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module JWE + # Decryption result for JSON Serialization + # Contains plaintext and information about successful/failed recipients + DecryptionResult = Struct.new(:plaintext, :successful_recipients, :failed_recipients, keyword_init: true) do + def initialize(plaintext:, successful_recipients: [], failed_recipients: []) + super + end + end +end diff --git a/lib/jwe/recipient.rb b/lib/jwe/recipient.rb new file mode 100644 index 0000000..d359561 --- /dev/null +++ b/lib/jwe/recipient.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module JWE + # Recipient structure for JSON Serialization + # Holds a key and optional per-recipient header + Recipient = Struct.new(:key, :header, keyword_init: true) do + def initialize(key:, header: {}) + super(key: key, header: header || {}) + end + end +end diff --git a/lib/jwe/serialization/json.rb b/lib/jwe/serialization/json.rb new file mode 100644 index 0000000..eeeae33 --- /dev/null +++ b/lib/jwe/serialization/json.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module JWE + module Serialization + # JSON Serialization namespace (RFC 7516 Section 7.2) + module Json + # General JWE JSON Serialization (RFC 7516 Section 7.2.1) + # Supports multiple recipients + class General + class << self + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists + def encode(protected_header:, unprotected_header:, recipients:, iv:, ciphertext:, tag:, aad:) + result = {} + + result['protected'] = protected_header if protected_header && !protected_header.empty? + result['unprotected'] = unprotected_header if unprotected_header && !unprotected_header.empty? + + result['recipients'] = recipients.map do |r| + recipient = {} + recipient['header'] = r[:header] if r[:header] && !r[:header].empty? + recipient['encrypted_key'] = Base64.jwe_encode(r[:encrypted_key]) + recipient + end + + result['aad'] = Base64.jwe_encode(aad) if aad + result['iv'] = Base64.jwe_encode(iv) + result['ciphertext'] = Base64.jwe_encode(ciphertext) + result['tag'] = Base64.jwe_encode(tag) + + result.to_json + end + + def decode(data) + raise JWE::DecodeError, 'Missing recipients' unless data['recipients'] + raise JWE::DecodeError, 'Missing ciphertext' unless data['ciphertext'] + + { + protected_header: data['protected'], + unprotected_header: data['unprotected'], + recipients: data['recipients'].map do |r| + { + header: r['header'], + encrypted_key: Base64.jwe_decode(r['encrypted_key'] || '') + } + end, + iv: Base64.jwe_decode(data['iv'] || ''), + ciphertext: Base64.jwe_decode(data['ciphertext']), + tag: Base64.jwe_decode(data['tag'] || ''), + aad: data['aad'] ? Base64.jwe_decode(data['aad']) : nil + } + end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists + end + end + + # Flattened JWE JSON Serialization (RFC 7516 Section 7.2.2) + # Single recipient only + class Flattened + class << self + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists + def encode(protected_header:, unprotected_header:, header:, encrypted_key:, iv:, ciphertext:, tag:, aad:) + result = {} + + result['protected'] = protected_header if protected_header && !protected_header.empty? + result['unprotected'] = unprotected_header if unprotected_header && !unprotected_header.empty? + result['header'] = header if header && !header.empty? + result['encrypted_key'] = Base64.jwe_encode(encrypted_key) + result['aad'] = Base64.jwe_encode(aad) if aad + result['iv'] = Base64.jwe_encode(iv) + result['ciphertext'] = Base64.jwe_encode(ciphertext) + result['tag'] = Base64.jwe_encode(tag) + + result.to_json + end + + def decode(data) + raise JWE::DecodeError, 'Missing ciphertext' unless data['ciphertext'] + raise JWE::DecodeError, 'Flattened format cannot have recipients' if data['recipients'] + + { + protected_header: data['protected'], + unprotected_header: data['unprotected'], + recipients: [ + { + header: data['header'], + encrypted_key: Base64.jwe_decode(data['encrypted_key'] || '') + } + ], + iv: Base64.jwe_decode(data['iv'] || ''), + ciphertext: Base64.jwe_decode(data['ciphertext']), + tag: Base64.jwe_decode(data['tag'] || ''), + aad: data['aad'] ? Base64.jwe_decode(data['aad']) : nil + } + end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists + end + end + end + end +end diff --git a/spec/jwe/serialization/json_spec.rb b/spec/jwe/serialization/json_spec.rb new file mode 100644 index 0000000..bccab46 --- /dev/null +++ b/spec/jwe/serialization/json_spec.rb @@ -0,0 +1,387 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe JWE::Serialization::Json do + let(:rsa_key) { OpenSSL::PKey::RSA.generate(2048) } + let(:plaintext) { 'Hello, World!' } + + describe JWE::Serialization::Json::General do + describe '.encode' do + it 'creates valid JSON structure' do + result = described_class.encode( + protected_header: 'eyJlbmMiOiJBMTI4R0NNIn0', + unprotected_header: { 'jku' => 'https://example.com' }, + recipients: [{ header: { 'alg' => 'RSA-OAEP' }, encrypted_key: 'encrypted' }], + iv: 'iv_value', + ciphertext: 'ciphertext', + tag: 'tag_value', + aad: nil + ) + + json = JSON.parse(result) + expect(json).to have_key('protected') + expect(json).to have_key('unprotected') + expect(json).to have_key('recipients') + expect(json).to have_key('iv') + expect(json).to have_key('ciphertext') + expect(json).to have_key('tag') + expect(json).not_to have_key('aad') + end + + it 'omits empty optional fields' do + result = described_class.encode( + protected_header: 'eyJlbmMiOiJBMTI4R0NNIn0', + unprotected_header: nil, + recipients: [{ header: nil, encrypted_key: 'encrypted' }], + iv: 'iv_value', + ciphertext: 'ciphertext', + tag: 'tag_value', + aad: nil + ) + + json = JSON.parse(result) + expect(json).not_to have_key('unprotected') + end + + it 'includes aad when provided' do + result = described_class.encode( + protected_header: 'eyJlbmMiOiJBMTI4R0NNIn0', + unprotected_header: nil, + recipients: [{ header: nil, encrypted_key: 'encrypted' }], + iv: 'iv_value', + ciphertext: 'ciphertext', + tag: 'tag_value', + aad: 'additional data' + ) + + json = JSON.parse(result) + expect(json).to have_key('aad') + end + end + + describe '.decode' do + it 'parses valid JSON' do + json = { + 'protected' => 'eyJlbmMiOiJBMTI4R0NNIn0', + 'recipients' => [{ 'header' => { 'alg' => 'RSA-OAEP' }, 'encrypted_key' => 'ZW5jcnlwdGVk' }], + 'iv' => 'aXY', + 'ciphertext' => 'Y2lwaGVydGV4dA', + 'tag' => 'dGFn' + }.to_json + + result = described_class.decode(JSON.parse(json)) + expect(result[:protected_header]).to eq('eyJlbmMiOiJBMTI4R0NNIn0') + expect(result[:recipients].length).to eq(1) + end + + it 'raises error for missing recipients' do + json = { 'ciphertext' => 'Y2lwaGVydGV4dA' }.to_json + expect do + described_class.decode(JSON.parse(json)) + end.to raise_error(JWE::DecodeError, /Missing recipients/) + end + + it 'raises error for missing ciphertext' do + json = { 'recipients' => [] }.to_json + expect do + described_class.decode(JSON.parse(json)) + end.to raise_error(JWE::DecodeError, /Missing ciphertext/) + end + end + end + + describe JWE::Serialization::Json::Flattened do + describe '.encode' do + it 'creates valid JSON structure' do + result = described_class.encode( + protected_header: 'eyJlbmMiOiJBMTI4R0NNIn0', + unprotected_header: nil, + header: { 'alg' => 'RSA-OAEP' }, + encrypted_key: 'encrypted', + iv: 'iv_value', + ciphertext: 'ciphertext', + tag: 'tag_value', + aad: nil + ) + + json = JSON.parse(result) + expect(json).to have_key('protected') + expect(json).to have_key('header') + expect(json).to have_key('encrypted_key') + expect(json).not_to have_key('recipients') + end + + it 'omits empty optional fields' do + result = described_class.encode( + protected_header: 'eyJlbmMiOiJBMTI4R0NNIn0', + unprotected_header: nil, + header: nil, + encrypted_key: 'encrypted', + iv: 'iv_value', + ciphertext: 'ciphertext', + tag: 'tag_value', + aad: nil + ) + + json = JSON.parse(result) + expect(json).not_to have_key('header') + expect(json).not_to have_key('unprotected') + end + end + + describe '.decode' do + it 'parses valid JSON' do + json = { + 'protected' => 'eyJlbmMiOiJBMTI4R0NNIn0', + 'header' => { 'alg' => 'RSA-OAEP' }, + 'encrypted_key' => 'ZW5jcnlwdGVk', + 'iv' => 'aXY', + 'ciphertext' => 'Y2lwaGVydGV4dA', + 'tag' => 'dGFn' + }.to_json + + result = described_class.decode(JSON.parse(json)) + expect(result[:recipients].length).to eq(1) + expect(result[:recipients][0][:header]).to eq({ 'alg' => 'RSA-OAEP' }) + end + + it 'raises error if recipients is present' do + json = { + 'recipients' => [], + 'ciphertext' => 'Y2lwaGVydGV4dA' + }.to_json + expect do + described_class.decode(JSON.parse(json)) + end.to raise_error(JWE::DecodeError, /cannot have recipients/) + end + + it 'raises error for missing ciphertext' do + json = { 'header' => { 'alg' => 'RSA-OAEP' } }.to_json + expect do + described_class.decode(JSON.parse(json)) + end.to raise_error(JWE::DecodeError, /Missing ciphertext/) + end + end + end +end + +RSpec.describe 'JWE JSON Serialization' do + let(:rsa_key) { OpenSSL::PKey::RSA.generate(2048) } + let(:plaintext) { 'Hello, World!' } + + describe 'JWE.encrypt_json' do + context 'with General serialization' do + it 'encrypts with a single recipient' do + recipients = [JWE::Recipient.new(key: rsa_key.public_key, header: { 'alg' => 'RSA-OAEP' })] + encrypted = JWE.encrypt_json(plaintext, recipients, protected_header: { enc: 'A128GCM' }) + + json = JSON.parse(encrypted) + expect(json).to have_key('protected') + expect(json).to have_key('recipients') + expect(json['recipients'].length).to eq(1) + end + + it 'encrypts with multiple recipients' do + rsa_key2 = OpenSSL::PKey::RSA.generate(2048) + recipients = [ + JWE::Recipient.new(key: rsa_key.public_key, header: { 'alg' => 'RSA-OAEP' }), + JWE::Recipient.new(key: rsa_key2.public_key, header: { 'alg' => 'RSA-OAEP' }) + ] + encrypted = JWE.encrypt_json(plaintext, recipients, protected_header: { enc: 'A128GCM' }) + + json = JSON.parse(encrypted) + expect(json['recipients'].length).to eq(2) + end + + it 'includes unprotected header when provided' do + recipients = [JWE::Recipient.new(key: rsa_key.public_key, header: { 'alg' => 'RSA-OAEP' })] + encrypted = JWE.encrypt_json(plaintext, recipients, + protected_header: { enc: 'A128GCM' }, + unprotected_header: { 'jku' => 'https://example.com' }) + + json = JSON.parse(encrypted) + expect(json['unprotected']).to eq({ 'jku' => 'https://example.com' }) + end + end + + context 'with Flattened serialization' do + it 'encrypts with a single recipient' do + recipients = [JWE::Recipient.new(key: rsa_key.public_key, header: { 'alg' => 'RSA-OAEP' })] + encrypted = JWE.encrypt_json(plaintext, recipients, + protected_header: { enc: 'A128GCM' }, + format: :flattened) + + json = JSON.parse(encrypted) + expect(json).to have_key('protected') + expect(json).to have_key('encrypted_key') + expect(json).not_to have_key('recipients') + end + + it 'raises error for multiple recipients' do + rsa_key2 = OpenSSL::PKey::RSA.generate(2048) + recipients = [ + JWE::Recipient.new(key: rsa_key.public_key, header: { 'alg' => 'RSA-OAEP' }), + JWE::Recipient.new(key: rsa_key2.public_key, header: { 'alg' => 'RSA-OAEP' }) + ] + + expect do + JWE.encrypt_json(plaintext, recipients, protected_header: { enc: 'A128GCM' }, format: :flattened) + end.to raise_error(ArgumentError, /only one recipient/) + end + end + + context 'with AAD' do + it 'includes AAD in encryption' do + recipients = [JWE::Recipient.new(key: rsa_key.public_key, header: { 'alg' => 'RSA-OAEP' })] + encrypted = JWE.encrypt_json(plaintext, recipients, + protected_header: { enc: 'A128GCM' }, + aad: 'my additional data') + + json = JSON.parse(encrypted) + expect(json).to have_key('aad') + end + end + + context 'with invalid parameters' do + it 'raises error for empty recipients' do + expect do + JWE.encrypt_json(plaintext, [], protected_header: { enc: 'A128GCM' }) + end.to raise_error(ArgumentError, /At least one recipient/) + end + + it 'raises error for missing enc' do + recipients = [JWE::Recipient.new(key: rsa_key.public_key, header: { 'alg' => 'RSA-OAEP' })] + expect do + JWE.encrypt_json(plaintext, recipients, protected_header: {}) + end.to raise_error(ArgumentError, /enc is required/) + end + + it 'raises error for missing alg in recipient' do + recipients = [JWE::Recipient.new(key: rsa_key.public_key, header: {})] + expect do + JWE.encrypt_json(plaintext, recipients, protected_header: { enc: 'A128GCM' }) + end.to raise_error(ArgumentError, /alg is required/) + end + end + end + + describe 'JWE.decrypt_json' do + context 'with General serialization' do + it 'decrypts successfully' do + recipients = [JWE::Recipient.new(key: rsa_key.public_key, header: { 'alg' => 'RSA-OAEP' })] + encrypted = JWE.encrypt_json(plaintext, recipients, protected_header: { enc: 'A128GCM' }) + + result = JWE.decrypt_json(encrypted, rsa_key) + expect(result.plaintext).to eq(plaintext) + expect(result.successful_recipients).to eq([0]) + end + + it 'decrypts with multiple recipients using correct key' do + rsa_key2 = OpenSSL::PKey::RSA.generate(2048) + recipients = [ + JWE::Recipient.new(key: rsa_key.public_key, header: { 'alg' => 'RSA-OAEP' }), + JWE::Recipient.new(key: rsa_key2.public_key, header: { 'alg' => 'RSA-OAEP' }) + ] + encrypted = JWE.encrypt_json(plaintext, recipients, protected_header: { enc: 'A128GCM' }) + + result = JWE.decrypt_json(encrypted, rsa_key2) + expect(result.plaintext).to eq(plaintext) + end + end + + context 'with Flattened serialization' do + it 'decrypts successfully' do + recipients = [JWE::Recipient.new(key: rsa_key.public_key, header: { 'alg' => 'RSA-OAEP' })] + encrypted = JWE.encrypt_json(plaintext, recipients, + protected_header: { enc: 'A128GCM' }, + format: :flattened) + + result = JWE.decrypt_json(encrypted, rsa_key) + expect(result.plaintext).to eq(plaintext) + end + end + + context 'with multiple keys' do + it 'selects the correct key by kid' do + rsa_key2 = OpenSSL::PKey::RSA.generate(2048) + recipients = [JWE::Recipient.new(key: rsa_key.public_key, header: { 'alg' => 'RSA-OAEP', 'kid' => 'my-key' })] + encrypted = JWE.encrypt_json(plaintext, recipients, protected_header: { enc: 'A128GCM' }) + + keys = { 'my-key' => rsa_key, 'other-key' => rsa_key2 } + result = JWE.decrypt_json(encrypted, keys) + expect(result.plaintext).to eq(plaintext) + end + end + + context 'with invalid data' do + it 'raises error when no recipient can decrypt' do + recipients = [JWE::Recipient.new(key: rsa_key.public_key, header: { 'alg' => 'RSA-OAEP' })] + encrypted = JWE.encrypt_json(plaintext, recipients, protected_header: { enc: 'A128GCM' }) + + wrong_key = OpenSSL::PKey::RSA.generate(2048) + expect do + JWE.decrypt_json(encrypted, wrong_key) + end.to raise_error(JWE::InvalidData, /No recipient could decrypt/) + end + end + + context 'with AAD' do + it 'validates AAD during decryption' do + recipients = [JWE::Recipient.new(key: rsa_key.public_key, header: { 'alg' => 'RSA-OAEP' })] + encrypted = JWE.encrypt_json(plaintext, recipients, + protected_header: { enc: 'A128GCM' }, + aad: 'my additional data') + + result = JWE.decrypt_json(encrypted, rsa_key) + expect(result.plaintext).to eq(plaintext) + end + + it 'fails if AAD is tampered' do + recipients = [JWE::Recipient.new(key: rsa_key.public_key, header: { 'alg' => 'RSA-OAEP' })] + encrypted = JWE.encrypt_json(plaintext, recipients, + protected_header: { enc: 'A128GCM' }, + aad: 'my additional data') + + json = JSON.parse(encrypted) + json['aad'] = JWE::Base64.jwe_encode('tampered data') + tampered = json.to_json + + expect do + JWE.decrypt_json(tampered, rsa_key) + end.to raise_error(JWE::InvalidData) + end + end + end +end + +RSpec.describe JWE::Recipient do + it 'creates with key only' do + key = OpenSSL::PKey::RSA.generate(2048) + recipient = described_class.new(key: key) + expect(recipient.key).to eq(key) + expect(recipient.header).to eq({}) + end + + it 'creates with key and header' do + key = OpenSSL::PKey::RSA.generate(2048) + recipient = described_class.new(key: key, header: { 'alg' => 'RSA-OAEP' }) + expect(recipient.key).to eq(key) + expect(recipient.header).to eq({ 'alg' => 'RSA-OAEP' }) + end +end + +RSpec.describe JWE::DecryptionResult do + it 'creates with all fields' do + result = described_class.new(plaintext: 'test', successful_recipients: [0], failed_recipients: [1]) + expect(result.plaintext).to eq('test') + expect(result.successful_recipients).to eq([0]) + expect(result.failed_recipients).to eq([1]) + end + + it 'has default empty arrays' do + result = described_class.new(plaintext: 'test') + expect(result.successful_recipients).to eq([]) + expect(result.failed_recipients).to eq([]) + end +end