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

JWK Common Parameters #520

Merged
merged 16 commits into from
Oct 14, 2022
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
**Features:**

- Support custom algorithms by passing algorithm objects[#512](https://github.com/jwt/ruby-jwt/pull/512) ([@anakinj](https://github.com/anakinj)).
- Support descriptive (not key related) JWK parameters[#520](https://github.com/jwt/ruby-jwt/pull/520) ([@bellebaum](https://github.com/bellebaum)).
- Your contribution here

**Fixes and enhancements:**
Expand Down
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,7 @@ JWK is a JSON structure representing a cryptographic key. Currently only support
If the 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`. The application can choose to implement some kind of JWK cache invalidation or other mechanism to handle such cases.

```ruby
jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), 'optional-kid')
jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), kid: 'optional-kid')
payload = { data: 'data' }
headers = { kid: jwk.kid }

Expand Down Expand Up @@ -612,13 +612,27 @@ JWT.decode(token, nil, true, { algorithms: ['RS512'], jwks: jwks})

### Importing and exporting JSON Web Keys

The ::JWT::JWK class can be used to import and export both the public key (default behaviour) and the private key. To include the private key in the export pass the `include_private` parameter to the export method.
The ::JWT::JWK class can be used to import both JSON Web Keys and OpenSSL keys
and export to either format with and without the private key included.

To include the private key in the export pass the `include_private` parameter to the export method.

```ruby
jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048))
# Import a JWK Hash (showing an HMAC example)
jwk = JWT::JWK.new({ kty: 'oct', k: 'my-secret', kid: 'my-kid' })

# Import an OpenSSL key
# You can optionally add descriptive parameters to the JWK
desc_params = { kid: 'my-kid', use: 'sig' }
jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), desc_params)

# Export as JWK Hash (public key only by default)
jwk_hash = jwk.export
jwk_hash_with_private_key = jwk.export(include_private: true)

# Export as OpenSSL key
public_key = jwk.public_key
private_key = jwk.keypair if jwk.private?
```

### Key ID (kid) and JWKs
Expand All @@ -630,7 +644,7 @@ JWT.configuration.jwk.kid_generator_type = :rfc7638_thumbprint
# OR
JWT.configuration.jwk.kid_generator = ::JWT::JWK::Thumbprint
# OR
jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), kid_generator: ::JWT::JWK::Thumbprint)
jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), nil, kid_generator: ::JWT::JWK::Thumbprint)

jwk_hash = jwk.export

Expand Down
23 changes: 12 additions & 11 deletions lib/jwt/jwk.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,19 @@
module JWT
module JWK
class << self
def import(jwk_data)
jwk_kty = jwk_data[:kty] || jwk_data['kty']
raise JWT::JWKError, 'Key type (kty) not provided' unless jwk_kty

mappings.fetch(jwk_kty.to_s) do |kty|
raise JWT::JWKError, "Key type #{kty} not supported"
end.import(jwk_data)
end
def create_from(key, params = nil, options = {})
if key.is_a?(Hash)
jwk_kty = key[:kty] || key['kty']
raise JWT::JWKError, 'Key type (kty) not provided' unless jwk_kty

return mappings.fetch(jwk_kty.to_s) do |kty|
raise JWT::JWKError, "Key type #{kty} not supported"
end.new(key, params, options)
end

def create_from(keypair, kid = nil)
mappings.fetch(keypair.class) do |klass|
mappings.fetch(key.class) do |klass|
raise JWT::JWKError, "Cannot create JWK from a #{klass.name}"
end.new(keypair, kid)
end.new(key, params, options)
end

def classes
Expand All @@ -26,6 +26,7 @@ def classes
end

alias new create_from
alias import create_from

private

Expand Down
242 changes: 130 additions & 112 deletions lib/jwt/jwk/ec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,37 +11,48 @@ class EC < KeyBase # rubocop:disable Metrics/ClassLength
KTY = 'EC'
KTYS = [KTY, OpenSSL::PKey::EC].freeze
BINARY = 2
EC_PUBLIC_KEY_ELEMENTS = %i[kty crv x y].freeze
EC_PRIVATE_KEY_ELEMENTS = %i[d].freeze
EC_KEY_ELEMENTS = (EC_PRIVATE_KEY_ELEMENTS + EC_PUBLIC_KEY_ELEMENTS).freeze

def initialize(key, params = nil, options = {})
params ||= {}

# For backwards compatibility when kid was a String
params = { kid: params } if params.is_a?(String)

key_params = case key
when OpenSSL::PKey::EC # Accept OpenSSL key as input
@keypair = key # Preserve the object to avoid recreation
parse_ec_key(key)
when Hash
key.transform_keys(&:to_sym)
else
raise ArgumentError, 'key must be of type OpenSSL::PKey::EC or Hash with key parameters'
end

attr_reader :keypair

