Skip to content

Commit 8af6dbe

Browse files
committed
Add support for x5t and x5t#S256 header
1 parent bbbd69d commit 8af6dbe

File tree

4 files changed

+181
-2
lines changed

4 files changed

+181
-2
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Take a look at the [upgrade guide](UPGRADING.md) for more details.
2020
- JWT::EncodedToken#verify! method that bundles signature and claim validation [#647](https://github.com/jwt/ruby-jwt/pull/647) ([@anakinj](https://github.com/anakinj))
2121
- Do not override the alg header if already given [#659](https://github.com/jwt/ruby-jwt/pull/659) ([@anakinj](https://github.com/anakinj))
2222
- Make `JWK::KeyFinder` compatible with `JWT::EncodedToken` [#663](https://github.com/jwt/ruby-jwt/pull/663) ([@anakinj](https://github.com/anakinj))
23+
- 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))
2324
- Your contribution here
2425

2526
**Fixes and enhancements:**

lib/jwt/decode.rb

+6-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require 'json'
44
require 'jwt/x5c_key_finder'
5+
require 'jwt/x5t_key_finder'
56

67
module JWT
78
# The Decode class is responsible for decoding and verifying JWT tokens.
@@ -61,9 +62,12 @@ def verify_algo
6162
def set_key
6263
@key = find_key(&@keyfinder) if @keyfinder
6364
@key = ::JWT::JWK::KeyFinder.new(jwks: @options[:jwks], allow_nil_kid: @options[:allow_nil_kid]).key_for(token.header['kid']) if @options[:jwks]
64-
return unless (x5c_options = @options[:x5c])
6565

66-
@key = X5cKeyFinder.new(x5c_options[:root_certificates], x5c_options[:crls]).from(token.header['x5c'])
66+
if (x5c_options = @options[:x5c])
67+
@key = X5cKeyFinder.new(x5c_options[:root_certificates], x5c_options[:crls]).from(token.header['x5c'])
68+
elsif (x5t_options = @options[:x5t])
69+
@key = X5tKeyFinder.new(x5t_options[:certificates]).from(token.header)
70+
end
6771
end
6872

6973
def allowed_and_valid_algorithms

lib/jwt/x5t_key_finder.rb

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# frozen_string_literal: true
2+
3+
module JWT
4+
# If the x5t header thumbprint matches one of the trusted certificates,
5+
# returns the public key from that certificate.
6+
# See https://tools.ietf.org/html/rfc7515#section-4.1.7 and
7+
# https://tools.ietf.org/html/rfc7515#section-4.1.8
8+
class X5tKeyFinder
9+
def initialize(certificates)
10+
raise ArgumentError, 'Certificates must be specified' unless certificates.is_a?(Array)
11+
12+
@certificates = certificates
13+
end
14+
15+
def from(header)
16+
if header['x5t']
17+
x5t = header['x5t']
18+
digest_class = OpenSSL::Digest::SHA1
19+
elsif header['x5t#S256']
20+
x5t = header['x5t#S256']
21+
digest_class = OpenSSL::Digest::SHA256
22+
end
23+
24+
raise JWT::DecodeError, 'x5t or x5t#S256 header parameter is required' unless x5t
25+
26+
thumbprint = ::JWT::Base64.url_decode(x5t)
27+
matching_cert = @certificates.find do |cert|
28+
digest_class.new(cert.to_der).digest == thumbprint
29+
end
30+
31+
raise JWT::VerificationError, 'No certificate matches the x5t thumbprint' unless matching_cert
32+
33+
matching_cert.public_key
34+
end
35+
end
36+
end

spec/jwt/x5t_key_finder_spec.rb

