Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Metrics/AbcSize:
Max: 25

Metrics/ClassLength:
Max: 101
Max: 103

Metrics/ModuleLength:
Max: 100
Expand Down Expand Up @@ -95,4 +95,4 @@ Layout/HashAlignment:
EnforcedLastArgumentHashStyle: always_ignore

Style/TrivialAccessors:
AllowPredicates: true
AllowPredicates: true
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,27 @@ You can specify claims that must be present for decoding to be successful. JWT::
JWT.decode token, hmac_secret, true, { required_claims: ['exp'], algorithm: 'HS256' }
```

### X.509 certificates in x5c header

A JWT signature can be verified using certificate(s) given in the `x5c` header. Before doing that, the trustworthiness of these certificate(s) must be established. This is done in accordance with RFC 5280 which (among other things) verifies the certificate(s) are issued by a trusted root certificate, the timestamps are valid, and none of the certificate(s) are revoked (i.e. being present in the root certificate's Certificate Revocation List).

```ruby
root_certificates = [] # trusted `OpenSSL::X509::Certificate` objects
crl_uris = root_certificates.map(&:crl_uris)
crls = crl_uris.map do |uri|
# look up cached CRL by `uri` and return it if found, otherwise continue
crl = Net::HTTP.get(uri)
crl = OpenSSL::X509::CRL.new(crl)
# cache `crl` using `uri` as the key, expiry set to `crl.next_update` timestamp
end

