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

Add hashed secure compare #280

Merged
merged 2 commits into from
Oct 28, 2019
Merged

Conversation

bdewater
Copy link
Contributor

Follow-up from the discussion over at #269 (comment) and 1ade643 where the Rails-like secure_compare naming was adopted. The debate around timing of hash functions made me realize why Rails' default of hashing the values to protect users from making mistakes is a good idea.

In 1ade643 the Rails-like secure_compare naming
was adopted and in original pull request introducing this functionality debate
around timing of hash functions followed. This made me realize why Rails'
default of hashing the values to protect users from making mistakes is a good
idea.
@ioquatix
Copy link
Member

@tenderlove @jeremyevans I am okay with this but since you have some background in this area, do you mind commenting?

@ioquatix
Copy link
Member

Thanks for this.

Since this is functionality in excess of OpenSSL, I'm on the fence as to whether it belongs here or not. Because we start implementing functionality and features that are layered on top of OpenSSL.

We also don't want to give users tools that are difficult to use correctly. So, I see the value in this.

Carrying on the previous discussion from #269

There are places where this this interface might actually be worse in practice.

Let's say you have some kind of IP address and/or user agent stored in a session, and that is considered secret.

The web server might receive the session id from a cookie, and then need to secure_compare the user agent strings. In order to avoid leaking the length or other details of the user agent strings, they are hashed first according to this function. Whether or not of practical use, some information is leaked about the secret in this process. You can't avoid it - a hash checksum is proportional to the size of the input to a certain extent.

Therefore, the more robust solution is to compute the hash of the user agent before saving it in the very first instance (non-hostile environment) and all subsequent checks are fixed length and therefore cannot possibly leak information.

Unfortunately, this interface encourages users to do the former, not the latter.

Thoughts?

@ioquatix
Copy link
Member

#!/usr/bin/env ruby

require 'digest/sha2'

class Secret
	def initialize(string)
		@secret = digest(string)
	end
	
	def digest(string)
		Digest::SHA256.digest(string)
	end
	
	def == hostile
		# OpenSSL.secure_compare(digest(hostile), @secret)
		digest(hostile) == @secret
	end
end

secret = Secret.new("Hello World!")

puts secret == "Hello World!"
puts secret == "Goodbye World!"

@bdewater
Copy link
Contributor Author

Thanks for your continuous feedback @ioquatix!

There are places where this this interface might actually be worse in practice.

fixed_length_secure_compare is still available for those who know what they're doing.

Therefore, the more robust solution is to compute the hash of the user agent before saving it in the very first instance (non-hostile environment) and all subsequent checks are fixed length and therefore cannot possibly leak information.

Double checking if I understand you correctly by rewording it in another example: my API is secured with a key supplied via an environment variable. You're saying it's more secure to hash that key once during app boot, instead of doing it every time the controller code is called?

I agree that's better in theory, but let's take a look at the benchmarks one more time - I forgot to post the comparison results in the previous PR. The difference in practice is very small with hashing:

readme example, same string lengths:       445628.5 i/s
readme example, small difference length:   436656.1 i/s - same-ish: difference falls within error
readme example, large difference length:   433763.4 i/s - same-ish: difference falls within error

Note that "large difference length" is the exact same length (32 bytes) of a prehashed SHA-256 digest. And we're working in a garbage collected language too so who knows when the GC kicks in in production 😄

While the direct comparison without hashing shows an order of magnitude difference:

direct comparison, same string lengths:   12001413.5 i/s
direct comparison, double length:           734025.4 i/s - 16.35x  slower
direct comparison, small difference length: 732699.2 i/s - 16.38x  slower

If there's only one thing someone is going to remember, I think this is a good default.

@ioquatix
Copy link
Member

ioquatix commented Oct 27, 2019

The real proof is whether you can write code, given everything except the secret string, can determine the length of the secret string.

Something like:

#!/usr/bin/env ruby

require 'benchmark'
require 'digest/sha2'

LETTERS = ('a'..'z').to_a

@secret = rand(0..250).times.map{LETTERS.sample}.join

puts "Secret: #@secret (#{@secret.bytesize} bytes)"

def digest(string)
	Digest::SHA256.digest(string)
end

def test(hostile)
	digest(hostile)
	digest(@secret)
end

def attack
	timings = []
	
	base_case = Benchmark.measure do
		10000.times do
			test("")
		end
	end.utime
	
	puts "Empty case: #{base_case}s"
	
	250.times do |length|
		puts "Checking #{length}..."
		
		string = "." * length
		
		duration = Benchmark.measure do
			10000.times do
				test(string)
			end
		end.utime
		
		if duration >= (base_case*1.1)
			puts "Duration: #{duration} **"
		else
			puts "Duration: #{duration}"
		end
	end