+138
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe JWT::X5tKeyFinder do
4+
let(:root_key) { test_pkey('rsa-2048-private.pem') }
5+
let(:root_dn) { OpenSSL::X509::Name.parse('/DC=org/DC=fake-ca/CN=Fake CA') }
6+
let(:root_certificate) { generate_root_cert(root_dn, root_key) }
7+
let(:leaf_key) { generate_key }
8+
let(:leaf_dn) { OpenSSL::X509::Name.parse('/DC=org/DC=fake/CN=Fake') }
9+
let(:leaf_certificate) do
10+
cert = generate_cert(leaf_dn, leaf_key.public_key, 2)
11+
cert.sign(root_key, 'sha256')
12+
cert
13+
end
14+
15+
subject(:keyfinder) { described_class.new([leaf_certificate]).from(header) }
16+
17+
context 'when certificates argument is nil' do
18+
subject(:keyfinder) { described_class.new(nil).from({}) }
19+
20+
it 'raises an argument error' do
21+
expect { keyfinder }.to raise_error(ArgumentError, 'Certificates must be specified')
22+
end
23+
end
24+
25+
context 'when certificates argument is not array' do
26+
subject(:keyfinder) { described_class.new('certificate').from({}) }
27+
28+
it 'raises an argument error' do
29+
expect { keyfinder }.to raise_error(ArgumentError, 'Certificates must be specified')
30+
end
31+
end
32+
33+
context 'when x5t header is not present' do
34+
subject(:keyfinder) { described_class.new([leaf_certificate]).from({}) }
35+
36+
it 'raises a decode error' do
37+
expect { keyfinder }.to raise_error(JWT::DecodeError, 'x5t or x5t#S256 header parameter is required')
38+
end
39+
end
40+
41+
context 'when the x5t header is present' do
42+
let(:x5t) { Base64.urlsafe_encode64(OpenSSL::Digest::SHA1.new(leaf_certificate.to_der).digest) }
43+
let(:header) { { 'x5t' => x5t } }
44+
45+
it 'returns the public key from a certificate matching the x5t thumbprint' do
46+
expect(keyfinder).to be_a(OpenSSL::PKey::RSA)
47+
expect(keyfinder.public_key.to_der).to eq(leaf_certificate.public_key.to_der)
48+
end
49+
50+
context '::JWT.decode' do
51+
let(:token_payload) { { 'data' => 'something' } }
52+
let(:encoded_token) { JWT.encode(token_payload, leaf_key, 'RS256', { 'x5t' => x5t }) }
53+
let(:decoded_payload) do
54+
JWT.decode(encoded_token, nil, true, algorithms: ['RS256'], x5t: { certificates: [leaf_certificate] }).first
55+
end
56+
57+
it 'returns the encoded payload after successful certificate thumbprint verification' do
58+
expect(decoded_payload).to eq(token_payload)
59+
end
60+
end
61+
62+
context 'when no certificate matches the thumbprint' do
63+
let(:different_cert) do
64+
generate_cert(leaf_dn, generate_key.public_key, 3).tap do |cert|
65+
cert.sign(root_key, 'sha256')
66+
end
67+
end
68+
subject(:keyfinder) { described_class.new([different_cert]).from(header) }
69+
70+
it 'raises a verification error' do
71+
expect { keyfinder }.to raise_error(JWT::VerificationError, 'No certificate matches the x5t thumbprint')
72+
end
73+
end
74+
end
75+
76+
context 'when the x5t#S256 header is present' do
77+
let(:x5t) { Base64.urlsafe_encode64(OpenSSL::Digest::SHA256.new(leaf_certificate.to_der).digest) }
78+
let(:header) { { 'x5t#S256' => x5t } }
79+
80+
it 'returns the public key from a certificate matching the x5t thumbprint' do
81+
expect(keyfinder).to be_a(OpenSSL::PKey::RSA)
82+
expect(keyfinder.public_key.to_der).to eq(leaf_certificate.public_key.to_der)
83+
end
84+
85+
context '::JWT.decode' do
86+
let(:token_payload) { { 'data' => 'something' } }
87+
let(:encoded_token) { JWT.encode(token_payload, leaf_key, 'RS256', { 'x5t#S256' => x5t }) }
88+
let(:decoded_payload) do
89+
JWT.decode(encoded_token, nil, true, algorithms: ['RS256'], x5t: { certificates: [leaf_certificate] }).first
90+
end
91+
92+
it 'returns the encoded payload after successful certificate thumbprint verification' do
93+
expect(decoded_payload).to eq(token_payload)
94+
end
95+
end
96+
97+
context 'when no certificate matches the thumbprint' do
98+
let(:different_cert) do
99+
generate_cert(leaf_dn, generate_key.public_key, 3).tap do |cert|
100+
cert.sign(root_key, 'sha256')
101+
end
102+
end
103+
subject(:keyfinder) { described_class.new([different_cert]).from(header) }
104+
105+
it 'raises a verification error' do
106+
expect { keyfinder }.to raise_error(JWT::VerificationError, 'No certificate matches the x5t thumbprint')
107+
end
108+
end
109+
end
110+
111+
private
112+
113+
def generate_key
114+
OpenSSL::PKey::RSA.new(2048)
115+
end
116+
117+
def generate_root_cert(root_dn, root_key)
118+
generate_cert(root_dn, root_key, 1).tap do |cert|
119+
ef = OpenSSL::X509::ExtensionFactory.new
120+
cert.add_extension(ef.create_extension('basicConstraints', 'CA:TRUE', true))
121+
cert.sign(root_key, 'sha256')
122+
end
123+
end
124+
125+
def generate_cert(subject, key, serial, issuer: nil, not_after: nil)
126+
OpenSSL::X509::Certificate.new.tap do |cert|
127+
issuer ||= cert
128+
cert.version = 2
129+
cert.serial = serial
130+
cert.subject = subject
131+
cert.issuer = issuer.subject
132+
cert.public_key = key
133+
now = Time.now
134+
cert.not_before = now - 3600
135+
cert.not_after = not_after || (now + 3600)
136+
end
137+
end
138+
end

0 commit comments

Comments
 (0)