def initialize(keypair, options = {})
raise ArgumentError, 'keypair must be of type OpenSSL::PKey::EC' unless keypair.is_a?(OpenSSL::PKey::EC)
params = params.transform_keys(&:to_sym)
check_jwk(key_params, params)

@keypair = keypair
super(options, key_params.merge(params))
end

super(options)
def keypair
@keypair ||= create_ec_key(self[:crv], self[:x], self[:y], self[:d])
end

def private?
@keypair.private_key?
keypair.private_key?
end

def members
crv, x_octets, y_octets = keypair_components(keypair)
{
kty: KTY,
crv: crv,
x: encode_octets(x_octets),
y: encode_octets(y_octets)
}
EC_PUBLIC_KEY_ELEMENTS.each_with_object({}) { |i, h| h[i] = self[i] }
end

def export(options = {})
exported_hash = members.merge(kid: kid)

return exported_hash unless private? && options[:include_private] == true

append_private_parts(exported_hash)
exported = parameters.clone
exported.reject! { |k, _| EC_PRIVATE_KEY_ELEMENTS.include? k } unless private? && options[:include_private] == true
exported
end

def key_digest
Expand All @@ -51,13 +62,20 @@ def key_digest
OpenSSL::Digest::SHA256.hexdigest(sequence.to_der)
end

def []=(key, value)
if EC_KEY_ELEMENTS.include?(key.to_sym)
raise ArgumentError, 'cannot overwrite cryptographic key attributes'
end

super(key, value)
end

private

def append_private_parts(the_hash)
octets = keypair.private_key.to_bn.to_s(BINARY)
the_hash.merge(
d: encode_octets(octets)
)
def check_jwk(keypair, params)
raise ArgumentError, 'cannot overwrite cryptographic key attributes' unless (EC_KEY_ELEMENTS & params.keys).empty?
raise JWT::JWKError, "Incorrect 'kty' value: #{keypair[:kty]}, expected #{KTY}" unless keypair[:kty] == KTY
raise JWT::JWKError, 'Key format is invalid for EC' unless keypair[:crv] && keypair[:x] && keypair[:y]
end

def keypair_components(ec_keypair)
Expand All @@ -82,22 +100,103 @@ def keypair_components(ec_keypair)
end

def encode_octets(octets)
return unless octets

::JWT::Base64.url_encode(octets)
end

def encode_open_ssl_bn(key_part)
::JWT::Base64.url_encode(key_part.to_s(BINARY))
end

class << self
def import(jwk_data)
# See https://tools.ietf.org/html/rfc7518#section-6.2.1 for an
# explanation of the relevant parameters.
def parse_ec_key(key)
crv, x_octets, y_octets = keypair_components(key)
octets = key.private_key&.to_bn&.to_s(BINARY)
{
kty: KTY,
crv: crv,
x: encode_octets(x_octets),
y: encode_octets(y_octets),
d: encode_octets(octets)
}.compact
end

jwk_crv, jwk_x, jwk_y, jwk_d, jwk_kid = jwk_attrs(jwk_data, %i[crv x y d kid])
raise JWT::JWKError, 'Key format is invalid for EC' unless jwk_crv && jwk_x && jwk_y
if ::JWT.openssl_3?
def create_ec_key(jwk_crv, jwk_x, jwk_y, jwk_d) # rubocop:disable Metrics/MethodLength
curve = EC.to_openssl_curve(jwk_crv)

x_octets = decode_octets(jwk_x)
y_octets = decode_octets(jwk_y)

point = OpenSSL::PKey::EC::Point.new(
OpenSSL::PKey::EC::Group.new(curve),
OpenSSL::BN.new([0x04, x_octets, y_octets].pack('Ca*a*'), 2)
)

sequence = if jwk_d
# https://datatracker.ietf.org/doc/html/rfc5915.html
# ECPrivateKey ::= SEQUENCE {
# version INTEGER { ecPrivkeyVer1(1) } (ecPrivkeyVer1),
# privateKey OCTET STRING,
# parameters [0] ECParameters {{ NamedCurve }} OPTIONAL,
# publicKey [1] BIT STRING OPTIONAL
# }

OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::Integer(1),
OpenSSL::ASN1::OctetString(OpenSSL::BN.new(decode_octets(jwk_d), 2).to_s(2)),
OpenSSL::ASN1::ObjectId(curve, 0, :EXPLICIT),
OpenSSL::ASN1::BitString(point.to_octet_string(:uncompressed), 1, :EXPLICIT)
])
else
OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::Sequence([OpenSSL::ASN1::ObjectId('id-ecPublicKey'), OpenSSL::ASN1::ObjectId(curve)]),
OpenSSL::ASN1::BitString(point.to_octet_string(:uncompressed))
])
end

new(ec_pkey(jwk_crv, jwk_x, jwk_y, jwk_d), kid: jwk_kid)
OpenSSL::PKey::EC.new(sequence.to_der)
end
else
def create_ec_key(jwk_crv, jwk_x, jwk_y, jwk_d)
curve = EC.to_openssl_curve(jwk_crv)

