From b044b94373d4c26cb6ba1a0743c8904c795f4b70 Mon Sep 17 00:00:00 2001 From: ydah Date: Thu, 29 Jan 2026 23:23:06 +0900 Subject: [PATCH] =?UTF-8?q?Add=20crit=20(Critical)=20header=20parameter=20?= =?UTF-8?q?validation=20per=20RFC=207516=20=C2=A74.1.13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implements `crit` (Critical) header parameter validation as defined in RFC 7516 Section 4.1.13. ## Usage ```ruby # Configure supported critical headers JWE.supported_critical_headers = ['custom-header'] # Encrypt with critical header encrypted = JWE.encrypt(payload, key, crit: ['custom-header'], 'custom-header': 'value') # Decrypt (validates crit automatically) decrypted = JWE.decrypt(encrypted, key) ``` ## References - [RFC 7516 §4.1.13](https://datatracker.ietf.org/doc/html/rfc7516#section-4.1.13) - [RFC 7515 §4.1.11](https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.11) --- CHANGELOG.md | 6 ++++ lib/jwe.rb | 24 +++++++++++++ spec/jwe/crit_spec.rb | 79 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 spec/jwe/crit_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index fc8df5c..7a4a4ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased](https://github.com/jwt/ruby-jwe/tree/HEAD) + +**Features:** + +- Add `crit` (Critical) header parameter validation per RFC 7516 §4.1.13 + ## [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..f0ce074 100644 --- a/lib/jwe.rb +++ b/lib/jwe.rb @@ -22,7 +22,13 @@ class InvalidData < RuntimeError; end VALID_ENC = %w[A128CBC-HS256 A192CBC-HS384 A256CBC-HS512 A128GCM A192GCM A256GCM].freeze VALID_ZIP = ['DEF'].freeze + REGISTERED_HEADERS = %w[ + alg enc zip jku jwk kid x5u x5c x5t x5t#S256 typ cty crit + ].freeze + class << self + attr_accessor :supported_critical_headers + def encrypt(payload, key, alg: 'RSA-OAEP', enc: 'A128GCM', **more_headers) header = generate_header(alg, enc, more_headers) check_params(header, key) @@ -55,6 +61,7 @@ def check_params(header, key) check_alg(header[:alg] || header['alg']) check_enc(header[:enc] || header['enc']) check_zip(header[:zip] || header['zip']) + check_crit(header) check_key(key) end @@ -74,6 +81,21 @@ def check_key(key) raise ArgumentError.new('The key must not be nil or blank') if key.nil? || (key.is_a?(String) && key.strip == '') end + def check_crit(header) + crit = header[:crit] || header['crit'] + return if crit.nil? + + raise ArgumentError, '"crit" header must be a non-empty array' unless crit.is_a?(Array) && !crit.empty? + + crit.each { |param| validate_critical_param(header, param) } + end + + def validate_critical_param(header, param) + raise ArgumentError, "\"#{param}\" is a registered header and cannot be in \"crit\"" if REGISTERED_HEADERS.include?(param) + raise ArgumentError, "\"#{param}\" is in \"crit\" but not present in header" unless header.key?(param) || header.key?(param.to_sym) + raise JWE::InvalidData, "Unsupported critical header: \"#{param}\"" unless supported_critical_headers.include?(param) + end + def param_to_class_name(param) klass = param.gsub(/[-+]/, '_').downcase.sub(/^[a-z\d]*/) { ::Regexp.last_match(0).capitalize } klass.gsub(/_([a-z\d]*)/i) { Regexp.last_match(1).capitalize } @@ -98,4 +120,6 @@ def generate_serialization(hdr, cek, content, cipher) Serialization::Compact.encode(hdr, cek, cipher.iv, content, cipher.tag) end end + + self.supported_critical_headers = [] end diff --git a/spec/jwe/crit_spec.rb b/spec/jwe/crit_spec.rb new file mode 100644 index 0000000..75189d7 --- /dev/null +++ b/spec/jwe/crit_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe JWE do + describe '.check_crit' do + context 'when crit header is not present' do + it 'does not raise an error' do + header = { alg: 'RSA-OAEP', enc: 'A128GCM' } + expect { JWE.check_crit(header) }.not_to raise_error + end + end + + context 'when crit header is present' do + context 'with valid critical headers' do + before do + JWE.supported_critical_headers = ['custom-header'] + end + + after do + JWE.supported_critical_headers = [] + end + + it 'accepts supported critical headers that exist in the header' do + header = { alg: 'RSA-OAEP', enc: 'A128GCM', crit: ['custom-header'], 'custom-header' => 'value' } + expect { JWE.check_crit(header) }.not_to raise_error + end + end + + context 'with invalid critical headers' do + it 'raises an error when crit is not an array' do + header = { alg: 'RSA-OAEP', enc: 'A128GCM', crit: 'not-an-array' } + expect { JWE.check_crit(header) }.to raise_error(ArgumentError, /"crit" header must be a non-empty array/) + end + + it 'raises an error when crit is an empty array' do + header = { alg: 'RSA-OAEP', enc: 'A128GCM', crit: [] } + expect { JWE.check_crit(header) }.to raise_error(ArgumentError, /"crit" header must be a non-empty array/) + end + + it 'raises an error when crit contains a registered header' do + header = { alg: 'RSA-OAEP', enc: 'A128GCM', crit: ['alg'] } + expect { JWE.check_crit(header) }.to raise_error(ArgumentError, /registered header/) + end + + it 'raises an error when crit references a non-existent header' do + header = { alg: 'RSA-OAEP', enc: 'A128GCM', crit: ['missing-header'] } + expect { JWE.check_crit(header) }.to raise_error(ArgumentError, /not present in header/) + end + + it 'raises an error when crit contains an unsupported header' do + header = { alg: 'RSA-OAEP', enc: 'A128GCM', crit: ['unsupported'], 'unsupported' => 'value' } + expect { JWE.check_crit(header) }.to raise_error(JWE::InvalidData, /Unsupported critical header/) + end + end + end + end + + describe 'encryption/decryption with crit header' do + let(:key) { OpenSSL::PKey::RSA.generate(2048) } + let(:plaintext) { 'Hello, World!' } + + context 'with supported critical headers' do + before do + JWE.supported_critical_headers = ['custom-header'] + end + + after do + JWE.supported_critical_headers = [] + end + + it 'successfully encrypts and decrypts with crit header' do + encrypted = JWE.encrypt(plaintext, key, crit: ['custom-header'], 'custom-header': 'value') + decrypted = JWE.decrypt(encrypted, key) + expect(decrypted).to eq(plaintext) + end + end + end +end