Identify a hash
What made this digest?
A puzzle hands you 5d41402abc4b2a76b9719d911017c592. No label, no hint. Now what?
Thirty-two hex digits make 16 bytes. MD5 makes 16 bytes. So do MD4, MD2, NTLM, RIPEMD-128 and HAVAL. That's all the shape can tell you, and identifyDigest tells you exactly that much:
import { identifyDigest } from "@agntn/hashes";
const found = identifyDigest("5d41402abc4b2a76b9719d911017c592");
found.length; // 16
found.candidates.map((candidate) => candidate.name);
// ["md5", "md4", "md2", "ntlm", "ripemd128", "haval", "haval", "haval", "lm", "scrypt", ...]
A whole column of them from one dump? Pass the list. identifyDigest([first, second]) answers once per hash, in order. hashes identify first second does the same from a shell.
Why HAVAL three times? Three pass counts, and 16 bytes fit them all. The two that aren't the default carry passes in their parameters, ready for hashes_verify.
LM comes without an algorithm. This package can't compute it, and the list says so instead of hiding it. The KDFs go last. They make any length you ask for, so they fit everything and prove nothing.
Which one is it?
Hash something you know and compare. A guess with a MATCH stops being a guess.
hashes identify 5d41402abc4b2a76b9719d911017c592 2>/dev/null | head -2
# md5: MD5, computable
# md4: MD4, computable
hashes verify md5 hello 5d41402abc4b2a76b9719d911017c592
# MATCH md5 5d41402abc4b2a76b9719d911017c592
Equal lengths still get an order. MD5 beats MD4, SHA-1 beats RIPEMD-160, SHA-256 beats the other 32-byte digests. Boring, but that's what you'll actually meet. A 0x in front puts Keccak-256 first, since that's how Ethereum writes it. Base64 works too, and then the answer also prints the bytes in hex.
What about $2b$ and friends?
Password hashes wear a prefix. The prefix says more than any length could. bcrypt, Argon2, SHA-512 and SHA-256 crypt, MD5 crypt, yescrypt, scrypt, PBKDF2 from passlib and Django, MySQL's * hashes. All named.
For the ones this package computes, it reads the salt, the costs and the digest out of the string. Copy them into verify and you're done:
hashes identify '$argon2id$v=19$m=1024,t=2,p=2$c29tZXNhbHRzYWx0$8mkYn4qtK5HHJtQQI+FNQKE4UECfkb5diD560/y7mZs' 2>/dev/null
# argon2id: Argon2id, PHC string, computable, salt 736f6d6573616c7473616c74, memory 1024, iterations 2, parallelism 2, keyLength 32, expected f269189f8aad2b91c726d41023e14d40a13850409f91be5d883e7ad3fcbb999b
hashes verify argon2id password f269189f8aad2b91c726d41023e14d40a13850409f91be5d883e7ad3fcbb999b --salt 736f6d6573616c7473616c74 --memory 1024 --iterations 2 --parallelism 2 --key-length 32
# MATCH argon2id f269189f8aad2b91c726d41023e14d40a13850409f91be5d883e7ad3fcbb999b
bcrypt works the same way. Got a $2y$ line from .htpasswd? It comes back as salt, cost and expected, all in hex:
hashes identify '$2y$04$X2kfXDUfLpgiqo1O5Bq6z.EhPme.LvywDHaK1QMd12JWp.XkUnIo6' 2>/dev/null
# bcrypt: bcrypt, computable, salt 6789a16455a136b8a4b2add0ec3b3cd4, cost 4, expected 1a3468800371d3214970cdd239fdf82d8ac06665a92aaf
hashes verify bcrypt ProbablyFine-2026 1a3468800371d3214970cdd239fdf82d8ac06665a92aaf --salt 6789a16455a136b8a4b2add0ec3b3cd4 --cost 4
# MATCH bcrypt 1a3468800371d3214970cdd239fdf82d8ac06665a92aaf
$2x$ only gets a name. It's crypt_blowfish's old sign bug, and nobody here wants to run it on purpose. The crypt family gets a name and not in this package too. Honest beats helpful here. Keep the single quotes, by the way. Without them the shell eats every $.
Can it prove anything?
No. A digest is just bytes. Any 16 bytes could be MD5, half of something longer, or plain noise. Identify narrows the list, verify settles it. The hashes_digest_identify tool tells the model the same, then points it at hashes_verify.