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 OKP Ed25519 support using rbnacl #540

Merged
merged 1 commit into from
Jan 31, 2023
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 @@ -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

**Fixes and enhancements:**
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 @@ -650,8 +650,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 @@ -21,7 +21,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
Loading