Module: Cybertrain::Password

Defined in:
cybertrain/password.rb

Overview

Hashes and verifies passwords (PBKDF2-HMAC-SHA256, salted, 100,000 iterations by default) for a model’s password_digest column. cybertrain generate authentication writes the User model that calls it; use it directly for any other credential.

A digest is a String of 130 or so characters ($pbkdf2-sha256$i=...$...$...) that records its own salt and iteration count, so Password.verify? works on digests made with other settings and Password.needs_rehash? says when one is behind the current ones. One hash costs about 0.2 s compiled (100,000 iterations) on a 2026 cloud core; raise ITERATIONS with Cybertrain::Password.iterations = ... in config/app.rb when the server has the budget (600,000 is OWASP’s 2023 figure, about a second). In the test environment the default is TEST_ITERATIONS, so test suites do not pay that cost per user they create. The loop is interruptible Ruby (a safepoint per iteration), so other requests keep being served while a login hashes; a login flow calls Password.verify_serialized so that a burst of attempts occupies one worker at most, and Password.dummy_digest when the account does not exist.

Examples:

digest = Cybertrain::Password.hash("correct horse battery staple")
Cybertrain::Password.verify?("correct horse battery staple", digest)   # => true
Cybertrain::Password.verify?("wrong", digest)                          # => false

Constant Summary collapse

ITERATIONS =

The default iteration count hash uses, and the one needs_rehash? compares against.

100_000
TEST_ITERATIONS =

The default iteration count in the test environment (Cybertrain.config.test?): the smallest iterations= accepts. A digest records its own count, so one made under test still verifies anywhere, and needs_rehash? upgrades it if it ever reached production.

1000

Class Method Summary collapse

Class Method Details

.dummy_digest ⇒ String

A digest of a random password, made the first time it is asked for and the same for the rest of the process (made again only if iterations changed since, so that it keeps costing what a real user’s digest costs). A sign-in for an address nobody registered verifies the given password against it, so the response takes as long as a wrong password for a real account and its timing does not reveal which addresses have accounts. No password matches it.

Examples:

user = User.find_by(email_address: address)
digest = user.nil? ? Cybertrain::Password.dummy_digest : user.password_digest
ok = Cybertrain::Password.verify_serialized(password, digest) && !user.nil?

Returns:

  • (String)


221
222
223
224
225
226
227
228
229
# File 'cybertrain/password.rb', line 221

def self.dummy_digest
  current = @dummy_digest
  parts = Password.parse(current)
  return current if !parts.nil? && parts[0].to_i == Password.iterations

  made = Password.hash(Crypto.random_token)
  @dummy_digest = made
  made
end

.hash(password) ⇒ String

Hashes a password with a fresh random salt and the current iterations.

Parameters:

  • password (String) —

    not empty, at most 1024 bytes

Returns:

  • (String) —

    the digest to store

Raises:

  • (ArgumentError) —

    for an empty or overlong password



106
107
108
109
110
111
112
113
114
115
# File 'cybertrain/password.rb', line 106

def self.hash(password)
  plain = password.to_s
  raise ArgumentError, "password must not be empty" if plain.empty?
  raise ArgumentError, "password is too long (#{plain.bytesize} bytes, at most #{MAX_PASSWORD_BYTES})" if plain.bytesize > MAX_PASSWORD_BYTES

  salt = SecureRandom.random_bytes(SALT_BYTES)
  n = Password.iterations
  key = Password.pbkdf2_sha256(plain, salt, n)
  "$#{ALGORITHM}$i=#{n}$#{Password.encode(salt)}$#{Password.encode(key)}"
end

.iterations ⇒ Integer

The iteration count new digests get (and the one needs_rehash? checks): what config/app.rb set with iterations=, otherwise TEST_ITERATIONS when Cybertrain.config.test? and ITERATIONS elsewhere.

Returns:

  • (Integer)


83
84
85
86
87
88
# File 'cybertrain/password.rb', line 83

def self.iterations
  set = @iterations
  return set if set > 0

  Cybertrain.config.test? ? TEST_ITERATIONS : ITERATIONS
