From c3a9fe178a084d5dbf01832cb04af41d881cf4d0 Mon Sep 17 00:00:00 2001 From: Didactic Drunk <1479616+didactic-drunk@users.noreply.github.com> Date: Thu, 12 Sep 2019 22:28:49 -0700 Subject: [PATCH] Sodium::Pwhash create keys based on time cost. Add #craete_key and #craete_kdf. --- spec/sodium/pwhash_spec.cr | 17 +++++ src/sodium/pwhash.cr | 124 ++++++++++++++++++++++++++++++++----- 2 files changed, 124 insertions(+), 17 deletions(-) diff --git a/spec/sodium/pwhash_spec.cr b/spec/sodium/pwhash_spec.cr index f09040f..4663f0a 100644 --- a/spec/sodium/pwhash_spec.cr +++ b/spec/sodium/pwhash_spec.cr @@ -98,6 +98,23 @@ describe Sodium::Pwhash do kdf = pwhash.derive_kdf salt, "foo", 32 end + it "creates a key and sets parameters by time" do + pwhash = pw_min + tcost = 0.2 + pwhash.tcost = tcost + salt = pwhash.random_salt + key, pwcreate = pwhash.create_key salt, "foo", 32 + + pwcreate.memlimit.should be > pwhash.memlimit + pwcreate.opslimit.should be > pwhash.opslimit + + ts = Time.measure do + key.should eq pwcreate.derive_key(salt, "foo", 32) + end + # ts should be within +|- 10%. allow up to 20% + (ts.to_f - tcost).abs.should be < (tcost * 0.2) + end + it "PyNaCl key vectors" do test_vectors "modular_crypt_argon2i_hashes.json", Sodium::Pwhash::Mode::Argon2i13 test_vectors "modular_crypt_argon2id_hashes.json", Sodium::Pwhash::Mode::Argon2id13 diff --git a/src/sodium/pwhash.cr b/src/sodium/pwhash.cr index f3378e7..c75c083 100644 --- a/src/sodium/pwhash.cr +++ b/src/sodium/pwhash.cr @@ -31,7 +31,7 @@ module Sodium Argon2id13 = 2 # The currently recommended algorithm, which can change from one version of libsodium to another. - def default + def self.default Mode.new LibSodium.crypto_pwhash_alg_default end end @@ -40,10 +40,21 @@ module Sodium # Specified in bytes. property memlimit = MEMLIMIT_INTERACTIVE + # Only used by create_key. + # Specified in seconds. + property tcost = 0.1 + # Only used by create_key. + property memlimit_min = MEMLIMIT_MIN + # Only used by create_key. + # Specified in bytes. + # defaults to 256M. + # TODO: defaults to 1/4 RAM (not swap). + property memlimit_max : UInt64 = 256_u64 * 1024 * 1024 + # Used by and must be set before calling #derive_key property mode : Mode? - # Apply the most recent password hashing algorithm agains a password. + # Apply the most recent password hashing algorithm against a password. # Returns a opaque String which includes: # * the result of a memory-hard, CPU-intensive hash function applied to the password # * the automatically generated salt used for the previous computation @@ -69,6 +80,7 @@ module Sodium self end + # Check if a password verification string str matches the parameters opslimit and memlimit, and the current default algorithm. def needs_rehash?(str) : Bool # BUG: verify str length case LibSodium.crypto_pwhash_str_needs_rehash(str, @opslimit, @memlimit) @@ -81,30 +93,108 @@ module Sodium end end - # Returns a consistent key based on [salt, pass, key_bytes, mode, ops_limit, mem_limit] in a SecureBuffer - # - # Must set a mode before calling. + # def set_params(secs, *, min_mem = MEMLIMIT_MIN, max_mem = 256*1024*1024) + # end + def derive_key(salt, pass, key_bytes) derive_key salt.to_slice, pass.to_slice, key_bytes end - def derive_key(salt : Bytes, pass : Bytes, key_bytes) : SecureBuffer + # Returns a consistent key based on [salt, pass, key_bytes, mode, ops_limit, mem_limit] in a SecureBuffer + # + # Must set a mode before calling. + def derive_key(salt : Bytes | String, pass : Bytes | String, key_bytes) : SecureBuffer raise "salt expected #{SALT_SIZE} bytes, got #{salt.bytesize} " if salt.bytesize != SALT_SIZE + m = mode || raise ArgumentError.new("mode not set") - if m = mode - key = SecureBuffer.new key_bytes - if LibSodium.crypto_pwhash(key.to_slice, key.bytesize, pass, pass.bytesize, salt, @opslimit, @memlimit, m) != 0 - raise Sodium::Error.new("crypto_pwhash_str") - end - key - else - raise ArgumentError.new("mode not set") + key = SecureBuffer.new key_bytes + derive_key key, m, salt, pass + key.readonly + end + + private def derive_key(key : SecureBuffer, m : Mode, salt : Bytes | String, pass : Bytes | String) : Nil + if LibSodium.crypto_pwhash(key.to_slice, key.bytesize, pass.to_slice, pass.bytesize, salt.to_slice, @opslimit, @memlimit, m) != 0 + raise Sodium::Error.new("crypto_pwhash") end end - # Derives a key using derive_key and returns KDF.new(key) - def derive_kdf(salt, pass, key_bytes) : Kdf - key = derive_key salt, pass, key_bytes + private def time_derive_key(key, m, salt, pass) + # TODO: switch to CPU time + ts = Time.measure do + derive_key key, m, salt, pass + end + ts + end + + # Returns a consistent key based on [salt, pass, key_bytes, mode] in a SecureBuffer **and** a new `Pwhash` with new params. + # Params on the new `Pwhash` are set to run in approximately `tcost` seconds. + # Make sure you store `mode`, `opslimit` and `memlimit` for later use with #derive_key. + # `Mode` has #to_s and #from_s for use with configuration files or databases. + def create_key(salt : Bytes | String, pass : Bytes | String, key_bytes) : {SecureBuffer, self} + pw = dup + key = pw.create_key! salt, pass, key_bytes + {key, pw} + end + + # :nodoc: + def create_key!(salt : Bytes | String, pass : Bytes | String, key_bytes) : SecureBuffer + m = self.mode ||= Mode.default + + @opslimit = OPSLIMIT_MIN + @memlimit = MEMLIMIT_MIN + + key = SecureBuffer.new key_bytes + + nsamples = 10 + samples = nsamples.times.map do + ts = time_derive_key key, m, salt, pass + end.to_a + mean = samples.sum / nsamples + return key.readonly if mean.to_f >= @tcost + + # initial sample to avoid overshooting on busy systems + # round to nearest pow2 / 3 + mult = Math.pw2ceil ((@tcost / 3.0 / mean.to_f).ceil.to_i) + @memlimit = (@memlimit * mult).clamp(@memlimit_min, @memlimit_max) + + last_memlimit = @memlimit + while @memlimit != @memlimit_max + ts = time_derive_key key, m, salt, pass + # tcost exceeded by memlimit_min + return key.readonly if ts.to_f >= @tcost * 0.9 + # / 3 to keep rounds > 1 mitigating attacks against argon with a low number of rounds + break if ts.to_f >= (@tcost / 3.0) * 0.9 + last_memlimit = @memlimit + # increments of 1K for compatibility with other libraries. + @memlimit = (((@memlimit / 1024).to_f * Math.max(1.1, (@tcost / ts.to_f / 3.0))).ceil.to_u64 * 1024).clamp(@memlimit_min, @memlimit_max) + # stopped making progress + break if @memlimit == last_memlimit + end + + last_opslimit = @opslimit + loop do + ts = time_derive_key key, m, salt, pass + # 90% is close enough + break if ts.to_f >= @tcost * 0.90 + last_opslimit = @opslimit + @opslimit = (@opslimit.to_f * Math.max(1.1, (@tcost / ts.to_f))).ceil.to_u64 + # stopped making progress + break if @opslimit == last_opslimit + end + + key.readonly + end + + # Creates a key using create_key and returns `{ KDF.new(key), Pwhash }` + # See #create_key for more details. + def create_kdf(salt, pass, key_bytes) : {Kdf, self} + key, pwhash = create_key salt.to_slice, pass.to_slice, key_bytes + {Kdf.new(key), pwhash} + end + + # Derives a key using derive_key and returns `KDF.new(key)` + def derive_kdf(salt, pass, key_bytes) + key = derive_key salt.to_slice, pass.to_slice, key_bytes Kdf.new key end