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.
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
-
.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 Password.iterations changed since, so that it keeps costing what a real user’s digest costs).
-
.hash(password) ⇒ String
Hashes a password with a fresh random salt and the current Password.iterations.
-
.iterations ⇒ Integer
The iteration count new digests get (and the one Password.needs_rehash? checks): what
config/app.rbset with Password.iterations=, otherwise TEST_ITERATIONS whenCybertrain.config.test?and ITERATIONS elsewhere. -
.iterations=(n) ⇒ Integer
-
.needs_rehash?(digest) ⇒ Boolean
Whether a digest was made with fewer iterations than the current Password.iterations (or is not a digest this version understands), so that a login flow can store a fresh Password.hash of the password it just verified.
-
.session_key(digest) ⇒ String
A value to keep in the session at sign-in and compare on every request (Password.session_key_matches?): an HMAC-SHA256 (64 hex digits) of the user’s password digest, keyed with
Cybertrain.config.secret_key_base. -
.session_key_matches?(key, digest) ⇒ Boolean
Whether
key(from the session) is Password.session_key ofdigest, compared in constant time. -
.verify?(password, digest) ⇒ Boolean
Whether the password matches the digest.
-
.verify_serialized(password, digest) ⇒ Boolean
Password.verify?, one call at a time across the process.
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.
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.
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.
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
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.
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.
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.
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.
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.
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 |