Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatic JWK enrichment #544

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/jwt/jwk.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ def create_from(key, params = nil, options = {})
end.new(key, params, options)
end

# Certificate to JWK
if key.is_a?(OpenSSL::X509::Certificate)
x5c = [::Base64.strict_encode64(key.to_der)]
return create_from(key.public_key, { x5c: x5c }, enrich_key: true)
end

mappings.fetch(key.class) do |klass|
raise JWT::JWKError, "Cannot create JWK from a #{klass.name}"
end.new(key, params, options)
Expand Down
79 changes: 79 additions & 0 deletions lib/jwt/jwk/key_base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ def initialize(options, params = {})
# Make sure the key has a kid
kid_generator = options[:kid_generator] || ::JWT.configuration.jwk.kid_generator
self[:kid] ||= kid_generator.new(self).generate

enrich_key(options) if options[:enrich_key]
end

def kid
Expand Down Expand Up @@ -50,6 +52,83 @@ def <=>(other)
private

attr_reader :parameters

KEY_USAGES_SIG = ['Digital Signature', 'Non Repudiation', 'Content Commitment', 'Key Cert Sign', 'CRL Sign'].freeze
Copy link
Member

Choose a reason for hiding this comment

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

Im wondering about where this certificate specific stuff should live. Now the logic baked into each and every JWK object created.

Most of these methods are just setting some values under certain keys? Could it just be a separate mechanism (method/extension) that would build the params passed into the key or add the params to an existing key?

KEY_USAGES_ENC = ['Key Encipherment', 'Data Encipherment', 'Key Agreement'].freeze
KEY_OPS_VERIFY = ['Digital Signature', 'Key Cert Sign', 'CRL Sign'].freeze
KEY_OPS_ENCRYPT = ['Data Encipherment'].freeze
KEY_OPS_WRAPKEY = ['Key Encipherment'].freeze

# Tries to derive additional key parameters from a certificate chain while maintaining semantic consistency
# Does not as of now validate the chain
def enrich_key(options)
certs = fetch_certificates(options)
if certs
add_thumbprints(certs.first)
add_key_operations(certs.first)
add_private_key_operations
add_usages(certs.first)
end
add_default_algorithm
end

# Try to find certificates. TODO: Suitably validate chain
def fetch_certificates(options)
certs = self[:x5c]&.map { |c| OpenSSL::X509::Certificate.new(::Base64.strict_decode64(c)) }
certs = options[:x5u_handler].call(self[:x5u]) if self[:x5u] && options[:x5u_handler]
certs if certs&.first
end

# Extract certificate key usages
def certificate_usages(certificate)
certificate.extensions&.find { |ext| ext.oid == 'keyUsage' }&.value&.split("\n")
end

# Set standard thumbprint parameters
def add_thumbprints(certificate)
self[:x5t] ||= ::Base64.urlsafe_encode64(OpenSSL::Digest.new('SHA1', certificate.to_der).to_s)
self[:'x5t#S256'] ||= ::Base64.urlsafe_encode64(OpenSSL::Digest.new('SHA256', certificate.to_der).to_s)
end

# Set standard use parameter
# C.t. RFC 5280, Section 4.2.1.3
# We do not care about encipherOnly and decipherOnly for the `use` param
def add_usages(certificate)
key_usages = certificate_usages(certificate)
self[:use] ||= 'sig' unless (KEY_USAGES_SIG & [*key_usages]).empty?
self[:use] ||= 'enc' unless (KEY_USAGES_ENC & [*key_usages]).empty?
end

# Tries to add a suitable key_ops parameter
def add_key_operations(certificate)
key_usages = certificate_usages(certificate)
self[:key_ops] ||= ['verify'] unless ([*key_usages] & KEY_OPS_VERIFY).empty? # sign
self[:key_ops] ||= ['encrypt'] unless ([*key_usages] & KEY_OPS_ENCRYPT).empty? # decrypt
self[:key_ops] ||= ['wrapKey'] unless ([*key_usages] & KEY_OPS_WRAPKEY).empty? # unwrapKey
end

# Adds the private counterpart to key operations for private keys
def add_private_key_operations
return unless private? && self[:key_ops]

