Skip to content

Commit

Permalink
HMAC using RbNaCL separated into own implementations.
Browse files Browse the repository at this point in the history
- HMAC using OpenSSL (default)
- HMAC with RbNaCl for keys under 32 chars (rbnacl < 6.0)
- HMAC with RbNaCl (rbnacl >= 6.0)
  • Loading branch information
anakinj committed Nov 12, 2022
1 parent 2c6fa02 commit f6c93c7
Show file tree
Hide file tree
Showing 18 changed files with 301 additions and 92 deletions.
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ jobs:
- gemfiles/standalone.gemfile
- gemfiles/openssl.gemfile
- gemfiles/rbnacl.gemfile
- gemfiles/rbnacl-pre-6.gemfile
experimental: [false]
include:
- os: ubuntu-22.04
Expand Down
6 changes: 5 additions & 1 deletion Appraisals
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,9 @@ appraise 'openssl' do
end

appraise 'rbnacl' do
gem 'rbnacl'
gem 'rbnacl', '>= 6'
end

appraise 'rbnacl-pre-6' do
gem 'rbnacl', '< 6'
end
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- Support custom algorithms by passing algorithm objects[#512](https://github.com/jwt/ruby-jwt/pull/512) ([@anakinj](https://github.com/anakinj)).
- Support descriptive (not key related) JWK parameters[#520](https://github.com/jwt/ruby-jwt/pull/520) ([@bellebaum](https://github.com/bellebaum)).
- Support for JSON Web Key Sets[#525](https://github.com/jwt/ruby-jwt/pull/525) ([@bellebaum](https://github.com/bellebaum)).
- Support HMAC keys over 32 chars when using RbNaCl[#521](https://github.com/jwt/ruby-jwt/pull/521) ([@anakinj](https://github.com/anakinj)).
- Your contribution here

**Fixes and enhancements:**
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,9 @@ decoded_token = JWT.decode token, hmac_secret, true, { algorithm: 'HS256' }
puts decoded_token
```

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.
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.

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

Expand Down Expand Up @@ -159,7 +159,7 @@ In order to use this algorithm you need to add the `RbNaCl` gem to you `Gemfile`
gem 'rbnacl'
```

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

* ED25519

Expand Down
8 changes: 8 additions & 0 deletions gemfiles/rbnacl-pre-6.gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# This file was generated by Appraisal

source "https://rubygems.org"

gem "rubocop", "< 1.32"
gem "rbnacl", "< 6"

gemspec path: "../"
2 changes: 1 addition & 1 deletion gemfiles/rbnacl.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
source "https://rubygems.org"

gem "rubocop", "< 1.32"
gem "rbnacl"
gem "rbnacl", ">= 6"

gemspec path: "../"
8 changes: 8 additions & 0 deletions gemfiles/rbnacl_pre_6.gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# This file was generated by Appraisal

source "https://rubygems.org"

gem "rubocop", "< 1.32"
gem "rbnacl", "< 6"

gemspec path: "../"
25 changes: 16 additions & 9 deletions lib/jwt/algos.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,22 @@ module JWT
module Algos
extend self

ALGOS = [
Algos::Hmac,
Algos::Ecdsa,
Algos::Rsa,
Algos::Eddsa,
Algos::Ps,
Algos::None,
Algos::Unsupported
].freeze
ALGOS = [Algos::Ecdsa,
Algos::Rsa,
Algos::Eddsa,
Algos::Ps,
Algos::None,
Algos::Unsupported].tap do |l|
if ::JWT.rbnacl_6_or_greater?
require_relative 'algos/hmac_rbnacl'
l.unshift(Algos::HmacRbNaCl)
elsif ::JWT.rbnacl?
require_relative 'algos/hmac_rbnacl_fixed'
l.unshift(Algos::HmacRbNaClFixed)
else
l.unshift(Algos::Hmac)
end
end.freeze

def find(algorithm)
indexed[algorithm && algorithm.downcase]
Expand Down
75 changes: 53 additions & 22 deletions lib/jwt/algos/hmac.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,38 +5,69 @@ module Algos
module Hmac
module_function

SUPPORTED = %w[HS256 HS512256 HS384 HS512].freeze
MAPPING = {
'HS256' => OpenSSL::Digest::SHA256,
'HS384' => OpenSSL::Digest::SHA384,
'HS512' => OpenSSL::Digest::SHA512
}.freeze

SUPPORTED = MAPPING.keys

def sign(algorithm, msg, key)
key ||= ''
authenticator, padded_key = SecurityUtils.rbnacl_fixup(algorithm, key)
if authenticator && padded_key
authenticator.auth(padded_key, msg.encode('binary'))
else
begin
OpenSSL::HMAC.digest(OpenSSL::Digest.new(algorithm.sub('HS', 'sha')), key, msg)
rescue OpenSSL::HMACError => e
if key == '' && e.message == 'EVP_PKEY_new_mac_key: malloc failure'
raise JWT::DecodeError.new('OpenSSL 3.0 does not support nil or empty hmac_secret')
end

raise e
end

raise JWT::DecodeError, 'HMAC key expected to be a String' unless key.is_a?(String)

OpenSSL::HMAC.digest(MAPPING[algorithm].new, key, msg)
rescue OpenSSL::HMACError => e
if key == '' && e.message == 'EVP_PKEY_new_mac_key: malloc failure'
raise JWT::DecodeError, 'OpenSSL 3.0 does not support nil or empty hmac_secret'
end

raise e
end

def verify(algorithm, key, signing_input, signature)
SecurityUtils.secure_compare(signature, sign(algorithm, signing_input, key))
end

def verify(algorithm, public_key, signing_input, signature)
authenticator, padded_key = SecurityUtils.rbnacl_fixup(algorithm, public_key)
if authenticator && padded_key
begin
authenticator.verify(padded_key, signature.encode('binary'), signing_input.encode('binary'))
rescue RbNaCl::BadAuthenticatorError
false
# Copy of https://github.com/rails/rails/blob/v7.0.3.1/activesupport/lib/active_support/security_utils.rb
# rubocop:disable Naming/MethodParameterName, Style/StringLiterals, Style/NumericPredicate
module SecurityUtils
# Constant time string comparison, for fixed length strings.
#
# The values compared should be of fixed length, such as strings
# that have already been processed by HMAC. Raises in case of length mismatch.

if defined?(OpenSSL.fixed_length_secure_compare)
def fixed_length_secure_compare(a, b)
OpenSSL.fixed_length_secure_compare(a, b)
end
else
SecurityUtils.secure_compare(signature, sign(algorithm, signing_input, public_key))
def fixed_length_secure_compare(a, b)
raise ArgumentError, "string length mismatch." unless a.bytesize == b.bytesize

l = a.unpack "C#{a.bytesize}"

res = 0
b.each_byte { |byte| res |= byte ^ l.shift }
res == 0
end
end
module_function :fixed_length_secure_compare

# Secure string comparison for strings of variable length.
#
# While a timing attack would not be able to discern the content of
# a secret compared via secure_compare, it is possible to determine
# the secret length. This should be considered when using secure_compare
# to compare weak, short secrets to user input.
def secure_compare(a, b)
a.bytesize == b.bytesize && fixed_length_secure_compare(a, b)
end
module_function :secure_compare
end
# rubocop:enable Naming/MethodParameterName, Style/StringLiterals, Style/NumericPredicate
end
end
end
53 changes: 53 additions & 0 deletions lib/jwt/algos/hmac_rbnacl.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# frozen_string_literal: true

module JWT
module Algos
module HmacRbNaCl
module_function

MAPPING = {
'HS256' => ::RbNaCl::HMAC::SHA256,
'HS512256' => ::RbNaCl::HMAC::SHA512256,
'HS384' => nil,
'HS512' => ::RbNaCl::HMAC::SHA512
}.freeze

SUPPORTED = MAPPING.keys

def sign(algorithm, msg, key)
if (hmac = resolve_algorithm(algorithm))
hmac.auth(key_for_rbnacl(hmac, key).encode('binary'), msg.encode('binary'))
else
Hmac.sign(algorithm, msg, key)
end
end

def verify(algorithm, key, signing_input, signature)
if (hmac = resolve_algorithm(algorithm))
hmac.verify(key_for_rbnacl(hmac, key).encode('binary'), signature.encode('binary'), signing_input.encode('binary'))
else
Hmac.verify(algorithm, key, signing_input, signature)
end
rescue ::RbNaCl::BadAuthenticatorError
false
end

def key_for_rbnacl(hmac, key)
key ||= ''
raise JWT::DecodeError, 'HMAC key expected to be a String' unless key.is_a?(String)

return padded_empty_key(hmac.key_bytes) if key == ''

key
end

def resolve_algorithm(algorithm)
MAPPING.fetch(algorithm)
end

def padded_empty_key(length)
Array.new(length, 0x0).pack('C*').encode('binary')
end
end
end
end
52 changes: 52 additions & 0 deletions lib/jwt/algos/hmac_rbnacl_fixed.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

module JWT
module Algos
module HmacRbNaClFixed
module_function

MAPPING = {
'HS256' => ::RbNaCl::HMAC::SHA256,
'HS512256' => ::RbNaCl::HMAC::SHA512256,
'HS384' => nil,
'HS512' => ::RbNaCl::HMAC::SHA512
}.freeze

SUPPORTED = MAPPING.keys

def sign(algorithm, msg, key)
key ||= ''

raise JWT::DecodeError, 'HMAC key expected to be a String' unless key.is_a?(String)

if (hmac = resolve_algorithm(algorithm)) && key.bytesize <= hmac.key_bytes
hmac.auth(padded_key_bytes(key, hmac.key_bytes), msg.encode('binary'))
else
Hmac.sign(algorithm, msg, key)
end
end

def verify(algorithm, key, signing_input, signature)
key ||= ''

raise JWT::DecodeError, 'HMAC key expected to be a String' unless key.is_a?(String)

if (hmac = resolve_algorithm(algorithm)) && key.bytesize <= hmac.key_bytes
hmac.verify(padded_key_bytes(key, hmac.key_bytes), signature.encode('binary'), signing_input.encode('binary'))
else
Hmac.verify(algorithm, key, signing_input, signature)
end
rescue ::RbNaCl::BadAuthenticatorError
false
end

def resolve_algorithm(algorithm)
MAPPING.fetch(algorithm)
end

def padded_key_bytes(key, bytesize)
key.bytes.fill(0, key.bytesize...bytesize).pack('C*')
end
end
end
end
27 changes: 0 additions & 27 deletions lib/jwt/security_utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,6 @@ module JWT
module SecurityUtils
module_function

def secure_compare(left, right)
left_bytesize = left.bytesize

return false unless left_bytesize == right.bytesize

unpacked_left = left.unpack "C#{left_bytesize}"
result = 0
right.each_byte { |byte| result |= byte ^ unpacked_left.shift }
result.zero?
end

def verify_rsa(algorithm, public_key, signing_input, signature)
public_key.verify(OpenSSL::Digest.new(algorithm.sub('RS', 'sha')), signature, signing_input)
end
Expand All @@ -39,21 +28,5 @@ def raw_to_asn1(signature, private_key)
sig_char = signature[byte_size..-1] || ''
OpenSSL::ASN1::Sequence.new([sig_bytes, sig_char].map { |int| OpenSSL::ASN1::Integer.new(OpenSSL::BN.new(int, 2)) }).to_der
end

def rbnacl_fixup(algorithm, key)
algorithm = algorithm.sub('HS', 'SHA').to_sym

return [] unless defined?(RbNaCl) && RbNaCl::HMAC.constants(false).include?(algorithm)

authenticator = RbNaCl::HMAC.const_get(algorithm)

# Fall back to OpenSSL for keys larger than 32 bytes.
return [] if key.bytesize > authenticator.key_bytes

[
authenticator,
key.bytes.fill(0, key.bytesize...authenticator.key_bytes).pack('C*')
]
end
end
end
8 changes: 8 additions & 0 deletions lib/jwt/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ def self.openssl_3?
return true if OpenSSL::OPENSSL_VERSION_NUMBER >= 3 * 0x10000000
end

def self.rbnacl?
defined?(::RbNaCl)
end

def self.rbnacl_6_or_greater?
rbnacl? && ::Gem::Version.new(::RbNaCl::VERSION) >= ::Gem::Version.new('6.0.0')
end

def self.openssl_3_hmac_empty_key_regression?
openssl_3? && openssl_version <= ::Gem::Version.new('3.0.0')
end
Expand Down
Loading

0 comments on commit f6c93c7

Please sign in to comment.