Salted KDFs
The salt comes back
A KDF without a salt draws 32 random bytes, bcrypt 16. That's the right default and a trap at the same time, because the digest is worthless without those bytes. So the result carries them, and the CLI prints them:
hashes scrypt "correct horse battery staple"
# N 16384, r 8, p 1, keyLength 64, salt 31f4aa33e6346bfa6d576a5dce9eec1c0d6c8e959271d03c2f489a71ac52453b
# bc010c96df4510eef56ce1a1fc1d140ee276b92d30a31e2ca2e065f6dac8977c9c1bd5d20951e54fbd5b4474984c0f45f5adbe8c7b18daeeacbef09dc330a902
The first line goes to stderr and the digest alone to stdout, so a pipe still gets one clean value. Run it again and you get another salt and another digest. That's what salts are for. --salt takes one back:
hashes scrypt "correct horse battery staple" --salt 73616c74 --n 1024
# N 1024, r 8, p 1, keyLength 64, salt 73616c74
# a1687da9760232a5febf395ab288cb93c9ebb6725c005323a68ae49a4c24538951d7e88980797633a82cddf7cc2f37ffcfa42d2a1222eb06efcb687651975e4c
In the library the same facts sit in result.options:
import { create } from "@agntn/hashes";
const derived = create("pbkdf2").hash("password", {
salt: "73616c74",
iterations: 4096,
digest: "sha256",
keyLength: 32,
});
derived.digest; // "c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a"
derived.options; // { encoding: "hex", iterations: 4096, digest: "sha256", keyLength: 32, salt: "73616c74" }
The salt is hex by default. saltEncoding: "utf8" or "base64" reads it another way, and a Uint8Array is taken as it is. A salt that doesn't decode is an error, never silently shorter.
An empty salt is still a salt, just a very short one. Who needs that? RFC 7914 does, its first scrypt vector has none. --salt "" or salt: "" runs exactly that, and the answer prints salt "" instead of a blank. Leave the option out and you're back to random bytes.
hashes scrypt "" --salt "" --n 16 --r 1 --p 1
# N 16, r 1, p 1, keyLength 64, salt ""
# 77d6576238657b203b19ca42c18a0497f16b4844e3074ae8dfdffa3fede21442fcd0069ded0948f8326a753a0fc81f17e8d3e0fb2e0d3628cf35e20c38d18906
The knobs
| Algorithm | Option | Default | What it does |
|---|---|---|---|
| scrypt | N | 16384 | CPU and memory cost, a power of 2 |
| scrypt | r | 8 | Block size |
| scrypt | p | 1 | Parallelization |
| scrypt | keyLength | 64 | Output bytes |
| pbkdf2 | iterations | 600000 | Rounds of HMAC |
| pbkdf2 | digest | sha512 | The hash under HMAC: sha256, sha384, sha512, sha3-256, sha3-512 or whirlpool |
| pbkdf2 | keyLength | 64 | Output bytes |
| argon2id, argon2i, argon2d | memory | 65536 | Memory in KiB, at least 8 per lane |
| argon2id, argon2i, argon2d | iterations | 3 | Passes over the memory |
| argon2id, argon2i, argon2d | parallelism | 4 | Lanes |
| argon2id, argon2i, argon2d | keyLength | 32 | Output bytes, at least 4 |
| argon2id, argon2i, argon2d | secret, associatedData | none | Hex mixed into the first hash, a pepper and context |
| bcrypt | cost | 12 | Base-2 logarithm of the rounds, 4 to 31 |
scrypt checks RFC 7914's bounds the way OpenSSL does, so N has to stay below 2 to the power 16·r when r is small. The defaults are honest about what they are. PBKDF2's 600000 rounds is OWASP's number for HMAC-SHA256, and with SHA-512 OWASP asks for 220000, so the default errs slow. scrypt's N=16384 with p=1 sits below what OWASP asks for passwords today, and the security note says so out loud instead of hiding it. Argon2's defaults are RFC 9106's second recommended option, and its salt needs at least 8 bytes. bcrypt wants exactly 16 bytes of salt and refuses a password past 72 bytes instead of cutting it. The scrypt, PBKDF2, Argon2id and bcrypt pages have the full notes.
When an agent picks the cost
The library lets you ask for any cost. A tool call doesn't, because one argument from a model shouldn't pin the CPU for a minute or allocate gigabytes. hashes_compute and hashes_verify cap N at 1048576, r at 32, p at 16, iterations at 10 million, keyLength and ivLength at 1024 bytes, and scrypt's block table, 128·r·N bytes, at 256 MiB. brainwallet.io's N=262144 with r=8 fills that to the byte and runs. Four times the table doesn't:
Invalid option N=1048576: with r=8 needs 1073741824 bytes of blocks, over 268435456 in a tool call
Argon2 gets 256 MiB too, as memory up to 262144. Its time grows with memory times passes, so that product stops at 1048576 KiB, about five seconds here. 256 MiB with four passes runs. With five it doesn't:
Invalid option iterations=5: with memory=262144 fills 1310720 KiB, over 1048576 in a tool call
bcrypt's cost stops at 16. Every step doubles the work, and 16 already takes a few seconds.
PBKDF2 and EVP_BytesToKey get a price on top. Ten million iterations sounds like a cap. It isn't one alone. Every iteration runs once per digest the output needs, and digests don't cost the same. Whirlpool takes nearly nine times what SHA-256 does. Ask for 1024 bytes of it at ten million and one call runs for over twenty minutes. Coffee, anyone? So the tool multiplies iterations by digests by what each digest costs here, and stops near five seconds. The error does the division for you:
Invalid option iterations=10000000: whirlpool fills 1024 bytes 64 at a time, so a tool call runs at most 36337 iterations; lower iterations or ask for fewer bytes
Django's 1.2 million iterations of SHA-256 still fit. About a second.
The tool answer names the salt it drew, like the CLI does. An MCP client sees only the text, so a salt that lived only in structured details would be a salt the model never saw.
Verifying
Verify needs the salt the expected digest was made with. Without it the answer could only be no, so hashes verify and hashes_verify refuse to run:
hashes verify pbkdf2 password c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a --iterations 4096 --digest sha256 --key-length 32
# Missing required option: salt (the one the expected digest was made with)
Add --salt 73616c74 and it says MATCH.