x_octets = decode_octets(jwk_x)
y_octets = decode_octets(jwk_y)

key = OpenSSL::PKey::EC.new(curve)

# The details of the `Point` instantiation are covered in:
# - https://docs.ruby-lang.org/en/2.4.0/OpenSSL/PKey/EC.html
# - https://www.openssl.org/docs/manmaster/man3/EC_POINT_new.html
# - https://tools.ietf.org/html/rfc5480#section-2.2
# - https://www.secg.org/SEC1-Ver-1.0.pdf
# Section 2.3.3 of the last of these references specifies that the
# encoding of an uncompressed point consists of the byte `0x04` followed
# by the x value then the y value.
point = OpenSSL::PKey::EC::Point.new(
OpenSSL::PKey::EC::Group.new(curve),
OpenSSL::BN.new([0x04, x_octets, y_octets].pack('Ca*a*'), 2)
)

key.public_key = point
key.private_key = OpenSSL::BN.new(decode_octets(jwk_d), 2) if jwk_d

key
end
end

def decode_octets(jwk_data)
::JWT::Base64.url_decode(jwk_data)
end

def decode_open_ssl_bn(jwk_data)
OpenSSL::BN.new(::JWT::Base64.url_decode(jwk_data), BINARY)
end

class << self
def import(jwk_data)
new(jwk_data)
end

def to_openssl_curve(crv)
Expand All @@ -112,87 +211,6 @@ def to_openssl_curve(crv)
else raise JWT::JWKError, 'Invalid curve provided'
end
end

private

def jwk_attrs(jwk_data, attrs)
bellebaum marked this conversation as resolved.
Show resolved Hide resolved
attrs.map do |attr|
jwk_data[attr] || jwk_data[attr.to_s]
end
end

if ::JWT.openssl_3?
def ec_pkey(jwk_crv, jwk_x, jwk_y, jwk_d) # rubocop:disable Metrics/MethodLength
curve = to_openssl_curve(jwk_crv)

x_octets = decode_octets(jwk_x)
y_octets = decode_octets(jwk_y)

point = OpenSSL::PKey::EC::Point.new(
OpenSSL::PKey::EC::Group.new(curve),
OpenSSL::BN.new([0x04, x_octets, y_octets].pack('Ca*a*'), 2)
)

sequence = if jwk_d
# https://datatracker.ietf.org/doc/html/rfc5915.html
# ECPrivateKey ::= SEQUENCE {
# version INTEGER { ecPrivkeyVer1(1) } (ecPrivkeyVer1),
# privateKey OCTET STRING,
# parameters [0] ECParameters {{ NamedCurve }} OPTIONAL,
# publicKey [1] BIT STRING OPTIONAL
# }

OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::Integer(1),
OpenSSL::ASN1::OctetString(OpenSSL::BN.new(decode_octets(jwk_d), 2).to_s(2)),
OpenSSL::ASN1::ObjectId(curve, 0, :EXPLICIT),
OpenSSL::ASN1::BitString(point.to_octet_string(:uncompressed), 1, :EXPLICIT)
])
else
OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::Sequence([OpenSSL::ASN1::ObjectId('id-ecPublicKey'), OpenSSL::ASN1::ObjectId(curve)]),
OpenSSL::ASN1::BitString(point.to_octet_string(:uncompressed))
])
end

OpenSSL::PKey::EC.new(sequence.to_der)
end
else
def ec_pkey(jwk_crv, jwk_x, jwk_y, jwk_d)
curve = to_openssl_curve(jwk_crv)

x_octets = decode_octets(jwk_x)
y_octets = decode_octets(jwk_y)

key = OpenSSL::PKey::EC.new(curve)

# The details of the `Point` instantiation are covered in:
# - https://docs.ruby-lang.org/en/2.4.0/OpenSSL/PKey/EC.html
# - https://www.openssl.org/docs/manmaster/man3/EC_POINT_new.html
# - https://tools.ietf.org/html/rfc5480#section-2.2
# - https://www.secg.org/SEC1-Ver-1.0.pdf
# Section 2.3.3 of the last of these references specifies that the
# encoding of an uncompressed point consists of the byte `0x04` followed
# by the x value then the y value.
point = OpenSSL::PKey::EC::Point.new(
OpenSSL::PKey::EC::Group.new(curve),
OpenSSL::BN.new([0x04, x_octets, y_octets].pack('Ca*a*'), 2)
)

key.public_key = point
key.private_key = OpenSSL::BN.new(decode_octets(jwk_d), 2) if jwk_d

key
end
end

def decode_octets(jwk_data)
::JWT::Base64.url_decode(jwk_data)
end

def decode_open_ssl_bn(jwk_data)
OpenSSL::BN.new(::JWT::Base64.url_decode(jwk_data), BINARY)
end
end
end
end
Expand Down
Loading