end

attack

If we can show an attack against the digest approach, then it's instant failure.

But if we can't show any attack, it might be feasible.

That's my advice right now.

@ioquatix
Copy link
Member

The other question is, do we about leaking length of secret string. Maybe it's irrelevant. I'm okay if we accept that position. It just needs to be clearly documented.

@ioquatix
Copy link
Member

direct comparison, same string lengths:   12001413.5 i/s
direct comparison, double length:           734025.4 i/s - 16.35x  slower
direct comparison, small difference length: 732699.2 i/s - 16.38x  slower

Doesn't make sense to me, is this using the constant time string comparison? It doesn't support different string lengths?

@jeremyevans
Copy link
Contributor

I don't think leaking the size of the secret is a major issue. Either the secret is large enough to make brute force attacks impractical, or it is small enough that it is vulnerable to brute force attacks anyway and trying brute force attacks on a smaller keyspace (fewer characters) first would not result in a significant slowdown.

That being said, I don't really like the fact that OpenSSL.secure_compare operates differently than Rack::Utils.secure_compare and raises an ArgumentError for different string lengths. So if we don't want to drop the ArgumentError being raised, hashing before compare seems reasonable.

lib/openssl.rb Outdated
# Constant time memory comparison. Inputs are hashed using SHA-256 to prevent
# leaking the length of the secret. Returns +true+ if the strings are
# identical, +false+ otherwise.
def secure_compare(a, b)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason why we can't just use def self.secure_compare? rather than module_function.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

module_function also makes it a private instance method, so that if someone does include OpenSSL, then they call secure_compare (instead of OpenSSL.secure_compare). FWIW, Rack::Utils.secure_compare also uses module_function.

@ioquatix
Copy link
Member

Okay based on that feedback, I think this is a pretty reasonable implementation. Happy to merge. Just one minor feedback regarding the module_function.

secure_compare is for user input, fixed_length_secure_compare for already processed data that is known to have the same length
@bdewater
Copy link
Contributor Author

If we can show an attack against the digest approach, then it's instant failure.
But if we can't show any attack, it might be feasible.

&

direct comparison, same string lengths:   12001413.5 i/s
direct comparison, double length:           734025.4 i/s - 16.35x  slower
direct comparison, small difference length: 732699.2 i/s - 16.38x  slower

Doesn't make sense to me, is this using the constant time string comparison? It doesn't support different string lengths?

Yes, it uses what this PR now calls fixed_length_secure_compare but rescues the ArgumentError similar to how a web application's exception handler would. By hashing the inputs the operation is running more in constant time than without, meaning an attacker gets less useful information to go on.

That being said, I don't really like the fact that OpenSSL.secure_compare operates differently than Rack::Utils.secure_compare and raises an ArgumentError for different string lengths.

Rack can still return early (as it does today) before calling fixed_length_secure_compare to keep that behaviour :)

Just one minor feedback regarding the module_function.

Adressed 👍

@ioquatix ioquatix merged commit 308fb19 into ruby:master Oct 28, 2019
@ioquatix
Copy link
Member

LGTM.

@bdewater bdewater deleted the fixed_length_secure_compare branch October 29, 2019 03:45
zekth pushed a commit to standard-webhooks/standard-webhooks that referenced this pull request Oct 7, 2024
These are some ruby code style adjustments, to make the code base better
with how
code is written in the ruby community.

There should be zero functional changes in here, it's just white space
and variable naming.

There are also a few non-functional code changes I could suggest, such
as another way to define private constants, a few more changes to the
gemspec file and adding a minimum ruby version (I'd suggest 3.1 or 3.0
as the minimum[1]).

However, I'll leave that for another PR, assuming you are open to such
changes.

There is also both `secure_compare` and `fixed_with_secure_compare` in
the Ruby OpenSSL library[2]. The difference is that the former will hash
to ensure fixed length. But if both have fixed length, then there is no
need.

What's the case here? Are we certain the two are fixed and identical
length already? Otherwise we should consider switching to
`secure_compare`.

(note that this is already what the 'fallback' implementation guards
against: `raise ArgumentError, "string length mismatch." unless
a.bytesize == b.bytesize`)


Fixes #158

[1] #158
[2] ruby/openssl#280

---------

Co-authored-by: sandstrom <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

3 participants