Skip to content

Commit f6c93c7

Browse files
committed
HMAC using RbNaCL separated into own implementations.
- HMAC using OpenSSL (default) - HMAC with RbNaCl for keys under 32 chars (rbnacl < 6.0) - HMAC with RbNaCl (rbnacl >= 6.0)
1 parent 2c6fa02 commit f6c93c7

18 files changed

+301
-92
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ jobs:
3939
- gemfiles/standalone.gemfile
4040
- gemfiles/openssl.gemfile
4141
- gemfiles/rbnacl.gemfile
42+
- gemfiles/rbnacl-pre-6.gemfile
4243
experimental: [false]
4344
include:
4445
- os: ubuntu-22.04

Appraisals

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,9 @@ appraise 'openssl' do
99
end
1010

1111
appraise 'rbnacl' do
12-
gem 'rbnacl'
12+
gem 'rbnacl', '>= 6'
13+
end
14+
15+
appraise 'rbnacl-pre-6' do
16+
gem 'rbnacl', '< 6'
1317
end

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- Support custom algorithms by passing algorithm objects[#512](https://github.com/jwt/ruby-jwt/pull/512) ([@anakinj](https://github.com/anakinj)).
77
- Support descriptive (not key related) JWK parameters[#520](https://github.com/jwt/ruby-jwt/pull/520) ([@bellebaum](https://github.com/bellebaum)).
88
- Support for JSON Web Key Sets[#525](https://github.com/jwt/ruby-jwt/pull/525) ([@bellebaum](https://github.com/bellebaum)).
9+
- Support HMAC keys over 32 chars when using RbNaCl[#521](https://github.com/jwt/ruby-jwt/pull/521) ([@anakinj](https://github.com/anakinj)).
910
- Your contribution here
1011

1112
**Fixes and enhancements:**

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,9 @@ decoded_token = JWT.decode token, hmac_secret, true, { algorithm: 'HS256' }
9595
puts decoded_token
9696
```
9797

98-
Note: If [RbNaCl](https://github.com/cryptosphere/rbnacl) is loadable, ruby-jwt will use it for HMAC-SHA256, HMAC-SHA512-256, and HMAC-SHA512. RbNaCl enforces a maximum key size of 32 bytes for these algorithms.
98+
Note: If [RbNaCl](https://github.com/RubyCrypto/rbnacl) is loadable, ruby-jwt will use it for HMAC-SHA256, HMAC-SHA512-256, and HMAC-SHA512. RbNaCl prior to 6.0.0 only support a maximum key size of 32 bytes for these algorithms.
9999

100-
[RbNaCl](https://github.com/cryptosphere/rbnacl) requires
100+
[RbNaCl](https://github.com/RubyCrypto/rbnacl) requires
101101
[libsodium](https://github.com/jedisct1/libsodium), it can be installed
102102
on MacOS with `brew install libsodium`.
103103

@@ -159,7 +159,7 @@ In order to use this algorithm you need to add the `RbNaCl` gem to you `Gemfile`
159159
gem 'rbnacl'
160160
```
161161

162-
For more detailed installation instruction check the official [repository](https://github.com/cryptosphere/rbnacl) on GitHub.
162+
For more detailed installation instruction check the official [repository](https://github.com/RubyCrypto/rbnacl) on GitHub.
163163

164164
* ED25519
165165

gemfiles/rbnacl-pre-6.gemfile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# This file was generated by Appraisal
2+
3+
source "https://rubygems.org"
4+
5+
gem "rubocop", "< 1.32"
6+
gem "rbnacl", "< 6"
7+
8+
gemspec path: "../"

gemfiles/rbnacl.gemfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33
source "https://rubygems.org"
44

55
gem "rubocop", "< 1.32"
6-
gem "rbnacl"
6+
gem "rbnacl", ">= 6"
77

88
gemspec path: "../"

gemfiles/rbnacl_pre_6.gemfile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# This file was generated by Appraisal
2+
3+
source "https://rubygems.org"
4+
5+
gem "rubocop", "< 1.32"
6+
gem "rbnacl", "< 6"
7+
8+
gemspec path: "../"

lib/jwt/algos.rb

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,22 @@ module JWT
2121
module Algos
2222
extend self
2323

24-
ALGOS = [
25-
Algos::Hmac,
26-
Algos::Ecdsa,
27-
Algos::Rsa,
28-
Algos::Eddsa,
29-
Algos::Ps,
30-
Algos::None,
31-
Algos::Unsupported
32-
].freeze
24+
ALGOS = [Algos::Ecdsa,
25+
Algos::Rsa,
26+
Algos::Eddsa,
27+
Algos::Ps,
28+
Algos::None,
29+
Algos::Unsupported].tap do |l|
30+
if ::JWT.rbnacl_6_or_greater?
31+
require_relative 'algos/hmac_rbnacl'
32+
l.unshift(Algos::HmacRbNaCl)
33+
elsif ::JWT.rbnacl?
34+
require_relative 'algos/hmac_rbnacl_fixed'
35+
l.unshift(Algos::HmacRbNaClFixed)
36+
else
37+
l.unshift(Algos::Hmac)
38+
end
39+
end.freeze
3340

3441
def find(algorithm)
3542
indexed[algorithm && algorithm.downcase]

lib/jwt/algos/hmac.rb

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,38 +5,69 @@ module Algos
55
module Hmac
66
module_function
77

8-
SUPPORTED = %w[HS256 HS512256 HS384 HS512].freeze
8+
MAPPING = {
9+
'HS256' => OpenSSL::Digest::SHA256,
10+
'HS384' => OpenSSL::Digest::SHA384,
11+
'HS512' => OpenSSL::Digest::SHA512
12+
}.freeze
13+
14+
SUPPORTED = MAPPING.keys
915

1016
def sign(algorithm, msg, key)
1117
key ||= ''
12-
authenticator, padded_key = SecurityUtils.rbnacl_fixup(algorithm, key)
13-
if authenticator && padded_key
14-
authenticator.auth(padded_key, msg.encode('binary'))
15-
else
16-
begin
17-
OpenSSL::HMAC.digest(OpenSSL::Digest.new(algorithm.sub('HS', 'sha')), key, msg)
18-
rescue OpenSSL::HMACError => e
19-
if key == '' && e.message == 'EVP_PKEY_new_mac_key: malloc failure'
20-
raise JWT::DecodeError.new('OpenSSL 3.0 does not support nil or empty hmac_secret')
21-
end
22-
23-
raise e
24-
end
18+
19+
raise JWT::DecodeError, 'HMAC key expected to be a String' unless key.is_a?(String)
20+
21+
OpenSSL::HMAC.digest(MAPPING[algorithm].new, key, msg)
22+
rescue OpenSSL::HMACError => e
23+
if key == '' && e.message == 'EVP_PKEY_new_mac_key: malloc failure'
24+
raise JWT::DecodeError, 'OpenSSL 3.0 does not support nil or empty hmac_secret'
2525
end
26+
27+
raise e
28+
end
29+
30+
def verify(algorithm, key, signing_input, signature)
31+
SecurityUtils.secure_compare(signature, sign(algorithm, signing_input, key))
2632
end
2733

28-
def verify(algorithm, public_key, signing_input, signature)
29-
authenticator, padded_key = SecurityUtils.rbnacl_fixup(algorithm, public_key)
30-
if authenticator && padded_key
31-
begin
32-
authenticator.verify(padded_key, signature.encode('binary'), signing_input.encode('binary'))
33-
rescue RbNaCl::BadAuthenticatorError
34-
false
34+
# Copy of https://github.com/rails/rails/blob/v7.0.3.1/activesupport/lib/active_support/security_utils.rb
35+
# rubocop:disable Naming/MethodParameterName, Style/StringLiterals, Style/NumericPredicate
36+
module SecurityUtils
37+
# Constant time string comparison, for fixed length strings.
38+
#
39+
# The values compared should be of fixed length, such as strings
40+
# that have already been processed by HMAC. Raises in case of length mismatch.
41+
42+
if defined?(OpenSSL.fixed_length_secure_compare)
43+
def fixed_length_secure_compare(a, b)
44+
OpenSSL.fixed_length_secure_compare(a, b)
3545
end
3646
else
37-
SecurityUtils.secure_compare(signature, sign(algorithm, signing_input, public_key))
47+
def fixed_length_secure_compare(a, b)
48+
raise ArgumentError, "string length mismatch." unless a.bytesize == b.bytesize
49+
50+
l = a.unpack "C#{a.bytesize}"
51+
52+
res = 0
53+
b.each_byte { |byte| res |= byte ^ l.shift }
54+
res == 0
55+
end
56+
end
57+
module_function :fixed_length_secure_compare
58+
59+
# Secure string comparison for strings of variable length.
60+
#
61+
# While a timing attack would not be able to discern the content of
62+
# a secret compared via secure_compare, it is possible to determine
63+
# the secret length. This should be considered when using secure_compare
64+
# to compare weak, short secrets to user input.
65+
def secure_compare(a, b)
66+
a.bytesize == b.bytesize && fixed_length_secure_compare(a, b)
3867
end
68+
module_function :secure_compare
3969
end
70+
# rubocop:enable Naming/MethodParameterName, Style/StringLiterals, Style/NumericPredicate
4071
end
4172
end
4273
end

lib/jwt/algos/hmac_rbnacl.rb

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
3+
module JWT
4+
module Algos
5+
module HmacRbNaCl
6+
module_function
7+
8+
MAPPING = {
9+
'HS256' => ::RbNaCl::HMAC::SHA256,
10+
'HS512256' => ::RbNaCl::HMAC::SHA512256,
11+
'HS384' => nil,
12+
'HS512' => ::RbNaCl::HMAC::SHA512
13+
}.freeze
14+
15+
SUPPORTED = MAPPING.keys
16+
17+
def sign(algorithm, msg, key)
18+
if (hmac = resolve_algorithm(algorithm))
19+
hmac.auth(key_for_rbnacl(hmac, key).encode('binary'), msg.encode('binary'))
20+
else
21+
Hmac.sign(algorithm, msg, key)
22+
end
23+
end
24+
25+
def verify(algorithm, key, signing_input, signature)
26+
if (hmac = resolve_algorithm(algorithm))
27+
hmac.verify(key_for_rbnacl(hmac, key).encode('binary'), signature.encode('binary'), signing_input.encode('binary'))
28+
else
29+
Hmac.verify(algorithm, key, signing_input, signature)
30+
end
31+
rescue ::RbNaCl::BadAuthenticatorError
32+
false
33+
end
34+
35+
def key_for_rbnacl(hmac, key)
36+
key ||= ''
37+
raise JWT::DecodeError, 'HMAC key expected to be a String' unless key.is_a?(String)
38+
39+
return padded_empty_key(hmac.key_bytes) if key == ''
40+
41+
key
42+
end
43+
44+
def resolve_algorithm(algorithm)
45+
MAPPING.fetch(algorithm)
46+
end
47+
48+
def padded_empty_key(length)
49+
Array.new(length, 0x0).pack('C*').encode('binary')
50+
end
51+
end
52+
end
53+
end

0 commit comments

Comments
 (0)