Skip to content

Commit

Permalink
Rebase JWT::JWK::EC and add private key export
Browse files Browse the repository at this point in the history
Updates `JWT::JWK::EC` to extend `KeyBase` and support other conventions
like the `JWT::JWK::KTYS` array.

Adds support for `#export(include_private: true)` to `JWT::JWK::EC` and
updates tests to match the new behavior.
  • Loading branch information
richardlarocque committed Nov 30, 2020
1 parent fce0cd7 commit 17967f1
Show file tree
Hide file tree
Showing 3 changed files with 61 additions and 26 deletions.
31 changes: 20 additions & 11 deletions lib/jwt/jwk/ec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,44 @@

module JWT
module JWK
class EC
class EC < KeyBase
extend Forwardable
def_delegators :@keypair, :private?, :public_key

attr_reader :keypair
attr_reader :kid

KTY = 'EC'.freeze
KTYS = [KTY, OpenSSL::PKey::EC].freeze
BINARY = 2

def initialize(keypair, kid = nil)
raise ArgumentError, 'keypair must be of type OpenSSL::PKey::EC' unless keypair.is_a?(OpenSSL::PKey::EC)

@keypair = keypair
@kid = kid || generate_kid(@keypair)
kid = kid || generate_kid(keypair)
super(keypair, kid)
end

def export
def export(options = {})
crv, x_octets, y_octets = keypair_components(keypair)
{
exported_hash = {
kty: KTY,
crv: crv,
x: encode_octets(x_octets),
y: encode_octets(y_octets),
kid: kid
}
return exported_hash unless private? && options[:include_private] == true

append_private_parts(exported_hash)
end

private

def append_private_parts(the_hash)
octets = keypair.private_key.to_bn.to_s(BINARY)
the_hash.merge(
d: encode_octets(octets)
)
end

def generate_kid(ec_keypair)
_crv, x_octets, y_octets = keypair_components(ec_keypair)
sequence = OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer.new(OpenSSL::BN.new(x_octets, BINARY)),
Expand Down Expand Up @@ -70,10 +78,10 @@ def import(jwk_data)
# See https://tools.ietf.org/html/rfc7518#section-6.2.1 for an
# explanation of the relevant parameters.

jwk_crv, jwk_x, jwk_y, jwk_kid = jwk_attrs(jwk_data, %i[crv x y kid])
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

new(ec_pkey(jwk_crv, jwk_x, jwk_y), jwk_kid)
new(ec_pkey(jwk_crv, jwk_x, jwk_y, jwk_d), jwk_kid)
end

def to_openssl_curve(crv)
Expand All @@ -96,7 +104,7 @@ def jwk_attrs(jwk_data, attrs)
end
end

def ec_pkey(jwk_crv, jwk_x, jwk_y)
def ec_pkey(jwk_crv, jwk_x, jwk_y, jwk_d)
curve = to_openssl_curve(jwk_crv)

x_octets = decode_octets(jwk_x)
Expand All @@ -118,6 +126,7 @@ def ec_pkey(jwk_crv, jwk_x, jwk_y)
)

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

key
end
Expand Down
50 changes: 35 additions & 15 deletions spec/jwk/ec_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
it 'returns a hash with the public parts of the key' do
expect(subject).to be_a Hash
expect(subject).to include(:kty, :kid, :x, :y)

# Don't include private `d` if not explicitly requested.
expect(subject).not_to include(:d)
end

Expand All @@ -58,29 +60,45 @@
end
end
end

context 'when private key is requested' do
subject { described_class.new(keypair).export(include_private: true) }
let(:keypair) { ec_key }
it 'returns a hash with the both parts of the key' do
expect(subject).to be_a Hash
expect(subject).to include(:kty, :kid, :x, :y)

# `d` is the private part.
expect(subject).to include(:d)
end
end
end

describe '.import' do
subject { described_class.import(params) }
let(:exported_key) { described_class.new(keypair).export }
let(:include_private) { false }
let(:exported_key) { described_class.new(keypair).export(include_private: include_private) }

['P-256', 'P-384', 'P-521'].each do |crv|
context "when crv=#{crv}" do
let(:openssl_curve) { JWT::JWK::EC.to_openssl_curve(crv) }
let(:ec_key) { OpenSSL::PKey::EC.new(openssl_curve).generate_key! }

context 'when keypair is private' do
let(:include_private) { true }
let(:keypair) { ec_key }
let(:params) { exported_key }

it 'returns a public key' do
# Exporting a key exports only the public parts, so the reimported
# key becomes public. This is odd, but this behavior is consistent
# with the traditional behavior of the RSA JWK tokens.
## expect(subject.private?).to eq true

it 'returns a private key' do
expect(subject.private?).to eq true
expect(subject).to be_a described_class
expect(subject.export).to eq(exported_key)

# Regular export returns only the non-private parts.
public_only = exported_key.select{ |k, v| k != :d }
expect(subject.export).to eq(public_only)

# Private export returns the original input.
expect(subject.export(include_private: true)).to eq(exported_key)
end

context 'with a custom "kid" value' do
Expand All @@ -93,14 +111,16 @@
end
end

context 'returns a public key' do
let(:keypair) { ec_key.tap { |x| x.private_key = nil } }
let(:params) { exported_key }
context 'when keypair is public' do
context 'returns a public key' do
let(:keypair) { ec_key.tap { |x| x.private_key = nil } }
let(:params) { exported_key }

it 'returns a hash with the public parts of the key' do
expect(subject).to be_a described_class
expect(subject.private?).to eq false
expect(subject.export).to eq(exported_key)
it 'returns a hash with the public parts of the key' do
expect(subject).to be_a described_class
expect(subject.private?).to eq false
expect(subject.export).to eq(exported_key)
end
end
end
end
Expand Down
6 changes: 6 additions & 0 deletions spec/jwk_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

describe JWT::JWK do
let(:rsa_key) { OpenSSL::PKey::RSA.new(2048) }
let(:ec_key) { OpenSSL::PKey::EC.new("secp384r1").generate_key! }

describe '.import' do
let(:keypair) { rsa_key.public_key }
Expand Down Expand Up @@ -54,5 +55,10 @@
let(:keypair) { 'secret-key' }
it { is_expected.to be_a ::JWT::JWK::HMAC }
end

context 'when EC key is given' do
let(:keypair) { ec_key }
it { is_expected.to be_a ::JWT::JWK::EC }
end
end
end

0 comments on commit 17967f1

Please sign in to comment.