end

.iterations=(n) ⇒ Integer

Parameters:

  • n (Integer) —

    at least 1,000

Returns:

  • (Integer)

Raises:

  • (ArgumentError) —

    when n is below 1,000



94
95
96
97
98
# File 'cybertrain/password.rb', line 94

def self.iterations=(n)
  raise ArgumentError, "Password.iterations must be at least #{TEST_ITERATIONS} (got #{n})" if n < TEST_ITERATIONS

  @iterations = n
end

.needs_rehash?(digest) ⇒ Boolean

Whether a digest was made with fewer iterations than the current iterations (or is not a digest this version understands), so that a login flow can store a fresh hash of the password it just verified.

Parameters:

  • digest (String, nil)

Returns:

  • (Boolean)


145
146
147
148
149
150
# File 'cybertrain/password.rb', line 145

def self.needs_rehash?(digest)
  parts = Password.parse(digest)
  return true if parts.nil?

  parts[0].to_i < Password.iterations
end

.session_key(digest) ⇒ String

A value to keep in the session at sign-in and compare on every request (session_key_matches?): an HMAC-SHA256 (64 hex digits) of the user’s password digest, keyed with Cybertrain.config.secret_key_base. Changing the password changes the digest (its salt is new), so every session started before the change stops matching and is signed out, while the session that made the change stores the new value. The cookie can be read by its holder; the value says nothing about the password or the digest without the secret.

Examples:

session[:password_key] = Cybertrain::Password.session_key(user.password_digest)

Parameters:

  • digest (String) —

    the user’s password_digest

Returns:

  • (String)


248
249
250
# File 'cybertrain/password.rb', line 248

def self.session_key(digest)
  Password.session_key_with(digest, Cybertrain.config.secret_key_base)
end

.session_key_matches?(key, digest) ⇒ Boolean

Whether key (from the session) is session_key of digest, compared in constant time. An empty key is false.

Parameters:

  • key (String, nil)
  • digest (String) —

    the user’s current password_digest

Returns:

  • (Boolean)


265
266
267
268
269
270
# File 'cybertrain/password.rb', line 265

def self.session_key_matches?(key, digest)
  given = key.to_s
  return false if given.empty?

  Crypto.secure_compare(given, Password.session_key(digest))
end

.verify?(password, digest) ⇒ Boolean

Whether the password matches the digest. A malformed digest, an unknown algorithm or an empty password is false, never an exception, so a login form can call it with whatever it was given. The comparison is constant-time.

Parameters:

  • password (String)
  • digest (String, nil) —

    what hash returned, from the database

Returns:

  • (Boolean)


125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'cybertrain/password.rb', line 125

def self.verify?(password, digest)
  plain = password.to_s
  return false if plain.empty? || plain.bytesize > MAX_PASSWORD_BYTES

  parts = Password.parse(digest)
  return false if parts.nil?

  expected = Password.decode(parts[2])
  return false if expected.bytesize != KEY_BYTES

  key = Password.pbkdf2_sha256(plain, Password.decode(parts[1]), parts[0].to_i)
  Crypto.secure_compare(key, expected)
end

.verify_serialized(password, digest) ⇒ Boolean

verify?, one call at a time across the process. A hash takes about 0.2 s of CPU (compiled, 100,000 iterations); a burst of sign-in attempts verified side by side would occupy every OS worker (SPINEL_WORKERS) for that long each, and the pages queue behind them. Through this method the attempts wait for each other on a Mutex instead (a waiting thread is parked, not spinning), so at most one worker is hashing and the others keep serving requests. Use it in the sign-in action; the generated User.authenticate_by does.

Examples:

Cybertrain::Password.verify_serialized(params[:password].to_s, user.password_digest)

Parameters:

  • password (String)
  • digest (String, nil)

Returns:

  • (Boolean) —

    what verify? returns



176
177
178
179
180
181
182
# File 'cybertrain/password.rb', line 176

def self.verify_serialized(password, digest)
  ok = false
  @serial_lock.synchronize do
    ok = Password.verify_locked(password.to_s, digest)
  end
  ok
end