From 8af6dbe292d9d389a88919b7fadfcaae266e819b Mon Sep 17 00:00:00 2001 From: Hieu Nguyen Date: Sat, 5 Apr 2025 05:41:03 +0800 Subject: [PATCH 1/6] Add support for x5t and x5t#S256 header --- CHANGELOG.md | 1 + lib/jwt/decode.rb | 8 +- lib/jwt/x5t_key_finder.rb | 36 +++++++++ spec/jwt/x5t_key_finder_spec.rb | 138 ++++++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 lib/jwt/x5t_key_finder.rb create mode 100644 spec/jwt/x5t_key_finder_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f8cff0b..ff5cbf07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Take a look at the [upgrade guide](UPGRADING.md) for more details. - JWT::EncodedToken#verify! method that bundles signature and claim validation [#647](https://github.com/jwt/ruby-jwt/pull/647) ([@anakinj](https://github.com/anakinj)) - Do not override the alg header if already given [#659](https://github.com/jwt/ruby-jwt/pull/659) ([@anakinj](https://github.com/anakinj)) - Make `JWK::KeyFinder` compatible with `JWT::EncodedToken` [#663](https://github.com/jwt/ruby-jwt/pull/663) ([@anakinj](https://github.com/anakinj)) +- Add support for x5t header parameter for X.509 certificate thumbprint verification [#669](https://github.com/jwt/ruby-jwt/pull/669) ([@hieuk09](https://github.com/hieuk09)) - Your contribution here **Fixes and enhancements:** diff --git a/lib/jwt/decode.rb b/lib/jwt/decode.rb index 5928f401..f933b8f6 100644 --- a/lib/jwt/decode.rb +++ b/lib/jwt/decode.rb @@ -2,6 +2,7 @@ require 'json' require 'jwt/x5c_key_finder' +require 'jwt/x5t_key_finder' module JWT # The Decode class is responsible for decoding and verifying JWT tokens. @@ -61,9 +62,12 @@ def verify_algo def set_key @key = find_key(&@keyfinder) if @keyfinder @key = ::JWT::JWK::KeyFinder.new(jwks: @options[:jwks], allow_nil_kid: @options[:allow_nil_kid]).key_for(token.header['kid']) if @options[:jwks] - return unless (x5c_options = @options[:x5c]) - @key = X5cKeyFinder.new(x5c_options[:root_certificates], x5c_options[:crls]).from(token.header['x5c']) + if (x5c_options = @options[:x5c]) + @key = X5cKeyFinder.new(x5c_options[:root_certificates], x5c_options[:crls]).from(token.header['x5c']) + elsif (x5t_options = @options[:x5t]) + @key = X5tKeyFinder.new(x5t_options[:certificates]).from(token.header) + end end def allowed_and_valid_algorithms diff --git a/lib/jwt/x5t_key_finder.rb b/lib/jwt/x5t_key_finder.rb new file mode 100644 index 00000000..9766f3c4 --- /dev/null +++ b/lib/jwt/x5t_key_finder.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module JWT + # If the x5t header thumbprint matches one of the trusted certificates, + # returns the public key from that certificate. + # See https://tools.ietf.org/html/rfc7515#section-4.1.7 and + # https://tools.ietf.org/html/rfc7515#section-4.1.8 + class X5tKeyFinder + def initialize(certificates) + raise ArgumentError, 'Certificates must be specified' unless certificates.is_a?(Array) + + @certificates = certificates + end + + def from(header) + if header['x5t'] + x5t = header['x5t'] + digest_class = OpenSSL::Digest::SHA1 + elsif header['x5t#S256'] + x5t = header['x5t#S256'] + digest_class = OpenSSL::Digest::SHA256 + end + + raise JWT::DecodeError, 'x5t or x5t#S256 header parameter is required' unless x5t + + thumbprint = ::JWT::Base64.url_decode(x5t) + matching_cert = @certificates.find do |cert| + digest_class.new(cert.to_der).digest == thumbprint + end + + raise JWT::VerificationError, 'No certificate matches the x5t thumbprint' unless matching_cert + + matching_cert.public_key + end + end +end diff --git a/spec/jwt/x5t_key_finder_spec.rb b/spec/jwt/x5t_key_finder_spec.rb new file mode 100644 index 00000000..85587b4e --- /dev/null +++ b/spec/jwt/x5t_key_finder_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +RSpec.describe JWT::X5tKeyFinder do + let(:root_key) { test_pkey('rsa-2048-private.pem') } + let(:root_dn) { OpenSSL::X509::Name.parse('/DC=org/DC=fake-ca/CN=Fake CA') } + let(:root_certificate) { generate_root_cert(root_dn, root_key) } + let(:leaf_key) { generate_key } + let(:leaf_dn) { OpenSSL::X509::Name.parse('/DC=org/DC=fake/CN=Fake') } + let(:leaf_certificate) do + cert = generate_cert(leaf_dn, leaf_key.public_key, 2) + cert.sign(root_key, 'sha256') + cert + end + + subject(:keyfinder) { described_class.new([leaf_certificate]).from(header) } + + context 'when certificates argument is nil' do + subject(:keyfinder) { described_class.new(nil).from({}) } + + it 'raises an argument error' do + expect { keyfinder }.to raise_error(ArgumentError, 'Certificates must be specified') + end + end + + context 'when certificates argument is not array' do + subject(:keyfinder) { described_class.new('certificate').from({}) } + + it 'raises an argument error' do + expect { keyfinder }.to raise_error(ArgumentError, 'Certificates must be specified') + end + end + + context 'when x5t header is not present' do + subject(:keyfinder) { described_class.new([leaf_certificate]).from({}) } + + it 'raises a decode error' do + expect { keyfinder }.to raise_error(JWT::DecodeError, 'x5t or x5t#S256 header parameter is required') + end + end + + context 'when the x5t header is present' do + let(:x5t) { Base64.urlsafe_encode64(OpenSSL::Digest::SHA1.new(leaf_certificate.to_der).digest) } + let(:header) { { 'x5t' => x5t } } + + it 'returns the public key from a certificate matching the x5t thumbprint' do + expect(keyfinder).to be_a(OpenSSL::PKey::RSA) + expect(keyfinder.public_key.to_der).to eq(leaf_certificate.public_key.to_der) + end + + context '::JWT.decode' do + let(:token_payload) { { 'data' => 'something' } } + let(:encoded_token) { JWT.encode(token_payload, leaf_key, 'RS256', { 'x5t' => x5t }) } + let(:decoded_payload) do + JWT.decode(encoded_token, nil, true, algorithms: ['RS256'], x5t: { certificates: [leaf_certificate] }).first + end + + it 'returns the encoded payload after successful certificate thumbprint verification' do + expect(decoded_payload).to eq(token_payload) + end + end + + context 'when no certificate matches the thumbprint' do + let(:different_cert) do + generate_cert(leaf_dn, generate_key.public_key, 3).tap do |cert| + cert.sign(root_key, 'sha256') + end + end + subject(:keyfinder) { described_class.new([different_cert]).from(header) } + + it 'raises a verification error' do + expect { keyfinder }.to raise_error(JWT::VerificationError, 'No certificate matches the x5t thumbprint') + end + end + end + + context 'when the x5t#S256 header is present' do + let(:x5t) { Base64.urlsafe_encode64(OpenSSL::Digest::SHA256.new(leaf_certificate.to_der).digest) } + let(:header) { { 'x5t#S256' => x5t } } + + it 'returns the public key from a certificate matching the x5t thumbprint' do + expect(keyfinder).to be_a(OpenSSL::PKey::RSA) + expect(keyfinder.public_key.to_der).to eq(leaf_certificate.public_key.to_der) + end + + context '::JWT.decode' do + let(:token_payload) { { 'data' => 'something' } } + let(:encoded_token) { JWT.encode(token_payload, leaf_key, 'RS256', { 'x5t#S256' => x5t }) } + let(:decoded_payload) do + JWT.decode(encoded_token, nil, true, algorithms: ['RS256'], x5t: { certificates: [leaf_certificate] }).first + end + + it 'returns the encoded payload after successful certificate thumbprint verification' do + expect(decoded_payload).to eq(token_payload) + end + end + + context 'when no certificate matches the thumbprint' do + let(:different_cert) do + generate_cert(leaf_dn, generate_key.public_key, 3).tap do |cert| + cert.sign(root_key, 'sha256') + end + end + subject(:keyfinder) { described_class.new([different_cert]).from(header) } + + it 'raises a verification error' do + expect { keyfinder }.to raise_error(JWT::VerificationError, 'No certificate matches the x5t thumbprint') + end + end + end + + private + + def generate_key + OpenSSL::PKey::RSA.new(2048) + end + + def generate_root_cert(root_dn, root_key) + generate_cert(root_dn, root_key, 1).tap do |cert| + ef = OpenSSL::X509::ExtensionFactory.new + cert.add_extension(ef.create_extension('basicConstraints', 'CA:TRUE', true)) + cert.sign(root_key, 'sha256') + end + end + + def generate_cert(subject, key, serial, issuer: nil, not_after: nil) + OpenSSL::X509::Certificate.new.tap do |cert| + issuer ||= cert + cert.version = 2 + cert.serial = serial + cert.subject = subject + cert.issuer = issuer.subject + cert.public_key = key + now = Time.now + cert.not_before = now - 3600 + cert.not_after = not_after || (now + 3600) + end + end +end From e1645c5afccd2c7ed3e211e457eb7424b11d17a8 Mon Sep 17 00:00:00 2001 From: Hieu Nguyen Date: Sat, 5 Apr 2025 05:57:01 +0800 Subject: [PATCH 2/6] Update documentation --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index cbd7c066..19dc623e 100644 --- a/README.md +++ b/README.md @@ -647,6 +647,24 @@ rescue JWT::DecodeError end ``` +### X.509 certificate thumbprint in x5t header + +A JWT signature can be verified using a certificate thumbprint given in the `x5t` or `x5t#S256` header. +The thumbprint is a base64url-encoded SHA-1 (or SHA256) hash of the DER encoding of an X.509 certificate. +The verification process involves matching this thumbprint against a set of trusted certificates. + +```ruby +# Load your trusted certificates +certificates = [OpenSSL::X509::Certificate.new(File.read('cert.pem'))] + +# Decode a JWT with x5t verification +begin + JWT.decode(token, nil, true, { x5t: { certificates: certificates } }) +rescue JWT::DecodeError + # Handle error, e.g. no certificate matches the x5t thumbprint +end +``` + ## JSON Web Key (JWK) JWK is a JSON structure representing a cryptographic key. This gem currently supports RSA, EC, OKP and HMAC keys. OKP support requires [RbNaCl](https://github.com/RubyCrypto/rbnacl) and currently only supports the Ed25519 curve. From 323ffc1ee8526ec56e68f5d400daf2153435b091 Mon Sep 17 00:00:00 2001 From: Hieu Nguyen Date: Tue, 8 Apr 2025 21:28:40 +0800 Subject: [PATCH 3/6] Handle x5t using JWK key finder --- lib/jwt/base64.rb | 6 ++ lib/jwt/decode.rb | 11 +-- lib/jwt/jwk/key_finder.rb | 31 ++++-- lib/jwt/jwk/rsa.rb | 6 +- lib/jwt/x5t_key_finder.rb | 36 ------- spec/jwt/jwk/decode_with_jwk_spec.rb | 23 ++++- spec/jwt/x5t_key_finder_spec.rb | 138 --------------------------- 7 files changed, 58 insertions(+), 193 deletions(-) delete mode 100644 lib/jwt/x5t_key_finder.rb delete mode 100644 spec/jwt/x5t_key_finder_spec.rb diff --git a/lib/jwt/base64.rb b/lib/jwt/base64.rb index fdf1bf95..3683c14e 100644 --- a/lib/jwt/base64.rb +++ b/lib/jwt/base64.rb @@ -13,6 +13,12 @@ def url_encode(str) ::Base64.urlsafe_encode64(str, padding: false) end + # Encode a string with Base64 complying with RFC 4648 (padded). + # @api private + def strict_encode(str) + ::Base64.strict_encode64(str) + end + # Decode a string with URL-safe Base64 complying with RFC 4648. # @api private def url_decode(str) diff --git a/lib/jwt/decode.rb b/lib/jwt/decode.rb index f933b8f6..fe4e0b72 100644 --- a/lib/jwt/decode.rb +++ b/lib/jwt/decode.rb @@ -2,7 +2,6 @@ require 'json' require 'jwt/x5c_key_finder' -require 'jwt/x5t_key_finder' module JWT # The Decode class is responsible for decoding and verifying JWT tokens. @@ -61,13 +60,11 @@ def verify_algo def set_key @key = find_key(&@keyfinder) if @keyfinder - @key = ::JWT::JWK::KeyFinder.new(jwks: @options[:jwks], allow_nil_kid: @options[:allow_nil_kid]).key_for(token.header['kid']) if @options[:jwks] + @key = ::JWT::JWK::KeyFinder.new(jwks: @options[:jwks], allow_nil_kid: @options[:allow_nil_kid]).call(token) if @options[:jwks] - if (x5c_options = @options[:x5c]) - @key = X5cKeyFinder.new(x5c_options[:root_certificates], x5c_options[:crls]).from(token.header['x5c']) - elsif (x5t_options = @options[:x5t]) - @key = X5tKeyFinder.new(x5t_options[:certificates]).from(token.header) - end + return unless (x5c_options = @options[:x5c]) + + @key = X5cKeyFinder.new(x5c_options[:root_certificates], x5c_options[:crls]).from(token.header['x5c']) end def allowed_and_valid_algorithms diff --git a/lib/jwt/jwk/key_finder.rb b/lib/jwt/jwk/key_finder.rb index 80a2e7fe..2e4dd7be 100644 --- a/lib/jwt/jwk/key_finder.rb +++ b/lib/jwt/jwk/key_finder.rb @@ -22,11 +22,10 @@ def initialize(options) # Returns the verification key for the given kid # @param [String] kid the key id - def key_for(kid) - raise ::JWT::DecodeError, 'No key id (kid) found from token headers' unless kid || @allow_nil_kid - raise ::JWT::DecodeError, 'Invalid type for kid header parameter' unless kid.nil? || kid.is_a?(String) + def key_for(kid, key_field = :kid) + raise ::JWT::DecodeError, "Invalid type for #{key_field} header parameter" unless kid.nil? || kid.is_a?(String) - jwk = resolve_key(kid) + jwk = resolve_key(kid, key_field) raise ::JWT::DecodeError, 'No keys found in jwks' unless @jwks.any? raise ::JWT::DecodeError, "Could not find public key for kid #{kid}" unless jwk @@ -37,22 +36,36 @@ def key_for(kid) # Returns the key for the given token # @param [JWT::EncodedToken] token the token def call(token) - key_for(token.header['kid']) + kid = token.header['kid'] + x5t = token.header['x5t'] + x5c = token.header['x5c'] + + if kid + key_for(kid, :kid) + elsif x5t + key_for(x5t, :x5t) + elsif x5c + key_for(x5c, :x5c) + elsif @allow_nil_kid + key_for(kid) + else + raise ::JWT::DecodeError, 'No key id (kid) or x5t found from token headers' + end end private - def resolve_key(kid) - key_matcher = ->(key) { (kid.nil? && @allow_nil_kid) || key[:kid] == kid } + def resolve_key(kid, key_field) + key_matcher = ->(key) { (kid.nil? && @allow_nil_kid) || key[key_field] == kid } # First try without invalidation to facilitate application caching - @jwks ||= JWT::JWK::Set.new(@jwks_loader.call(kid: kid)) + @jwks ||= JWT::JWK::Set.new(@jwks_loader.call(key_field => kid)) jwk = @jwks.find { |key| key_matcher.call(key) } return jwk if jwk # Second try, invalidate for backwards compatibility - @jwks = JWT::JWK::Set.new(@jwks_loader.call(invalidate: true, kid_not_found: true, kid: kid)) + @jwks = JWT::JWK::Set.new(@jwks_loader.call(invalidate: true, kid_not_found: true, key_field => kid)) @jwks.find { |key| key_matcher.call(key) } end end diff --git a/lib/jwt/jwk/rsa.rb b/lib/jwt/jwk/rsa.rb index df0eeab2..997fdf20 100644 --- a/lib/jwt/jwk/rsa.rb +++ b/lib/jwt/jwk/rsa.rb @@ -51,6 +51,10 @@ def verify_key def export(options = {}) exported = parameters.clone exported.reject! { |k, _| RSA_PRIVATE_KEY_ELEMENTS.include? k } unless private? && options[:include_private] == true + + exported[:x5c] = Base64.strict_encode(rsa_key.to_der) if options[:x5c] + exported[:x5t] = Base64.url_encode(OpenSSL::Digest::SHA1.new(rsa_key.to_der).digest) if options[:x5t] + exported end @@ -67,7 +71,7 @@ def key_digest def []=(key, value) raise ArgumentError, 'cannot overwrite cryptographic key attributes' if RSA_KEY_ELEMENTS.include?(key.to_sym) - super(key, value) + super end private diff --git a/lib/jwt/x5t_key_finder.rb b/lib/jwt/x5t_key_finder.rb deleted file mode 100644 index 9766f3c4..00000000 --- a/lib/jwt/x5t_key_finder.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -module JWT - # If the x5t header thumbprint matches one of the trusted certificates, - # returns the public key from that certificate. - # See https://tools.ietf.org/html/rfc7515#section-4.1.7 and - # https://tools.ietf.org/html/rfc7515#section-4.1.8 - class X5tKeyFinder - def initialize(certificates) - raise ArgumentError, 'Certificates must be specified' unless certificates.is_a?(Array) - - @certificates = certificates - end - - def from(header) - if header['x5t'] - x5t = header['x5t'] - digest_class = OpenSSL::Digest::SHA1 - elsif header['x5t#S256'] - x5t = header['x5t#S256'] - digest_class = OpenSSL::Digest::SHA256 - end - - raise JWT::DecodeError, 'x5t or x5t#S256 header parameter is required' unless x5t - - thumbprint = ::JWT::Base64.url_decode(x5t) - matching_cert = @certificates.find do |cert| - digest_class.new(cert.to_der).digest == thumbprint - end - - raise JWT::VerificationError, 'No certificate matches the x5t thumbprint' unless matching_cert - - matching_cert.public_key - end - end -end diff --git a/spec/jwt/jwk/decode_with_jwk_spec.rb b/spec/jwt/jwk/decode_with_jwk_spec.rb index dfbde197..3e7ad8b8 100644 --- a/spec/jwt/jwk/decode_with_jwk_spec.rb +++ b/spec/jwt/jwk/decode_with_jwk_spec.rb @@ -4,7 +4,8 @@ describe '.decode for JWK usecase' do let(:keypair) { test_pkey('rsa-2048-private.pem') } let(:jwk) { JWT::JWK.new(keypair) } - let(:public_jwks) { { keys: [jwk.export, { kid: 'not_the_correct_one', kty: 'oct', k: 'secret' }] } } + let(:valid_key) { jwk.export } + let(:public_jwks) { { keys: [valid_key, { kid: 'not_the_correct_one', kty: 'oct', k: 'secret' }] } } let(:token_payload) { { 'data' => 'something' } } let(:token_headers) { { kid: jwk.kid } } let(:algorithm) { 'RS512' } @@ -38,6 +39,24 @@ end end + context 'and x5t is in the set' do + let(:valid_key) { jwk.export(x5t: true) } + let(:token_headers) { { x5t: Base64.urlsafe_encode64(OpenSSL::Digest::SHA1.new(keypair.to_der).digest, padding: false) } } + it 'is able to decode the token' do + payload, _header = described_class.decode(signed_token, nil, true, { algorithms: [algorithm], jwks: public_jwks }) + expect(payload).to eq(token_payload) + end + end + + context 'and x5c is in the set' do + let(:valid_key) { jwk.export(x5c: true) } + let(:token_headers) { { x5c: Base64.strict_encode64(keypair.to_der) } } + it 'is able to decode the token' do + payload, _header = described_class.decode(signed_token, nil, true, { algorithms: [algorithm], jwks: public_jwks }) + expect(payload).to eq(token_payload) + end + end + context 'no keys are found in the set' do let(:public_jwks) { { keys: [] } } it 'raises an exception' do @@ -51,7 +70,7 @@ let(:token_headers) { {} } it 'raises an exception' do expect { described_class.decode(signed_token, nil, true, { algorithms: [algorithm], jwks: public_jwks }) }.to raise_error( - JWT::DecodeError, 'No key id (kid) found from token headers' + JWT::DecodeError, 'No key id (kid) or x5t found from token headers' ) end end diff --git a/spec/jwt/x5t_key_finder_spec.rb b/spec/jwt/x5t_key_finder_spec.rb deleted file mode 100644 index 85587b4e..00000000 --- a/spec/jwt/x5t_key_finder_spec.rb +++ /dev/null @@ -1,138 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe JWT::X5tKeyFinder do - let(:root_key) { test_pkey('rsa-2048-private.pem') } - let(:root_dn) { OpenSSL::X509::Name.parse('/DC=org/DC=fake-ca/CN=Fake CA') } - let(:root_certificate) { generate_root_cert(root_dn, root_key) } - let(:leaf_key) { generate_key } - let(:leaf_dn) { OpenSSL::X509::Name.parse('/DC=org/DC=fake/CN=Fake') } - let(:leaf_certificate) do - cert = generate_cert(leaf_dn, leaf_key.public_key, 2) - cert.sign(root_key, 'sha256') - cert - end - - subject(:keyfinder) { described_class.new([leaf_certificate]).from(header) } - - context 'when certificates argument is nil' do - subject(:keyfinder) { described_class.new(nil).from({}) } - - it 'raises an argument error' do - expect { keyfinder }.to raise_error(ArgumentError, 'Certificates must be specified') - end - end - - context 'when certificates argument is not array' do - subject(:keyfinder) { described_class.new('certificate').from({}) } - - it 'raises an argument error' do - expect { keyfinder }.to raise_error(ArgumentError, 'Certificates must be specified') - end - end - - context 'when x5t header is not present' do - subject(:keyfinder) { described_class.new([leaf_certificate]).from({}) } - - it 'raises a decode error' do - expect { keyfinder }.to raise_error(JWT::DecodeError, 'x5t or x5t#S256 header parameter is required') - end - end - - context 'when the x5t header is present' do - let(:x5t) { Base64.urlsafe_encode64(OpenSSL::Digest::SHA1.new(leaf_certificate.to_der).digest) } - let(:header) { { 'x5t' => x5t } } - - it 'returns the public key from a certificate matching the x5t thumbprint' do - expect(keyfinder).to be_a(OpenSSL::PKey::RSA) - expect(keyfinder.public_key.to_der).to eq(leaf_certificate.public_key.to_der) - end - - context '::JWT.decode' do - let(:token_payload) { { 'data' => 'something' } } - let(:encoded_token) { JWT.encode(token_payload, leaf_key, 'RS256', { 'x5t' => x5t }) } - let(:decoded_payload) do - JWT.decode(encoded_token, nil, true, algorithms: ['RS256'], x5t: { certificates: [leaf_certificate] }).first - end - - it 'returns the encoded payload after successful certificate thumbprint verification' do - expect(decoded_payload).to eq(token_payload) - end - end - - context 'when no certificate matches the thumbprint' do - let(:different_cert) do - generate_cert(leaf_dn, generate_key.public_key, 3).tap do |cert| - cert.sign(root_key, 'sha256') - end - end - subject(:keyfinder) { described_class.new([different_cert]).from(header) } - - it 'raises a verification error' do - expect { keyfinder }.to raise_error(JWT::VerificationError, 'No certificate matches the x5t thumbprint') - end - end - end - - context 'when the x5t#S256 header is present' do - let(:x5t) { Base64.urlsafe_encode64(OpenSSL::Digest::SHA256.new(leaf_certificate.to_der).digest) } - let(:header) { { 'x5t#S256' => x5t } } - - it 'returns the public key from a certificate matching the x5t thumbprint' do - expect(keyfinder).to be_a(OpenSSL::PKey::RSA) - expect(keyfinder.public_key.to_der).to eq(leaf_certificate.public_key.to_der) - end - - context '::JWT.decode' do - let(:token_payload) { { 'data' => 'something' } } - let(:encoded_token) { JWT.encode(token_payload, leaf_key, 'RS256', { 'x5t#S256' => x5t }) } - let(:decoded_payload) do - JWT.decode(encoded_token, nil, true, algorithms: ['RS256'], x5t: { certificates: [leaf_certificate] }).first - end - - it 'returns the encoded payload after successful certificate thumbprint verification' do - expect(decoded_payload).to eq(token_payload) - end - end - - context 'when no certificate matches the thumbprint' do - let(:different_cert) do - generate_cert(leaf_dn, generate_key.public_key, 3).tap do |cert| - cert.sign(root_key, 'sha256') - end - end - subject(:keyfinder) { described_class.new([different_cert]).from(header) } - - it 'raises a verification error' do - expect { keyfinder }.to raise_error(JWT::VerificationError, 'No certificate matches the x5t thumbprint') - end - end - end - - private - - def generate_key - OpenSSL::PKey::RSA.new(2048) - end - - def generate_root_cert(root_dn, root_key) - generate_cert(root_dn, root_key, 1).tap do |cert| - ef = OpenSSL::X509::ExtensionFactory.new - cert.add_extension(ef.create_extension('basicConstraints', 'CA:TRUE', true)) - cert.sign(root_key, 'sha256') - end - end - - def generate_cert(subject, key, serial, issuer: nil, not_after: nil) - OpenSSL::X509::Certificate.new.tap do |cert| - issuer ||= cert - cert.version = 2 - cert.serial = serial - cert.subject = subject - cert.issuer = issuer.subject - cert.public_key = key - now = Time.now - cert.not_before = now - 3600 - cert.not_after = not_after || (now + 3600) - end - end -end From 431b524560cf0aae7303716a2025e061afa3d52d Mon Sep 17 00:00:00 2001 From: Hieu Nguyen Date: Tue, 8 Apr 2025 21:34:15 +0800 Subject: [PATCH 4/6] Update documentation and add tests --- README.md | 25 ++++--------------------- spec/jwt/jwk/rsa_spec.rb | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 19dc623e..264cb56b 100644 --- a/README.md +++ b/README.md @@ -647,24 +647,6 @@ rescue JWT::DecodeError end ``` -### X.509 certificate thumbprint in x5t header - -A JWT signature can be verified using a certificate thumbprint given in the `x5t` or `x5t#S256` header. -The thumbprint is a base64url-encoded SHA-1 (or SHA256) hash of the DER encoding of an X.509 certificate. -The verification process involves matching this thumbprint against a set of trusted certificates. - -```ruby -# Load your trusted certificates -certificates = [OpenSSL::X509::Certificate.new(File.read('cert.pem'))] - -# Decode a JWT with x5t verification -begin - JWT.decode(token, nil, true, { x5t: { certificates: certificates } }) -rescue JWT::DecodeError - # Handle error, e.g. no certificate matches the x5t thumbprint -end -``` - ## JSON Web Key (JWK) JWK is a JSON structure representing a cryptographic key. This gem currently supports RSA, EC, OKP and HMAC keys. OKP support requires [RbNaCl](https://github.com/RubyCrypto/rbnacl) and currently only supports the Ed25519 curve. @@ -692,13 +674,14 @@ algorithms = jwks.map { |key| key[:alg] }.compact.uniq JWT.decode(token, nil, true, algorithms: algorithms, jwks: jwks) ``` -The `jwks` option can also be given as a lambda that evaluates every time a kid is resolved. +The `jwks` option can also be given as a lambda that evaluates every time a key identifier is resolved. This can be used to implement caching of remotely fetched JWK Sets. -If the requested `kid` is not found from the given set the loader will be called a second time with the `kid_not_found` option set to `true`. +Key identifiers can be specified using `kid`, `x5t` or `x5c` header parameters. +If the requested identifier is not found from the given set the loader will be called a second time with the `kid_not_found` option set to `true`. The application can choose to implement some kind of JWK cache invalidation or other mechanism to handle such cases. -Tokens without a specified `kid` are rejected by default. +Tokens without a specified key identifier (`kid`, `x5t` or `x5c`) are rejected by default. This behaviour may be overwritten by setting the `allow_nil_kid` option for `decode` to `true`. ```ruby diff --git a/spec/jwt/jwk/rsa_spec.rb b/spec/jwt/jwk/rsa_spec.rb index 7c574e00..8a37004a 100644 --- a/spec/jwt/jwk/rsa_spec.rb +++ b/spec/jwt/jwk/rsa_spec.rb @@ -67,6 +67,26 @@ expect(subject).to include(:kty, :n, :e, :kid, :d, :p, :q, :dp, :dq, :qi) end end + + context 'when x5c option is requested' do + subject { described_class.new(keypair).export(x5c: true) } + let(:keypair) { rsa_key } + it 'returns a hash with x5c certificate chain' do + expect(subject).to be_a Hash + expect(subject).to include(:kty, :n, :e, :kid, :x5c) + expect(subject[:x5c]).to be_a String + end + end + + context 'when x5t option is requested' do + subject { described_class.new(keypair).export(x5t: true) } + let(:keypair) { rsa_key } + it 'returns a hash with x5t thumbprint' do + expect(subject).to be_a Hash + expect(subject).to include(:kty, :n, :e, :kid, :x5t) + expect(subject[:x5t]).to be_a String + end + end end describe '.kid' do From 2ef77680bc277e49dfee39bdbf180479d80649ce Mon Sep 17 00:00:00 2001 From: Hieu Nguyen Date: Sun, 20 Apr 2025 21:35:15 +0800 Subject: [PATCH 5/6] Add `include_` prefix to x5t option for consistency --- spec/jwt/jwk/decode_with_jwk_spec.rb | 2 +- spec/jwt/jwk/rsa_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/jwt/jwk/decode_with_jwk_spec.rb b/spec/jwt/jwk/decode_with_jwk_spec.rb index 3e7ad8b8..52280262 100644 --- a/spec/jwt/jwk/decode_with_jwk_spec.rb +++ b/spec/jwt/jwk/decode_with_jwk_spec.rb @@ -40,7 +40,7 @@ end context 'and x5t is in the set' do - let(:valid_key) { jwk.export(x5t: true) } + let(:valid_key) { jwk.export(include_x5t: true) } let(:token_headers) { { x5t: Base64.urlsafe_encode64(OpenSSL::Digest::SHA1.new(keypair.to_der).digest, padding: false) } } it 'is able to decode the token' do payload, _header = described_class.decode(signed_token, nil, true, { algorithms: [algorithm], jwks: public_jwks }) diff --git a/spec/jwt/jwk/rsa_spec.rb b/spec/jwt/jwk/rsa_spec.rb index 8a37004a..16acc707 100644 --- a/spec/jwt/jwk/rsa_spec.rb +++ b/spec/jwt/jwk/rsa_spec.rb @@ -79,7 +79,7 @@ end context 'when x5t option is requested' do - subject { described_class.new(keypair).export(x5t: true) } + subject { described_class.new(keypair).export(include_x5t: true) } let(:keypair) { rsa_key } it 'returns a hash with x5t thumbprint' do expect(subject).to be_a Hash From 5008010ce8013af99cfaf26cdadad4541f05c42e Mon Sep 17 00:00:00 2001 From: Hieu Nguyen Date: Sun, 20 Apr 2025 21:36:12 +0800 Subject: [PATCH 6/6] Do not handle x5c via JWK --- lib/jwt/jwk/rsa.rb | 3 +-- spec/jwt/jwk/decode_with_jwk_spec.rb | 9 --------- spec/jwt/jwk/rsa_spec.rb | 10 ---------- 3 files changed, 1 insertion(+), 21 deletions(-) diff --git a/lib/jwt/jwk/rsa.rb b/lib/jwt/jwk/rsa.rb index 997fdf20..78792c10 100644 --- a/lib/jwt/jwk/rsa.rb +++ b/lib/jwt/jwk/rsa.rb @@ -52,8 +52,7 @@ def export(options = {}) exported = parameters.clone exported.reject! { |k, _| RSA_PRIVATE_KEY_ELEMENTS.include? k } unless private? && options[:include_private] == true - exported[:x5c] = Base64.strict_encode(rsa_key.to_der) if options[:x5c] - exported[:x5t] = Base64.url_encode(OpenSSL::Digest::SHA1.new(rsa_key.to_der).digest) if options[:x5t] + exported[:x5t] = Base64.url_encode(OpenSSL::Digest::SHA1.new(rsa_key.to_der).digest) if options[:include_x5t] exported end diff --git a/spec/jwt/jwk/decode_with_jwk_spec.rb b/spec/jwt/jwk/decode_with_jwk_spec.rb index 52280262..5d20d3f2 100644 --- a/spec/jwt/jwk/decode_with_jwk_spec.rb +++ b/spec/jwt/jwk/decode_with_jwk_spec.rb @@ -48,15 +48,6 @@ end end - context 'and x5c is in the set' do - let(:valid_key) { jwk.export(x5c: true) } - let(:token_headers) { { x5c: Base64.strict_encode64(keypair.to_der) } } - it 'is able to decode the token' do - payload, _header = described_class.decode(signed_token, nil, true, { algorithms: [algorithm], jwks: public_jwks }) - expect(payload).to eq(token_payload) - end - end - context 'no keys are found in the set' do let(:public_jwks) { { keys: [] } } it 'raises an exception' do diff --git a/spec/jwt/jwk/rsa_spec.rb b/spec/jwt/jwk/rsa_spec.rb index 16acc707..9c0259ae 100644 --- a/spec/jwt/jwk/rsa_spec.rb +++ b/spec/jwt/jwk/rsa_spec.rb @@ -68,16 +68,6 @@ end end - context 'when x5c option is requested' do - subject { described_class.new(keypair).export(x5c: true) } - let(:keypair) { rsa_key } - it 'returns a hash with x5c certificate chain' do - expect(subject).to be_a Hash - expect(subject).to include(:kty, :n, :e, :kid, :x5c) - expect(subject[:x5c]).to be_a String - end - end - context 'when x5t option is requested' do subject { described_class.new(keypair).export(include_x5t: true) } let(:keypair) { rsa_key }