Skip to content

Commit

Permalink
JWK OKP Ed25519 support using rbnacl
Browse files Browse the repository at this point in the history
 - Prepare to deprecate the ::JWT::JWK::[RSA/EC/HMAC]#keypair method in favor of methods describing the use
  • Loading branch information
anakinj committed Jan 31, 2023
1 parent fce8bbc commit d6ee141
Show file tree
Hide file tree
Showing 14 changed files with 377 additions and 55 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

**Features:**

- Support OKP (Ed25519) keys for JWKs [#540](https://github.com/jwt/ruby-jwt/pull/540) ([@anakinj](https://github.com/anakinj)).
- Your contribution here
- JWK Sets can now be used for tokens with nil kid[#543](https://github.com/jwt/ruby-jwt/pull/543) ([@bellebaum](https://github.com/bellebaum))

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,7 @@ end

### JSON Web Key (JWK)

JWK is a JSON structure representing a cryptographic key. This gem currently supports RSA, EC and HMAC keys.
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.

To encode a JWT using your JWK:

Expand All @@ -579,7 +579,7 @@ jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), optional_parameters)

# Encoding
payload = { data: 'data' }
token = JWT.encode(payload, jwk.keypair, jwk[:alg], kid: jwk[:kid])
token = JWT.encode(payload, jwk.signing_key, jwk[:alg], kid: jwk[:kid])

# JSON Web Key Set for advertising your signing keys
jwks_hash = JWT::JWK::Set.new(jwk).export
Expand Down Expand Up @@ -653,8 +653,8 @@ 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?
public_key = jwk.verify_key
private_key = jwk.signing_key if jwk.private?

# You can also import and export entire JSON Web Key Sets
jwks_hash = { keys: [{ kty: 'oct', k: 'my-secret', kid: 'my-kid' }] }
Expand Down
1 change: 1 addition & 0 deletions lib/jwt/jwk.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,4 @@ def generate_mappings
require_relative 'jwk/ec'
require_relative 'jwk/rsa'
require_relative 'jwk/hmac'
require_relative 'jwk/okp_rbnacl' if ::JWT.rbnacl?
35 changes: 24 additions & 11 deletions lib/jwt/jwk/ec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@
module JWT
module JWK
class EC < KeyBase # rubocop:disable Metrics/ClassLength
extend Forwardable
def_delegators :keypair, :public_key

KTY = 'EC'
KTYS = [KTY, OpenSSL::PKey::EC, JWT::JWK::EC].freeze
BINARY = 2
Expand All @@ -24,17 +21,29 @@ def initialize(key, params = nil, options = {})
key_params = extract_key_params(key)

params = params.transform_keys(&:to_sym)
check_jwk(key_params, params)
check_jwk_params!(key_params, params)

super(options, key_params.merge(params))
end

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

def private?
keypair.private_key?
ec_key.private_key?
end

def signing_key
ec_key
end

def verify_key
ec_key
end

def public_key
ec_key
end

def members
Expand All @@ -48,7 +57,7 @@ def export(options = {})
end

def key_digest
_crv, x_octets, y_octets = keypair_components(keypair)
_crv, x_octets, y_octets = keypair_components(ec_key)
sequence = OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer.new(OpenSSL::BN.new(x_octets, BINARY)),
OpenSSL::ASN1::Integer.new(OpenSSL::BN.new(y_octets, BINARY))])
OpenSSL::Digest::SHA256.hexdigest(sequence.to_der)
Expand All @@ -64,12 +73,16 @@ def []=(key, value)

private

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

def extract_key_params(key)
case key
when JWT::JWK::EC
key.export(include_private: true)
when OpenSSL::PKey::EC # Accept OpenSSL key as input
@keypair = key # Preserve the object to avoid recreation
@ec_key = key # Preserve the object to avoid recreation
parse_ec_key(key)
when Hash
key.transform_keys(&:to_sym)
Expand All @@ -78,10 +91,10 @@ def extract_key_params(key)
end
end

def check_jwk(keypair, params)
def check_jwk_params!(key_params, 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]
raise JWT::JWKError, "Incorrect 'kty' value: #{key_params[:kty]}, expected #{KTY}" unless key_params[:kty] == KTY
raise JWT::JWKError, 'Key format is invalid for EC' unless key_params[:crv] && key_params[:x] && key_params[:y]
end

def keypair_components(ec_keypair)
Expand Down
16 changes: 13 additions & 3 deletions lib/jwt/jwk/hmac.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def initialize(key, params = nil, options = {})
end

def keypair
self[:k]
secret
end

def private?
Expand All @@ -35,6 +35,14 @@ def public_key
nil
end

def verify_key
secret
end

def signing_key
secret
end

# See https://tools.ietf.org/html/rfc7517#appendix-A.3
def export(options = {})
exported = parameters.clone
Expand All @@ -46,8 +54,6 @@ def members
HMAC_KEY_ELEMENTS.each_with_object({}) { |i, h| h[i] = self[i] }
end

alias signing_key keypair # for backwards compatibility

def key_digest
sequence = OpenSSL::ASN1::Sequence([OpenSSL::ASN1::UTF8String.new(signing_key),
OpenSSL::ASN1::UTF8String.new(KTY)])
Expand All @@ -64,6 +70,10 @@ def []=(key, value)

private

