iyi

Standard library, 0.18.0

std/password

Password hashing, written in iyi: bcrypt (Provos and Mazières, USENIX 1999, as OpenBSD's bcrypt.c writes it) on its own Blowfish, and scrypt (RFC 7914) on std/crypto's PBKDF2-HMAC-SHA256 and a Salsa20/8 core.

import std/password to use it. Source: src/std/password.iyi at v0.18.0.

  import std/password::{Bcrypt, Scrypt}
  stored = Bcrypt.hash(password)        # "$2b$11$..."
  Bcrypt.verify(attempt, stored)        # true or false
  Scrypt.derive(password, salt, 16384, 8, 1, 32)

A password or a salt is a String, whose bytes are taken, or an Array(UInt8); what scrypt derives is an Array(UInt8).

Why this is a module of its own and not part of std/crypto: a bcrypt hash needs a salt nobody can guess, and that is Random::Secure, so this imports std/random, and through it the clocks and the environment. std/crypto reaches nothing past std/int and std/digest, and a package that only MACs its messages should not be counted as reaching the operating system's entropy because the module beside HMAC hashes passwords (iyi mod reach). Scrypt takes its salt from the caller and needs no randomness; it is here because it is the other password hash, and a program looking for one looks here.

For a password stored to be checked later, Bcrypt is the one with the format: the string it answers carries the cost and the salt, is the modular $2b$ string OpenBSD, the other library and Python's bcrypt write and read, and verify needs nothing else. Scrypt answers raw bytes, for a key or for a format of the caller's own. Both are pure iyi and keep up with the native code Python calls: measured on one x86_64 Linux machine, --release, bcrypt at cost 10 took 37 ms against Python's bcrypt (Rust) 39 ms, and scrypt at n = 16384, r = 8, p = 1 took 18 ms against hashlib.scrypt's (OpenSSL) 23 ms; a plain build took 94 and 148.

Refused with a sentence: a bcrypt cost outside 4 to 31, and scrypt parameters out of the RFC's range or past the caller's memory limit (each says which below). A stored bcrypt hash is data, and a malformed one answers false from verify.

Bcryptstruct

bcrypt, Provos and Mazières (USENIX 1999), as OpenBSD's bcrypt.c writes it and Crystal's Crypto::Bcrypt::Password reads it: a password hashed into a 60-character string that carries its own cost and salt,

  $2b$11$ 22 characters of salt 31 characters of hash

both in bcrypt's own base64 (./A-Za-z0-9, no padding). hash draws 16 bytes of salt from Random::Secure and writes $2b$; verify reads $2a$, $2b$ and $2y$, hashes the password again under the salt and cost it finds, and compares the two strings with Subtle.constant_time_compare, so how long a wrong guess takes does not say how much of it was right.

The password is OpenBSD's key: its bytes up to the first NUL, cut to 72 bytes, then a NUL, and only the first 72 bytes of that matter. So a password's 73rd byte onwards changes nothing, and neither does anything after a NUL: "a\0b" hashes as "a" does, which is what OpenBSD's C, with its strlen, does with it. OpenBSD's $2a$ once counted the length in a byte and wrapped at 255 (the reason for $2b$); this reads $2a$ as $2b$, so a $2a$ hash that old code made of a password of 255 bytes or more does not verify here.

cost is the log2 of the number of key-schedule passes: each one more doubles the time. 11 is Crystal's default and this one's; outside 4 to 31 hash panics with a sentence. A stored hash is data, and a malformed one, or one whose cost is outside 4 to 31, does not verify: verify answers false for it rather than ending the task.

std/passwordiyi doc src/std/password.iyi
pub struct Bcrypt
  def self.hash(password : String | Array(UInt8), cost : Int32 = 11) : String
  def initialize
  def self.verify(password : String | Array(UInt8), hash : String) : Bool
end

Scryptstruct

scrypt, RFC 7914: PBKDF2-HMAC-SHA256 spread into p blocks of 128 * r bytes, each block walked through a table of n versions of itself (ROMix) by BlockMix, a chain of Salsa20/8 cores, and PBKDF2 once more over what comes out. The table is what makes it costly to guess at: a guess needs 128 * r * n bytes of memory, or the time to recompute what it did not keep. RFC 7914's interactive-login figure is n = 16384, r = 8, p = 1, sixteen mebibytes.

max_memory is a refusal, not a cap: the parameters ask for 128 * r * p + 128 * r * (n + 2) bytes, which is how OpenSSL counts them, and past the limit the call panics rather than allocate. Its default, 32 MiB, is OpenSSL's and so Python's, so a call one library accepts the other accepts too. Refused with a sentence besides: an n that is not a power of two above 1, or not below 2^(16r) (RFC 7914 §2; OpenSSL refuses it too), an r, p or length below one, and a 128 * r * p that does not fit an Int32 (p * r of 2^24 and up).

std/passwordiyi doc src/std/password.iyi
pub struct Scrypt
  def self.derive(password : String | Array(UInt8), salt : String | Array(UInt8), n : Int32, r : Int32, p : Int32, length : Int32, max_memory : Int64 = 33554432_i64) : Array(UInt8)
  def initialize
end