self[:key_ops] << {
'verify' => 'sign',
'encrypt' => 'decrypt',
'wrapKey' => 'unwrapKey'
}[self[:key_ops].first]
self[:key_ops].uniq!
end

# Adds a default algorithm to each key, depending on the type.
def add_default_algorithm
return unless self[:use] == 'sig' # Only signing algorithms supported

self[:alg] = {
'RSA' => 'RS512',
'EC' => 'ES512',
'oct' => 'HS512'
}[self[:kty]]
end
end
end
end
82 changes: 82 additions & 0 deletions spec/jwk_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,19 @@
describe '.new' do
let(:options) { nil }
subject { described_class.new(keypair, options) }
let(:certificate) { certificate_unsigned.sign(keypair, OpenSSL::Digest.new('SHA256')) }
let(:certificate_unsigned) { certificate_base }
let(:certificate_base) {
cert = OpenSSL::X509::Certificate.new
cert.version = 2
cert.serial = 1
cert.subject = OpenSSL::X509::Name.parse '/CN=Test'
cert.issuer = cert.subject # Self-signed
cert.public_key = keypair.public_key
cert.not_before = Time.now
cert.not_after = cert.not_before + 3600 # 1h
cert
}

context 'when RSA key is given' do
let(:keypair) { rsa_key }
Expand All @@ -74,6 +87,28 @@
end
end

context 'when certificate is given' do
let(:keypair) { rsa_key }
let(:certificate_unsigned) {
ef = OpenSSL::X509::ExtensionFactory.new
certificate_base.add_extension(ef.create_extension('keyUsage', 'digitalSignature', true))
certificate_base
}
subject { described_class.new(certificate) }

it 'derives the correct key type' do
is_expected.to be_a ::JWT::JWK::RSA
end

it 'derives common parameters' do
expect(subject[:x5c]).not_to eq(nil)
expect(subject[:x5t]).not_to eq(nil)
expect(subject[:'x5t#S256']).not_to eq(nil)
expect(subject[:key_ops]).to eq(['verify'])
expect(subject[:use]).to eq('sig')
end
end

context 'when a common parameter is given' do
subject { described_class.new(keypair, params) }
let(:keypair) { rsa_key }
Expand All @@ -82,6 +117,53 @@
expect(subject[:use]).to eq('sig')
end
end

context 'when enrich_key is specified' do
subject { described_class.new(keypair, params, enrich_key: true) }
let(:keypair) { rsa_key }
let(:params) { { 'use' => 'sig' } }
it 'sets a suitable default alg header' do
expect(subject[:use]).not_to eq(nil)
end

context 'when given an X.509 certificate chain' do
let(:x5c) { [::Base64.strict_encode64(certificate.to_der)] }

context 'in the x5c header' do
subject { described_class.new(keypair, { x5c: x5c }, enrich_key: true) }
it 'adds the thumbprints' do
expect(subject[:x5t]).not_to eq(nil)
expect(subject[:'x5t#S256']).not_to eq(nil)
end
end

context 'in the x5u header' do
let(:cert_fetcher) { ->(url) { x5c.map { |c| OpenSSL::X509::Certificate.new(::Base64.strict_decode64(c)) } if url == 'https://example.org/certs' } }
subject { described_class.new(keypair, { x5u: 'https://example.org/certs' }, enrich_key: true, x5u_handler: cert_fetcher) }
it 'adds the thumbprints' do
expect(subject[:x5t]).not_to eq(nil)
expect(subject[:'x5t#S256']).not_to eq(nil)
end
end

context 'with keyUsage extension' do
let(:certificate_unsigned) {
ef = OpenSSL::X509::ExtensionFactory.new
certificate_base.add_extension(ef.create_extension('keyUsage', 'digitalSignature', true))
certificate_base
}
subject { described_class.new(keypair, { x5c: x5c }, enrich_key: true) }
it 'derives the correct key operations and usages' do
expect(subject[:key_ops]).to eq(['verify', 'sign'])
expect(subject[:use]).to eq('sig')
end

it 'sets a suitable default alg header' do
expect(subject[:use]).not_to eq(nil)
end
end
end
end
end

describe '.[]' do
Expand Down