Salted KDFs
The salt comes back
A KDF without a salt draws 32 random bytes. 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.
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 or sha3-512 |
| pbkdf2 | keyLength | 64 | Output bytes |
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. The scrypt and PBKDF2 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. hash_compute and hash_verify cap N at 1048576, r at 32, p at 16, iterations at 10 million and keyLength at 1024 bytes, and scrypt's working memory, 128·r·(N + p + 2) bytes, at 256 MiB:
Invalid option N=1048576: with r and p needs 1073744896 bytes, over 268435456 in a tool call
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 hash_verify refuse to run:
hashes verify pbkdf2 password c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a --iterations 4096 --digest sha256 --keyLength 32
# Missing required option: salt (the one the expected digest was made with)
Add --salt 73616c74 and it says MATCH.