Guide

Search for a transform

You have the words and the digest. Find the order and the joiner and the case and the algorithm that turn one into the other

Which words made this hash?

Puzzles love this trick. Take a few answers, glue them together, hash the lot. The digest is your password. You found the words. Great. In which order, though? Joined with what? Lowercase? Hashed twice?

Nobody wants to write that loop again. searchDigest takes the words and the digest and tries everything:

ts
import { searchDigest } from "@agntn/hashes";

const found = searchDigest(
  Uint8Array.fromHex("250f37726d6862939f723edc4f993fde9d33c6004aab4f2203d9ee489d61ce4c"),
  { words: ["heisenbergsuncertaintyprinciple", "giveitjustonesecond", "jacquefresco"] },
);
found.recipe;
// { words: ["jacquefresco", "giveitjustonesecond", "heisenbergsuncertaintyprinciple"],
//   joiner: "", case: "as-is", algorithm: "sha256", rounds: 1,
//   input: "jacquefrescogiveitjustonesecondheisenbergsuncertaintyprinciple" }

That's the OpenSSL password of GSMG phase 3.2. Three words, no spaces, SHA-256 in hex. Done before you could type the second guess.

What does it try?

Every subset of the words, the smallest first, in every order. Each word at most once. Then:

  • Joiners. Nothing, a space, a comma, a line feed. Or your own list.
  • Cases. As given, all lowercase, all uppercase, each word capitalized.
  • Algorithms. Every fixed-length digest as long as yours. A 32-byte target gets SHA-256, SHA3-256, Keccak-256, BLAKE3 and the rest of its size. A 20-byte one gets SHA-1, SHA-0, RIPEMD-160 and HASH160. A 4-byte one gets CRC-32, its BZIP2 variant and XXH32 too, the same readings identify lists.
  • Rounds. Up to rounds deep. Each round after the first hashes the digest's bytes, its lowercase hex or its uppercase hex.

Two cases that give the same text count once. alpha lowercased is still alpha, so it's hashed once. A word listed twice is an error, not a quiet double. So are Moon and moon with lowercase on the list: one of them is enough.

One overlap stays. With no joiner, ab alone and a plus b make the same text, so it's hashed twice. Cheap, and the count includes it up front.

The KDFs stay out. They need a salt, and then it isn't a transform of your words anymore.

How long will it take?

You know before it starts. total is the exact number of hashes the scope holds, counted without listing them. onProgress hears it first, then the running count every 100,000 hashes.

Each hash costs a couple of microseconds. Five lowercase words make 3,855 texts, and every algorithm of the digest's length hashes each one. Gone in a blink. Nine words make 11,836,827 texts. That's minutes, not seconds. Order matters, and nine words have a lot of orders.

limit stops it early, and stopped tells you it did. The first match ends the search either way.

Three hashes, one handful of words?

Puzzles rarely stop at one digest. Searching them one at a time builds the same texts again and pays for every hash again. Why bother? Pass a list:

ts
const found = searchDigest(
  [
    "a18caf4d9bca26b5d8594c058648dad73a863ac2bc8ee1db43f60d7db03ff869",
    "0c872254d5f9982d78a14eb0ba09cbb2",
    "4f4a9410ffcdf895c4adb880659e9b5c0dd1f23a30790684340b3eaacb045398",
  ].map((hex) => Uint8Array.fromHex(hex)),
  { words: ["alpha", "beta", "gamma"] },
);
found.items.map((item) => item.recipe?.input);
// ["gamma alpha", "Beta,Gamma", undefined]

Each text is built once. Its digest is held against every target of that length. A match fills in that target's recipe. The search goes on for the rest. Five digests of one length cost what the slowest of them costs alone. Not bad for one extra pair of brackets.

Mixed lengths are fine. The 32-byte target gets SHA-256 and its crowd. The 16-byte one gets MD5 and friends. total counts every length. A length with all its recipes found stops costing anything. Your own algorithms split the same way, and one that fits none of the digests is an error.

The third digest? It's SHA-256 of delta, and nobody passed that word. So it comes back with no recipe. The other two keep theirs. They also survive limit: whatever was found before it hit stays found.

From the shell

The recipe and the text that was hashed go to stdout. The count and the verdict go to stderr:

shell
hashes search 250f37726d6862939f723edc4f993fde9d33c6004aab4f2203d9ee489d61ce4c \
  heisenbergsuncertaintyprinciple giveitjustonesecond jacquefresco 2>/dev/null
# sha256 of the words "jacquefresco", "giveitjustonesecond", "heisenbergsuncertaintyprinciple" joined by "", case as-is
# input "jacquefrescogiveitjustonesecondheisenbergsuncertaintyprinciple"

--joiners takes a JSON array, since a joiner can be a comma: --joiners '["", "-", "_"]'. Words split on spaces, quoted or not, so "jacque fresco" is two words. Bad news? Not really. The empty joiner glues them back into jacquefresco and the search above finds it anyway, a few hundred hashes later. --cases, --algorithms and --chains take comma lists. So does the digest itself. Hex and base64 never hold a comma, so hashes search <digest>,<digest> alpha beta is safe. Both go in one pass. Each digest gets its own line, MATCH with the recipe or NO MATCH. The exit code stays 1 until every one has a recipe. --limit stops at 100,000,000 hashes unless you say otherwise. On a terminal the running count ticks over itself. A pipe gets the total and the verdict, nothing else.

Nothing matched. Now what?

The answer says exactly what it covered. Here a guess dropped the c from the name and kept to SHA-256:

shell
hashes search 250f37726d6862939f723edc4f993fde9d33c6004aab4f2203d9ee489d61ce4c \
  jaquefresco giveitjustonesecond heisenbergsuncertaintyprinciple --algorithms sha256
# Searching 153 hashes
# NO MATCH in 153 hashes
# Covered 1 to 3 of the words "jaquefresco", "giveitjustonesecond", "heisenbergsuncertaintyprinciple"; joiners "", " ", ",", "\n"; cases as-is, lower, upper, title; algorithms sha256; 1 round.

So is the idea dead? No. Only that scope is. The real word had a c, or the digest went through three rounds, or there's a fourth word you haven't found. Widen one thing at a time. The exit code is 1, so a script can tell.

For agents

hashes_digest_search takes the same things as arguments and answers in the same lines. digest takes a list too, up to 16, and the answer opens with how many got a recipe, then one block per digest. A tool call stops at a million hashes, fewer when the words run long, a list included, and then names the arguments to narrow. Every word comes back in quotes, so a word with a line feed in it can't fake a line of the answer. Brute force, but polite about it.