begin
JWT.decode(token, nil, true, { x5c: { root_certificates: root_certificates, crls: crls })
rescue JWT::DecodeError
# Handle error, e.g. x5c header certificate revoked or expired
end
```

### JSON Web Key (JWK)

JWK is a JSON structure representing a cryptographic key. Currently only supports RSA public keys.
Expand Down
9 changes: 8 additions & 1 deletion lib/jwt/decode.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

require 'jwt/signature'
require 'jwt/verify'
require 'jwt/x5c_key_finder'
# JWT::Decode module
module JWT
# Decoding logic for JWT
Expand All @@ -23,6 +24,7 @@ def decode_segments
validate_segment_count!
if @verify
decode_crypto
verify_algo
set_key
verify_signature
verify_claims
Expand All @@ -45,13 +47,18 @@ def verify_signature
raise(JWT::VerificationError, 'Signature verification failed')
end

def set_key
def verify_algo
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a nice extraction!

raise(JWT::IncorrectAlgorithm, 'An algorithm must be specified') if allowed_algorithms.empty?
raise(JWT::IncorrectAlgorithm, 'Token is missing alg header') unless algorithm
raise(JWT::IncorrectAlgorithm, 'Expected a different algorithm') unless options_includes_algo_in_header?
end

def set_key
@key = find_key(&@keyfinder) if @keyfinder
@key = ::JWT::JWK::KeyFinder.new(jwks: @options[:jwks]).key_for(header['kid']) if @options[:jwks]
if (x5c_options = @options[:x5c])
@key = X5cKeyFinder.new(x5c_options[:root_certificates], x5c_options[:crls]).from(header['x5c'])
end
end

def verify_signature_for?(key)
Expand Down
55 changes: 55 additions & 0 deletions lib/jwt/x5c_key_finder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen_string_literal: true

require 'base64'
require 'jwt/error'

module JWT
# If the x5c header certificate chain can be validated by trusted root
# certificates, and none of the certificates are revoked, returns the public
# key from the first certificate.
# See https://tools.ietf.org/html/rfc7515#section-4.1.6
class X5cKeyFinder
def initialize(root_certificates, crls = nil)
raise(ArgumentError, 'Root certificates must be specified') unless root_certificates

@store = build_store(root_certificates, crls)
end

def from(x5c_header_or_certificates)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JWT::X5cKeyFinder#from has approx 6 statements

Read more about it here.

signing_certificate, *certificate_chain = parse_certificates(x5c_header_or_certificates)
store_context = OpenSSL::X509::StoreContext.new(@store, signing_certificate, certificate_chain)

if store_context.verify
signing_certificate.public_key
else
error = "Certificate verification failed: #{store_context.error_string}."
if (current_cert = store_context.current_cert)
error = "#{error} Certificate subject: #{current_cert.subject}."
end

raise(JWT::VerificationError, error)
end
end

private

def build_store(root_certificates, crls)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JWT::X5cKeyFinder#build_store doesn't depend on instance state (maybe move it to another class?)

Read more about it here.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JWT::X5cKeyFinder#build_store has approx 8 statements

Read more about it here.

store = OpenSSL::X509::Store.new
store.purpose = OpenSSL::X509::PURPOSE_ANY
store.flags = OpenSSL::X509::V_FLAG_CRL_CHECK | OpenSSL::X509::V_FLAG_CRL_CHECK_ALL
root_certificates.each { |certificate| store.add_cert(certificate) }
crls&.each { |crl| store.add_crl(crl) }

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JWT::X5cKeyFinder#build_store performs a nil-check

Read more about it here.

store
end

def parse_certificates(x5c_header_or_certificates)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JWT::X5cKeyFinder#parse_certificates doesn't depend on instance state (maybe move it to another class?)

Read more about it here.

if x5c_header_or_certificates.all? { |obj| obj.is_a?(OpenSSL::X509::Certificate) }
x5c_header_or_certificates
else
x5c_header_or_certificates.map do |encoded|
OpenSSL::X509::Certificate.new(::Base64.strict_decode64(encoded))
end
end
end
end
end
19 changes: 19 additions & 0 deletions spec/jwt_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -676,4 +676,23 @@
end
end
end

describe '::JWT.decode with x5c parameter' do
let(:alg) { "RS256" }
let(:root_certificates) { [instance_double('OpenSSL::X509::Certificate')] }
let(:key_finder) { instance_double('::JWT::X5cKeyFinder') }

before do
expect(::JWT::X5cKeyFinder).to receive(:new).with(root_certificates, nil).and_return(key_finder)
expect(key_finder).to receive(:from).and_return(data[:rsa_public])
end
subject(:decoded_token) { ::JWT.decode(data[alg], nil, true, algorithm: alg, x5c: { root_certificates: root_certificates }) }

it 'calls X5cKeyFinder#from to verify the signature and return the payload' do
jwt_payload, header = decoded_token

expect(header['alg']).to eq alg
expect(jwt_payload).to eq payload
end
end
end
203 changes: 203 additions & 0 deletions spec/x5c_key_finder_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# frozen_string_literal: true

require 'spec_helper'
require 'jwt/x5c_key_finder'

describe JWT::X5cKeyFinder do
let(:root_key) { OpenSSL::PKey.read(File.read(File.join(CERT_PATH, '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_serial) { 2 }
let(:leaf_not_after) { Time.now + 3600 }
let(:leaf_signing_key) { root_key }
let(:leaf_certificate) do
cert = generate_cert(
leaf_dn,
leaf_key.public_key,
leaf_serial,
issuer: root_certificate,
not_after: leaf_not_after
)
ef = OpenSSL::X509::ExtensionFactory.new
ef.config = OpenSSL::Config.parse(leaf_cdp)
ef.subject_certificate = cert
cert.add_extension(ef.create_extension('crlDistributionPoints', '@crlDistPts'))
cert.sign(leaf_signing_key, 'sha256')
cert
end
let(:leaf_cdp) { <<-_CNF_ }
[crlDistPts]
URI.1 = http://www.example.com/crl
_CNF_

let(:crl) { issue_crl([], issuer: root_certificate, issuer_key: root_key) }

let(:x5c_header) { [Base64.strict_encode64(leaf_certificate.to_der)] }
subject(:keyfinder) { described_class.new([root_certificate], [crl]).from(x5c_header) }

it 'returns the public key from a certificate that is signed by trusted roots and not revoked' 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 'already parsed certificates' do
let(:x5c_header) { [leaf_certificate] }

it 'returns the public key from a certificate that is signed by trusted roots and not revoked' do
expect(keyfinder).to be_a(OpenSSL::PKey::RSA)
expect(keyfinder.public_key.to_der).to eq(leaf_certificate.public_key.to_der)
end
end

context '::JWT.decode' do
let(:token_payload) { { 'data' => 'something' } }
let(:encoded_token) { JWT.encode(token_payload, leaf_key, 'RS256', { 'x5c' => x5c_header }) }
let(:decoded_payload) do
JWT.decode(encoded_token, nil, true, algorithms: ['RS256'], x5c: { root_certificates: [root_certificate], crls: [crl]}).first
end

it 'returns the encoded payload after successful certificate path verification' do
expect(decoded_payload).to eq(token_payload)
end
end

context 'certificate' do
context 'expired' do
let(:leaf_not_after) { Time.now - 3600 }

it 'raises an error' do
error = 'Certificate verification failed: certificate has expired. Certificate subject: /DC=org/DC=fake/CN=Fake.'
expect { keyfinder }.to raise_error(JWT::VerificationError, error)
end
end

context 'signature could not be verified with the given trusted roots' do
let(:leaf_signing_key) { generate_key }

it 'raises an error' do
error = 'Certificate verification failed: certificate signature failure. Certificate subject: /DC=org/DC=fake/CN=Fake.'
expect { keyfinder }.to raise_error(JWT::VerificationError, error)
end
end

context 'could not be chained to a trusted root certificate' do
context 'given an array' do
subject(:keyfinder) { described_class.new([], [crl]).from(x5c_header) }

it 'raises a verification error' do
error = 'Certificate verification failed: unable to get local issuer certificate. Certificate subject: /DC=org/DC=fake/CN=Fake.'
expect { keyfinder }.to raise_error(JWT::VerificationError, error)
end
end

context 'given nil' do
subject(:keyfinder) { described_class.new(nil, [crl]).from(x5c_header) }

it 'raises a decode error' do
error = 'Root certificates must be specified'
expect { keyfinder }.to raise_error(ArgumentError, error)
end
end
end

context 'revoked' do
let(:revocation) { [leaf_serial, Time.now - 60, 1] }
let(:crl) { issue_crl([revocation], issuer: root_certificate, issuer_key: root_key) }

it 'raises an error' do
error = 'Certificate verification failed: certificate revoked. Certificate subject: /DC=org/DC=fake/CN=Fake.'
expect { keyfinder }.to raise_error(JWT::VerificationError, error)
end
end
end

context 'CRL' do
context 'expired' do
let(:next_up) { Time.now - 60 }
let(:crl) { issue_crl([], next_up: next_up, issuer: root_certificate, issuer_key: root_key) }

it 'raises an error' do
error = 'Certificate verification failed: CRL has expired. Certificate subject: /DC=org/DC=fake/CN=Fake.'
expect { keyfinder }.to raise_error(JWT::VerificationError, error)
end
end

context 'signature could not be verified with the given trusted roots' do
let(:crl) { issue_crl([], issuer: root_certificate, issuer_key: generate_key) }

it 'raises an error' do
error = 'Certificate verification failed: CRL signature failure. Certificate subject: /DC=org/DC=fake/CN=Fake.'
expect { keyfinder }.to raise_error(JWT::VerificationError, error)
end
end

context 'not given' do
subject(:keyfinder) { described_class.new([root_certificate], nil).from(x5c_header) }

it 'raises an error' do
error = 'Certificate verification failed: unable to get certificate CRL. Certificate subject: /DC=org/DC=fake/CN=Fake.'
expect { keyfinder }.to raise_error(JWT::VerificationError, error)
end
end
end

private

def generate_key
OpenSSL::PKey::RSA.new(2048)
end

def generate_root_cert(root_dn, root_key)
cert = generate_cert(root_dn, root_key, 1)
ef = OpenSSL::X509::ExtensionFactory.new
cert.add_extension(ef.create_extension('basicConstraints', 'CA:TRUE', true))
cert.sign(root_key, 'sha256')
cert
end

def generate_cert(subject, key, serial, issuer: nil, not_after: nil)
cert = OpenSSL::X509::Certificate.new
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)
cert
end

def issue_crl(revocations, issuer:, issuer_key:, next_up: nil)
crl = OpenSSL::X509::CRL.new
crl.issuer = issuer.subject
crl.version = 1
now = Time.now
crl.last_update = now - 3600
crl.next_update = next_up || (now + 3600)

revocations.each do |rserial, time, reason_code|
revoked = build_revoked(rserial, time, reason_code)
crl.add_revoked(revoked)
end

crlnum = OpenSSL::ASN1::Integer(1)
crl.add_extension(OpenSSL::X509::Extension.new('crlNumber', crlnum))

crl.sign(issuer_key, 'sha256')
crl
end

def build_revoked(rserial, time, reason_code)
revoked = OpenSSL::X509::Revoked.new
revoked.serial = rserial
revoked.time = time
enum = OpenSSL::ASN1::Enumerated(reason_code)
ext = OpenSSL::X509::Extension.new('CRLReason', enum)
revoked.add_extension(ext)
revoked
end
end