Skip to content

Commit 4775b11

Browse files
authored
Allow point to be used as the verification key in ECDSA (#689)
* Convert points to ec pkeys * Handle legacy Rubies
1 parent d9266a2 commit 4775b11

File tree

5 files changed

+87
-64
lines changed

5 files changed

+87
-64
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
- Add support for x5t header parameter for X.509 certificate thumbprint verification [#669](https://github.com/jwt/ruby-jwt/pull/669) ([@hieuk09](https://github.com/hieuk09))
1010
- Raise an error if the ECDSA signing or verification key is not an instance of `OpenSSL::PKey::EC` [#688](https://github.com/jwt/ruby-jwt/pull/688) ([@anakinj](https://github.com/anakinj))
11+
- Allow `OpenSSL::PKey::EC::Point` to be used as the verification key in ECDSA [#689](https://github.com/jwt/ruby-jwt/pull/689) ([@anakinj](https://github.com/anakinj))
1112
- Your contribution here
1213

1314
**Fixes and enhancements:**

lib/jwt/jwa/ecdsa.rb

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ def initialize(alg, digest)
1212
end
1313

1414
def sign(data:, signing_key:)
15-
raise_sign_error!("The given key is a #{signing_key.class}. It has to be an OpenSSL::PKey::EC instance.") unless signing_key.is_a?(::OpenSSL::PKey::EC)
15+
raise_sign_error!("The given key is a #{signing_key.class}. It has to be an OpenSSL::PKey::EC instance") unless signing_key.is_a?(::OpenSSL::PKey::EC)
16+
raise_sign_error!('The given key is not a private key') unless signing_key.private?
1617

1718
curve_definition = curve_by_name(signing_key.group.curve_name)
1819
key_algorithm = curve_definition[:algorithm]
@@ -23,7 +24,9 @@ def sign(data:, signing_key:)
2324
end
2425

2526
def verify(data:, signature:, verification_key:)
26-
raise_verify_error!("The given key is a #{verification_key.class}. It has to be an OpenSSL::PKey::EC instance.") unless verification_key.is_a?(::OpenSSL::PKey::EC)
27+
verification_key = self.class.create_public_key_from_point(verification_key) if verification_key.is_a?(::OpenSSL::PKey::EC::Point)
28+
29+
raise_verify_error!("The given key is a #{verification_key.class}. It has to be an OpenSSL::PKey::EC instance") unless verification_key.is_a?(::OpenSSL::PKey::EC)
2730

2831
curve_definition = curve_by_name(verification_key.group.curve_name)
2932
key_algorithm = curve_definition[:algorithm]
@@ -67,6 +70,22 @@ def self.curve_by_name(name)
6770
end
6871
end
6972

73+
if ::JWT.openssl_3?
74+
def self.create_public_key_from_point(point)
75+
sequence = OpenSSL::ASN1::Sequence([
76+
OpenSSL::ASN1::Sequence([OpenSSL::ASN1::ObjectId('id-ecPublicKey'), OpenSSL::ASN1::ObjectId(point.group.curve_name)]),
77+
OpenSSL::ASN1::BitString(point.to_octet_string(:uncompressed))
78+
])
79+
OpenSSL::PKey::EC.new(sequence.to_der)
80+
end
81+
else
82+
def self.create_public_key_from_point(point)
83+
OpenSSL::PKey::EC.new(point.group.curve_name).tap do |key|
84+
key.public_key = point
85+
end
86+
end
87+
end
88+
7089
private
7190

7291
attr_reader :digest

lib/jwt/jwk/ec.rb

Lines changed: 44 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -136,67 +136,54 @@ def parse_ec_key(key)
136136
}.compact
137137
end
138138

139-
if ::JWT.openssl_3?
140-
def create_ec_key(jwk_crv, jwk_x, jwk_y, jwk_d) # rubocop:disable Metrics/MethodLength
141-
curve = EC.to_openssl_curve(jwk_crv)
142-
x_octets = decode_octets(jwk_x)
143-
y_octets = decode_octets(jwk_y)
144-
145-
point = OpenSSL::PKey::EC::Point.new(
146-
OpenSSL::PKey::EC::Group.new(curve),
147-
OpenSSL::BN.new([0x04, x_octets, y_octets].pack('Ca*a*'), 2)
148-
)
149-
150-
sequence = if jwk_d
151-
# https://datatracker.ietf.org/doc/html/rfc5915.html
152-
# ECPrivateKey ::= SEQUENCE {
153-
# version INTEGER { ecPrivkeyVer1(1) } (ecPrivkeyVer1),
154-
# privateKey OCTET STRING,
155-
# parameters [0] ECParameters {{ NamedCurve }} OPTIONAL,
156-
# publicKey [1] BIT STRING OPTIONAL
157-
# }
158-
159-
OpenSSL::ASN1::Sequence([
160-
OpenSSL::ASN1::Integer(1),
161-
OpenSSL::ASN1::OctetString(OpenSSL::BN.new(decode_octets(jwk_d), 2).to_s(2)),
162-
OpenSSL::ASN1::ObjectId(curve, 0, :EXPLICIT),
163-
OpenSSL::ASN1::BitString(point.to_octet_string(:uncompressed), 1, :EXPLICIT)
164-
])
165-
else
166-
OpenSSL::ASN1::Sequence([
167-
OpenSSL::ASN1::Sequence([OpenSSL::ASN1::ObjectId('id-ecPublicKey'), OpenSSL::ASN1::ObjectId(curve)]),
168-
OpenSSL::ASN1::BitString(point.to_octet_string(:uncompressed))
169-
])
170-
end
139+
def create_point(jwk_crv, jwk_x, jwk_y)
140+
curve = EC.to_openssl_curve(jwk_crv)
141+
x_octets = decode_octets(jwk_x)
142+
y_octets = decode_octets(jwk_y)
143+
144+
# The details of the `Point` instantiation are covered in:
145+
# - https://docs.ruby-lang.org/en/2.4.0/OpenSSL/PKey/EC.html
146+
# - https://www.openssl.org/docs/manmaster/man3/EC_POINT_new.html
147+
# - https://tools.ietf.org/html/rfc5480#section-2.2
148+
# - https://www.secg.org/SEC1-Ver-1.0.pdf
149+
# Section 2.3.3 of the last of these references specifies that the
150+
# encoding of an uncompressed point consists of the byte `0x04` followed
151+
# by the x value then the y value.
152+
OpenSSL::PKey::EC::Point.new(
153+
OpenSSL::PKey::EC::Group.new(curve),
154+
OpenSSL::BN.new([0x04, x_octets, y_octets].pack('Ca*a*'), 2)
155+
)
156+
end
171157

158+
if ::JWT.openssl_3?
159+
def create_ec_key(jwk_crv, jwk_x, jwk_y, jwk_d)
160+
point = create_point(jwk_crv, jwk_x, jwk_y)
161+
162+
return ::JWT::JWA::Ecdsa.create_public_key_from_point(point) unless jwk_d
163+
164+
# https://datatracker.ietf.org/doc/html/rfc5915.html
165+
# ECPrivateKey ::= SEQUENCE {
166+
# version INTEGER { ecPrivkeyVer1(1) } (ecPrivkeyVer1),
167+
# privateKey OCTET STRING,
168+
# parameters [0] ECParameters {{ NamedCurve }} OPTIONAL,
169+
# publicKey [1] BIT STRING OPTIONAL
170+
# }
171+
172+
sequence = OpenSSL::ASN1::Sequence([
173+
OpenSSL::ASN1::Integer(1),
174+
OpenSSL::ASN1::OctetString(OpenSSL::BN.new(decode_octets(jwk_d), 2).to_s(2)),
175+
OpenSSL::ASN1::ObjectId(point.group.curve_name, 0, :EXPLICIT),
176+
OpenSSL::ASN1::BitString(point.to_octet_string(:uncompressed), 1, :EXPLICIT)
177+
])
172178
OpenSSL::PKey::EC.new(sequence.to_der)
173179
end
174180
else
175181
def create_ec_key(jwk_crv, jwk_x, jwk_y, jwk_d)
176-
curve = EC.to_openssl_curve(jwk_crv)
177-
178-
x_octets = decode_octets(jwk_x)
179-
y_octets = decode_octets(jwk_y)
180-
181-
key = OpenSSL::PKey::EC.new(curve)
182-
183-
# The details of the `Point` instantiation are covered in:
184-
# - https://docs.ruby-lang.org/en/2.4.0/OpenSSL/PKey/EC.html
185-
# - https://www.openssl.org/docs/manmaster/man3/EC_POINT_new.html
186-
# - https://tools.ietf.org/html/rfc5480#section-2.2
187-
# - https://www.secg.org/SEC1-Ver-1.0.pdf
188-
# Section 2.3.3 of the last of these references specifies that the
189-
# encoding of an uncompressed point consists of the byte `0x04` followed
190-
# by the x value then the y value.
191-
point = OpenSSL::PKey::EC::Point.new(
192-
OpenSSL::PKey::EC::Group.new(curve),
193-
OpenSSL::BN.new([0x04, x_octets, y_octets].pack('Ca*a*'), 2)
194-
)
195-
196-
key.public_key = point
197-
key.private_key = OpenSSL::BN.new(decode_octets(jwk_d), 2) if jwk_d
198-
199-
key
182+
point = create_point(jwk_crv, jwk_x, jwk_y)
183+
184+
::JWT::JWA::Ecdsa.create_public_key_from_point(point).tap do |key|
185+
key.private_key = OpenSSL::BN.new(decode_octets(jwk_d), 2) if jwk_d
186+
end
200187
end
201188
end
202189

@@ -205,7 +192,7 @@ def decode_octets(base64_encoded_coordinate)
205192
# Some base64 encoders on some platform omit a single 0-byte at
206193
# the start of either Y or X coordinate of the elliptic curve point.
207194
# This leads to an encoding error when data is passed to OpenSSL BN.
208-
# It is know to have happend to exported JWKs on a Java application and
195+
# It is know to have happened to exported JWKs on a Java application and
209196
# on a Flutter/Dart application (both iOS and Android). All that is
210197
# needed to fix the problem is adding a leading 0-byte. We know the
211198
# required byte is 0 because with any other byte the point is no longer

spec/jwt/jwa/ecdsa_spec.rb

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,19 @@
3535
let(:ecdsa_key) { test_pkey('ec256-private.pem') }
3636
let(:data) { 'test data' }
3737
let(:instance) { described_class.new('ES256', 'sha256') }
38+
let(:signature) { instance.sign(data: data, signing_key: ecdsa_key) }
3839

3940
describe '#verify' do
4041
context 'when the verification key is valid' do
4142
it 'returns true for a valid signature' do
42-
signature = instance.sign(data: data, signing_key: ecdsa_key)
4343
expect(instance.verify(data: data, signature: signature, verification_key: ecdsa_key)).to be true
4444
end
4545

4646
it 'returns false for an invalid signature' do
4747
expect(instance.verify(data: data, signature: 'invalid_signature', verification_key: ecdsa_key)).to be false
4848
end
4949
end
50+
5051
context 'when verification results in a OpenSSL::PKey::PKeyError error' do
5152
it 'raises a JWT::VerificationError' do
5253
allow(ecdsa_key).to receive(:dsa_verify_asn1).and_raise(OpenSSL::PKey::PKeyError.new('Error'))
@@ -60,25 +61,40 @@
6061
it 'raises a JWT::DecodeError' do
6162
expect do
6263
instance.verify(data: data, signature: '', verification_key: 'not_a_key')
63-
end.to raise_error(JWT::DecodeError, 'The given key is a String. It has to be an OpenSSL::PKey::EC instance.')
64+
end.to raise_error(JWT::DecodeError, 'The given key is a String. It has to be an OpenSSL::PKey::EC instance')
65+
end
66+
end
67+
68+
context 'when the verification key is a point' do
69+
it 'verifies the signature' do
70+
expect(ecdsa_key.public_key).to be_a(OpenSSL::PKey::EC::Point)
71+
expect(instance.verify(data: data, signature: signature, verification_key: ecdsa_key.public_key)).to be(true)
6472
end
6573
end
6674
end
6775

6876
describe '#sign' do
6977
context 'when the signing key is valid' do
7078
it 'returns a valid signature' do
71-
signature = instance.sign(data: data, signing_key: ecdsa_key)
7279
expect(signature).to be_a(String)
7380
expect(signature.length).to be > 0
7481
end
7582
end
7683

84+
context 'when the signing key is a public key' do
85+
it 'raises a JWT::DecodeError' do
86+
public_key = test_pkey('ec256-public.pem')
87+
expect do
88+
instance.sign(data: data, signing_key: public_key)
89+
end.to raise_error(JWT::EncodeError, 'The given key is not a private key')
90+
end
91+
end
92+
7793
context 'when the signing key is not an OpenSSL::PKey::EC instance' do
7894
it 'raises a JWT::DecodeError' do
7995
expect do
8096
instance.sign(data: data, signing_key: 'not_a_key')
81-
end.to raise_error(JWT::EncodeError, 'The given key is a String. It has to be an OpenSSL::PKey::EC instance.')
97+
end.to raise_error(JWT::EncodeError, 'The given key is a String. It has to be an OpenSSL::PKey::EC instance')
8298
end
8399
end
84100

spec/jwt/jwk/decode_with_jwk_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@
169169

170170
it 'fails in some way' do
171171
expect { described_class.decode(signed_token, nil, true, algorithms: ['ES384'], jwks: jwks) }.to(
172-
raise_error(JWT::DecodeError, 'The given key is a String. It has to be an OpenSSL::PKey::EC instance.')
172+
raise_error(JWT::DecodeError, 'The given key is a String. It has to be an OpenSSL::PKey::EC instance')
173173
)
174174
end
175175
end

0 commit comments

Comments
 (0)