diff --git a/CHANGELOG.md b/CHANGELOG.md index 8318e9e1e..a40669d9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +**Fixes and enhancements:** + +- Backport: Reject `nil` and empty HMAC keys when signing and verifying ([CVE-2026-45363](https://www.cve.org/CVERecord?id=CVE-2026-45363) / [GHSA-c32j-vqhx-rx3x](https://github.com/jwt/ruby-jwt/security/advisories/GHSA-c32j-vqhx-rx3x)) [#725](https://github.com/jwt/ruby-jwt/pull/725) ([@royzwambag](https://github.com/royzwambag)) + ## [v2.10.2](https://github.com/jwt/ruby-jwt/tree/v2.10.2) (2025-06-29) [Full Changelog](https://github.com/jwt/ruby-jwt/compare/v2.10.1...v2.10.2) diff --git a/lib/jwt/jwa/hmac.rb b/lib/jwt/jwa/hmac.rb index e66d8ffd8..db287a108 100644 --- a/lib/jwt/jwa/hmac.rb +++ b/lib/jwt/jwa/hmac.rb @@ -16,18 +16,15 @@ def initialize(alg, digest) end def sign(data:, signing_key:) - signing_key ||= '' - raise_verify_error!('HMAC key expected to be a String') unless signing_key.is_a?(String) + ensure_valid_key!(signing_key) OpenSSL::HMAC.digest(digest.new, signing_key, data) - rescue OpenSSL::HMACError => e - raise_verify_error!('OpenSSL 3.0 does not support nil or empty hmac_secret') if signing_key == '' && e.message == 'EVP_PKEY_new_mac_key: malloc failure' - - raise e end def verify(data:, signature:, verification_key:) - SecurityUtils.secure_compare(signature, sign(data: data, signing_key: verification_key)) + ensure_valid_key!(verification_key) + + SecurityUtils.secure_compare(signature, OpenSSL::HMAC.digest(digest.new, verification_key, data)) end register_algorithm(new('HS256', OpenSSL::Digest::SHA256)) @@ -38,6 +35,11 @@ def verify(data:, signature:, verification_key:) attr_reader :digest + def ensure_valid_key!(key) + raise_verify_error!('HMAC key expected to be a String') unless key.is_a?(String) + raise_verify_error!('HMAC key cannot be empty') if key.empty? + end + # Copy of https://github.com/rails/rails/blob/v7.0.3.1/activesupport/lib/active_support/security_utils.rb # rubocop:disable Naming/MethodParameterName, Style/StringLiterals, Style/NumericPredicate module SecurityUtils diff --git a/spec/integration/readme_examples_spec.rb b/spec/integration/readme_examples_spec.rb index 9f84058f7..c6a17d7c3 100644 --- a/spec/integration/readme_examples_spec.rb +++ b/spec/integration/readme_examples_spec.rb @@ -28,18 +28,6 @@ ] end - it 'decodes with HMAC algorithm without secret key' do - pending 'Different behaviour on OpenSSL 3.0 (https://github.com/openssl/openssl/issues/13089)' if JWT.openssl_3_hmac_empty_key_regression? - token = JWT.encode payload, nil, 'HS256' - decoded_token = JWT.decode token, nil, false - - expect(token).to eq 'eyJhbGciOiJIUzI1NiJ9.eyJkYXRhIjoidGVzdCJ9.pVzcY2dX8JNM3LzIYeP2B1e1Wcpt1K3TWVvIYSF4x-o' - expect(decoded_token).to eq [ - { 'data' => 'test' }, - { 'alg' => 'HS256' } - ] - end - it 'RSA' do rsa_private = OpenSSL::PKey::RSA.generate 2048 rsa_public = rsa_private.public_key diff --git a/spec/jwt/jwa/hmac_spec.rb b/spec/jwt/jwa/hmac_spec.rb index 2d98d2271..0a2e2d943 100644 --- a/spec/jwt/jwa/hmac_spec.rb +++ b/spec/jwt/jwa/hmac_spec.rb @@ -12,68 +12,39 @@ it { is_expected.to eq(valid_signature) } end - # Address OpenSSL 3.0 errors with empty hmac_secret - https://github.com/jwt/ruby-jwt/issues/526 + # GHSA-c32j-vqhx-rx3x: empty/nil keys must be rejected before reaching OpenSSL, + # so a forged token signed with "" cannot verify. context 'when nil hmac_secret is passed' do let(:hmac_secret) { nil } - context 'when OpenSSL 3.0 raises a malloc failure' do - before do - allow(OpenSSL::HMAC).to receive(:digest).and_raise(OpenSSL::HMACError.new('EVP_PKEY_new_mac_key: malloc failure')) - end - it 'raises JWT::DecodeError' do - expect { subject }.to raise_error(JWT::DecodeError, 'OpenSSL 3.0 does not support nil or empty hmac_secret') - end + it 'raises JWT::DecodeError' do + expect { subject }.to raise_error(JWT::DecodeError, 'HMAC key expected to be a String') end - context 'when OpenSSL raises any other error' do - before do - allow(OpenSSL::HMAC).to receive(:digest).and_raise(OpenSSL::HMACError.new('Another Random Error')) - end - - it 'raises the original error' do - expect { subject }.to raise_error(OpenSSL::HMACError, 'Another Random Error') - end - end - - context 'when other versions of openssl do not raise an exception' do - let(:response) { Base64.decode64("Q7DO+ZJl+eNMEOqdNQGSbSezn1fG1nRWHYuiNueoGfs=\n") } - before do - allow(OpenSSL::HMAC).to receive(:digest).and_return(response) - end - - it { is_expected.to eql(response) } + it 'does not call OpenSSL::HMAC.digest' do + expect(OpenSSL::HMAC).not_to receive(:digest) + expect { subject }.to raise_error(JWT::DecodeError) end end context 'when blank hmac_secret is passed' do let(:hmac_secret) { '' } - context 'when OpenSSL 3.0 raises a malloc failure' do - before do - allow(OpenSSL::HMAC).to receive(:digest).and_raise(OpenSSL::HMACError.new('EVP_PKEY_new_mac_key: malloc failure')) - end - it 'raises JWT::DecodeError' do - expect { subject }.to raise_error(JWT::DecodeError, 'OpenSSL 3.0 does not support nil or empty hmac_secret') - end + it 'raises JWT::DecodeError' do + expect { subject }.to raise_error(JWT::DecodeError, 'HMAC key cannot be empty') end - context 'when OpenSSL raises any other error' do - before do - allow(OpenSSL::HMAC).to receive(:digest).and_raise(OpenSSL::HMACError.new('Another Random Error')) - end - - it 'raises the original error' do - expect { subject }.to raise_error(OpenSSL::HMACError, 'Another Random Error') - end + it 'does not call OpenSSL::HMAC.digest' do + expect(OpenSSL::HMAC).not_to receive(:digest) + expect { subject }.to raise_error(JWT::DecodeError) end + end - context 'when other versions of openssl do not raise an exception' do - let(:response) { Base64.decode64("Q7DO+ZJl+eNMEOqdNQGSbSezn1fG1nRWHYuiNueoGfs=\n") } - before do - allow(OpenSSL::HMAC).to receive(:digest).and_return(response) - end + context 'when non-String hmac_secret is passed' do + let(:hmac_secret) { 123 } - it { is_expected.to eql(response) } + it 'raises JWT::DecodeError' do + expect { subject }.to raise_error(JWT::DecodeError, 'HMAC key expected to be a String') end end @@ -124,6 +95,45 @@ it { is_expected.to be(false) } end + + # GHSA-c32j-vqhx-rx3x: empty/nil keys must be rejected before reaching OpenSSL, + # so a forged token signed with "" cannot verify. + context 'when verification_key is nil' do + let(:signature) { valid_signature } + let(:hmac_secret) { nil } + + it 'raises JWT::DecodeError' do + expect { subject }.to raise_error(JWT::DecodeError, 'HMAC key expected to be a String') + end + + it 'does not call OpenSSL::HMAC.digest' do + expect(OpenSSL::HMAC).not_to receive(:digest) + expect { subject }.to raise_error(JWT::DecodeError) + end + end + + context 'when verification_key is an empty string' do + let(:signature) { valid_signature } + let(:hmac_secret) { '' } + + it 'raises JWT::DecodeError' do + expect { subject }.to raise_error(JWT::DecodeError, 'HMAC key cannot be empty') + end + + it 'does not call OpenSSL::HMAC.digest' do + expect(OpenSSL::HMAC).not_to receive(:digest) + expect { subject }.to raise_error(JWT::DecodeError) + end + end + + context 'when verification_key is not a String' do + let(:signature) { valid_signature } + let(:hmac_secret) { 123 } + + it 'raises JWT::DecodeError' do + expect { subject }.to raise_error(JWT::DecodeError, 'HMAC key expected to be a String') + end + end end context 'backwards compatibility' do diff --git a/spec/jwt/jwt_spec.rb b/spec/jwt/jwt_spec.rb index 779db969b..8e7377c94 100644 --- a/spec/jwt/jwt_spec.rb +++ b/spec/jwt/jwt_spec.rb @@ -650,20 +650,6 @@ end end - context 'when hmac algorithm is used without secret key' do - it 'encodes payload' do - pending 'Different behaviour on OpenSSL 3.0 (https://github.com/openssl/openssl/issues/13089)' if JWT.openssl_3_hmac_empty_key_regression? - payload = { a: 1, b: 'b' } - - token = JWT.encode(payload, '', 'HS256') - - expect do - token_without_secret = JWT.encode(payload, nil, 'HS256') - expect(token).to eq(token_without_secret) - end.not_to raise_error - end - end - context 'algorithm case insensitivity' do let(:payload) { { 'a' => 1, 'b' => 'b' } } @@ -780,14 +766,6 @@ end end - describe 'when token signed with nil and decoded with nil' do - let(:no_key_token) { JWT.encode(payload, nil, 'HS512') } - it 'raises JWT::DecodeError' do - pending 'Different behaviour on OpenSSL 3.0 (https://github.com/openssl/openssl/issues/13089)' if JWT.openssl_3_hmac_empty_key_regression? - expect { JWT.decode(no_key_token, nil, true, algorithms: 'HS512') }.to raise_error(JWT::DecodeError, 'No verification key available') - end - end - context 'when token ends with a newline char' do let(:token) { "#{JWT.encode(payload, 'secret', 'HS256')}\n" } it 'ignores the newline and decodes the token' do