Functional Weave
Code in Rust

auth.password-hash@1.0.0

README.md

3,024 bytes · view raw

# auth.password-hash

Store a password as a string that says how it was hashed, check a login
against it, and notice when it was hashed more weakly than today's setting:

```
stored = hashPassword("correct horse battery staple", salt, 600000)
# pbkdf2_sha256$600000$AAECAwQFBgcICQoLDA0ODw==$<44 base64 characters>
verifyPassword(attempt, stored)            # true / false
passwordNeedsRehash(stored, 600000)        # after a successful login: re-hash and save if true
```

**The salt is an argument.** A capability cannot read randomness and stay
testable, so the application generates it: 16 or more bytes from a secure
source, fresh for every hash (`list(secrets.token_bytes(16))` in Python,
`Array.from(crypto.getRandomValues(new Uint8Array(16)))` in TypeScript), never
reused, never derived from the user. Fewer than 16 bytes is refused (NIST SP
800-132 section 5.1 asks for at least 128 bits).

**The algorithm** is PBKDF2-HMAC-SHA256 (`crypto.pbkdf2-sha256`) over the
password's UTF-8 bytes, deriving 32 bytes. The iteration count is the work
factor: OWASP's Password Storage Cheat Sheet recommends 600,000 for this
algorithm, which takes about 50 ms in Python and about 200 ms in pure
TypeScript or Rust (timings in `crypto.pbkdf2-sha256`). Fewer than 1,000 is
refused (NIST SP 800-132's minimum). The count, salt and hash are all in the
stored string, so raising the count later does not break existing hashes:
`verifyPassword` uses the stored count, and `passwordNeedsRehash` says which
hashes to upgrade at their owner's next successful login.

The stored form is `pbkdf2_sha256$<iterations>$<salt>$<hash>`, with the salt
and hash in standard, padded base64. It looks like Django's, but Django keeps
its salt as text, so the two are not interchangeable.

**verifyPassword** re-derives the key with the stored salt and count and
compares it with `crypto.constant-time-equal`, so the time taken does not
reveal how close a guess was. A wrong password is `false`. A stored string
that is not this format (another algorithm, a missing field, bad base64, a
count of 0 or with a leading zero) throws, because it means the database
holds something this code did not write, and quietly answering `false`
would lock the user out with no trace of why.

The password is used exactly as given: not trimmed (a trailing space is part
of it) and not Unicode-normalised, so `"é"` typed as one code point and as
`e` plus a combining accent are different passwords. NIST SP 800-63B
suggests normalising; the standard libraries of Rust and browser-free
TypeScript have no normaliser, so it is left to the caller to apply NFC
consistently if it wants one.

Sources: RFC 8018 section 5.2 (PBKDF2); NIST SP 800-132, Recommendation
for Password-Based Key Derivation, sections 5.1 and 5.2
(https://csrc.nist.gov/pubs/sp/800/132/final); NIST SP 800-63B section
5.1.1.2 (https://pages.nist.gov/800-63-3/sp800-63b.html); OWASP Password
Storage Cheat Sheet
(https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html).