Security Through Obesity
Jeremy Spilman recently proposed changes to how user’s hashes are stored in website’s and companies databases. This post was originally going to look at some of the issues involved in the scheme he envisioned, however, he rather quickly posted a followup article with a well thought out solution that countered all of the issues that other people and myself were able to come up with. I’d strongly recommend reading both if you haven’t done so. Instead of announcing flaws, I’m turning this into a post with a simple functional implementation of the described scheme in Ruby using DataMapper.
At first I’d like to point out that this is one of those few examples where a form of security through obscurity is actually increasing not only the perceived security but the cost to attack a system as well.
Please note this code is a minimal, functional, example and should not be used in production. It is missing a lot of things that I personally would add before attempting to use this but that is an exercise for the reader. It is licensed under the MIT license. I’ll walk through the code briefly afterwards going over some bits.
# encoding: utf-8
require "rubygems" # You only need this if you use bundler
require "dm-core"
require "dm-migrations"
require "dm-sqlite-adapter"
require "dm-validations"
require "scrypt"
DataMapper.setup :default, "sqlite:hash.db"
class User
include DataMapper::Resource
property :id, Serial
property :username, String, :required => true,
:unique => true
property :crypt_hash, String, :required => true,
:length => 64
property :salt, String, :required => true,
:length => 25
def check_password(plaintext_password)
encrypted_hash = scrypt_helper(plaintext_password, self.salt)
hash_obj = SiteHash.first(:crypt_hash => encrypted_hash)
if hash_obj.nil?
puts "Invalid password"
return false
end
verification_hash = scrypt_helper(plaintext_password, hash_obj.salt)
if self.crypt_hash == verification_hash
return true
else
puts "WARNING: Found matching hash, but verification failed."
return false
end
end
def password=(plaintext_password)
generate_salt
encrypted_password = SiteHash.new
encrypted_password.crypt_hash = scrypt_helper(plaintext_password,
self.salt)
encrypted_password.save
self.crypt_hash = scrypt_helper(plaintext_password,
encrypted_password.salt)
end
private
def generate_salt
self.salt = SCrypt::Engine.generate_salt(:max_time => 1.0)
end
def scrypt_helper(plaintext_password, salt)
SCrypt::Engine.scrypt(plaintext_password, salt,
SCrypt::Engine.autodetect_cost(salt),
32).unpack('H*').first
end
end
class SiteHash
include DataMapper::Resource
property :id, Serial
property :crypt_hash, String, :required => true,
:length => 64
property :salt, String, :required => true,
:length => 25
def initialize(*args)
super
generate_salt
end
private
def generate_salt
self.salt = SCrypt::Engine.generate_salt(:max_time => 1.0)
end
end
DataMapper.finalize
DataMapper.auto_upgrade!
I tried to keep this as a simple minimum implementation without playing golf. Strictly speaking the validations on the data_mapper models aren’t necessary and could have been removed, in this case, however, the length fields do actually indicate a bit more of what you might expect to see in the database, while the requires are just good habits living on.
Both of the two models are required to have both a salt and a hash, the name ‘crypt_hash’ was chosen do too a conflict with one of data_mapper’s reserved words ‘hash’, the same goes for the model name, however, that class comes from elsewhere. Raw scrypt’d hashes are 256 bits long or 64 hex characters long, while the salts are 64 bits (16 hex characters) plus some meta-data totaling 25 hex characters in this example.
Salts are hashes are computed by the ‘scrypt’ gem. In this example I’ve bumped up the max time option to create a hash from the default of 0.2 seconds up to 1 second. This is one of those things that I could have left out as the default is fine for an example, but it also couldn’t hurt slightly increasing it in case someone did copy-paste this into production.
The one thing that I’d like to point out is a couple of ‘puts’ statements I dropped in the check_password method on the User model. The first one simply announces an invalid password. A lot of these could indicate a brute force attack. The second one is more serious, it indicates that there is either a bug in the code, a hash collision has occurred, or an attacker has been able to drop in hash of their choosing into the site_hashes table, but haven’t updated the verification hash on the user model yet. I’d strongly recommend reading through both of Jeremy’s posts if you want to understand how this threat works and specifically the second post to see how the verification hash protects what it does.
So how would you use this code? Well you’d want to create a user with a password and then check if their password is valid or not later on like so:
User.create(:username => 'admin', :password => 'admin')
User.first(:username => 'admin').check_password('admin')
One of the key ways this separation increases the security of real users hashes is by having a large number of fake hashes in the hash table that the attackers will have to crack at the same time. As a bonus I’ve written a module to handle just that for the code I’ve already provided. Once again this is licensed under the MIT license and should not be considered production ready.
# This is the code above, you can also include everything below
# this in the same file if you're into that sort of thing
require "user_hash_example"
module HashFaker
def self.fast_hash
SiteHash.create(:crypt_hash => get_bytes(32))
end
def self.hash
SiteHash.create(:crypt_hash => scrypt_helper(get_bytes(24),
generate_salt))
end
def self.generate_hashes(count = 5000, fast = false)
count.times do
fast ? fast_hash : hash
end
end
private
def self.generate_salt
SCrypt::Engine.generate_salt(:max_time => 1.0)
end
def self.get_bytes(num)
OpenSSL::Random.random_bytes(num).unpack('H*').first
end
def self.scrypt_helper(plaintext_password, salt)
SCrypt::Engine.scrypt(plaintext_password, salt,
SCrypt::Engine.autodetect_cost(salt),
32).unpack('H*').first
end
end