def secret
self[:k]
end

def extract_key_params(key)
case key
when JWT::JWK::HMAC
Expand Down
2 changes: 1 addition & 1 deletion lib/jwt/jwk/key_finder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def key_for(kid)
raise ::JWT::DecodeError, 'No keys found in jwks' unless @jwks.any?
raise ::JWT::DecodeError, "Could not find public key for kid #{kid}" unless jwk

jwk.keypair
jwk.verify_key
end

private
Expand Down
110 changes: 110 additions & 0 deletions lib/jwt/jwk/okp_rbnacl.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# frozen_string_literal: true

module JWT
module JWK
class OKPRbNaCl < KeyBase
KTY = 'OKP'
KTYS = [KTY, JWT::JWK::OKPRbNaCl, RbNaCl::Signatures::Ed25519::SigningKey, RbNaCl::Signatures::Ed25519::VerifyKey].freeze
OKP_PUBLIC_KEY_ELEMENTS = %i[kty n x].freeze
OKP_PRIVATE_KEY_ELEMENTS = %i[d].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 = extract_key_params(key)

params = params.transform_keys(&:to_sym)
check_jwk_params!(key_params, params)
super(options, key_params.merge(params))
end

def verify_key
return @verify_key if defined?(@verify_key)

@verify_key = verify_key_from_parameters
end

def signing_key
return @signing_key if defined?(@signing_key)

@signing_key = signing_key_from_parameters
end

def key_digest
Thumbprint.new(self).to_s
end

def private?
!signing_key.nil?
end

def members
OKP_PUBLIC_KEY_ELEMENTS.each_with_object({}) { |i, h| h[i] = self[i] }
end

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

private

def extract_key_params(key)
case key
when JWT::JWK::KeyBase
key.export(include_private: true)
when RbNaCl::Signatures::Ed25519::SigningKey
@signing_key = key
@verify_key = key.verify_key
parse_okp_key_params(@verify_key, @signing_key)
when RbNaCl::Signatures::Ed25519::VerifyKey
@signing_key = nil
@verify_key = key
parse_okp_key_params(@verify_key)
when Hash
key.transform_keys(&:to_sym)
else
raise ArgumentError, 'key must be of type RbNaCl::Signatures::Ed25519::SigningKey, RbNaCl::Signatures::Ed25519::VerifyKey or Hash with key parameters'
end
end

def check_jwk_params!(key_params, _given_params)
raise JWT::JWKError, "Incorrect 'kty' value: #{key_params[:kty]}, expected #{KTY}" unless key_params[:kty] == KTY
end

def parse_okp_key_params(verify_key, signing_key = nil)
params = {
kty: KTY,
crv: 'Ed25519',
x: ::JWT::Base64.url_encode(verify_key.to_bytes)
}

if signing_key
params[:d] = ::JWT::Base64.url_encode(signing_key.to_bytes)
end

params
end

def verify_key_from_parameters
RbNaCl::Signatures::Ed25519::VerifyKey.new(::JWT::Base64.url_decode(self[:x]))
end

def signing_key_from_parameters
return nil unless self[:d]

RbNaCl::Signatures::Ed25519::SigningKey.new(::JWT::Base64.url_decode(self[:d]))
end

class << self
def import(jwk_data)
new(jwk_data)
end
end
end
end
end
28 changes: 20 additions & 8 deletions lib/jwt/jwk/rsa.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,29 @@ def initialize(key, params = nil, options = {})
key_params = extract_key_params(key)

params = params.transform_keys(&:to_sym)
check_jwk(key_params, params)
check_jwk_params!(key_params, params)

super(options, key_params.merge(params))
end

def keypair
@keypair ||= self.class.create_rsa_key(jwk_attributes(*(RSA_KEY_ELEMENTS - [:kty])))
rsa_key
end

def private?
keypair.private?
rsa_key.private?
end

def public_key
keypair.public_key
rsa_key.public_key
end

def signing_key
rsa_key if private?
end

def verify_key
rsa_key.public_key
end

def export(options = {})
Expand Down Expand Up @@ -65,12 +73,16 @@ def []=(key, value)

private

def rsa_key
@rsa_key ||= self.class.create_rsa_key(jwk_attributes(*(RSA_KEY_ELEMENTS - [:kty])))
end

def extract_key_params(key)
case key
when JWT::JWK::RSA
key.export(include_private: true)
when OpenSSL::PKey::RSA # Accept OpenSSL key as input
@keypair = key # Preserve the object to avoid recreation
@rsa_key = key # Preserve the object to avoid recreation
parse_rsa_key(key)
when Hash
key.transform_keys(&:to_sym)
Expand All @@ -79,10 +91,10 @@ def extract_key_params(key)
end
end

def check_jwk(keypair, params)
def check_jwk_params!(key_params, params)
raise ArgumentError, 'cannot overwrite cryptographic key attributes' unless (RSA_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 RSA' unless keypair[:n] && keypair[:e]
raise JWT::JWKError, "Incorrect 'kty' value: #{key_params[:kty]}, expected #{KTY}" unless key_params[:kty] == KTY
raise JWT::JWKError, 'Key format is invalid for RSA' unless key_params[:n] && key_params[:e]
end

def parse_rsa_key(key)
Expand Down

0 comments on commit d6ee141

Please sign in to comment.