This page is about how the code is shaped. Not what each line does โ that's the next page. Here we zoom out and look at how the pieces fit together, what data flows where, and why we picked this shape over the alternatives.
The whole tool is one Python file: hash_identifier.py. Everything that runs lives in that file. There are three layers inside it:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ CLI layer (main, _build_argument_parser, _render_table) โ
โ - reads command-line arguments โ
โ - prints the colored table to your terminal โ
โ - returns an exit code โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ calls
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Pure-function layer (identify) โ
โ - the actual decision-making โ
โ - takes a string, returns a list of HashCandidate โ
โ - touches NO files, NO network, NO global state โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ uses
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Data layer (PREFIX_RULES, HEX_LENGTH_RULES, charsets) โ
โ - lookup tables describing what we know about hashes โ
โ - read-only, defined at module load time โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Reading top to bottom, the CLI layer is the outside of the program: it deals with the human. The pure-function layer is the brain: it takes a clean string in and returns a clean answer out. The data layer is the knowledge: every "we know X about hash Y" lives in one of those tables, not scattered through the code.
This three-way split is deliberate. We can test the brain in isolation without ever spawning a CLI โ and the test file does exactly that, calling identify() directly. We can change how the table looks (color, layout, JSON output) without touching the brain. And we can add new hash formats by adding a row to a table, not by adding a new function.
Here's what happens when you type just run -- 5f4dcc3b5aa765d61d8327deb882cf99:
(your terminal)
โ
โ "5f4dcc3b5aa765d61d8327deb882cf99"
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ argparse โ
โ parses sys.argv into args.hash โ
โโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ args.hash = "5f4dcc..."
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ identify(args.hash) โ
โ โ
โ text = args.hash.strip() โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Step 1: prefix match? โ โ
โ โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโ โ
โ no match โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Step 2: special shape? โ โ
โ โ (NetNTLM / MySQL5 / DES) โ โ
โ โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโ โ
โ no match โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Step 3: hex + length match? โ โ
โ โ โ 32 hex chars โ MD5/NTLM..โ โ โ
โ โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ returns [HashCandidate, ...] โ
โโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ _render_table() โ
โ builds a rich.Table, prints it โ
โโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
(your terminal)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Candidates for: 5f4dcc3b5aa765d61d8327deb882cf99 โ
โ โญโโโโโโโโโโโโฌโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ โ
โ โ algorithm โ confidence โ reason โ โ
โ โโโโโโโโโโโโโผโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโค โ
โ โ MD5 โ medium โ 32 hex chars โ most likelyโ โ
โ โ NTLM โ low โ 32 hex chars โ also poss. โ โ
โ โ MD4 โ low โ 32 hex chars โ also poss. โ โ
โ โ RIPEMD-128โ low โ 32 hex chars โ also poss. โ โ
โ โฐโโโโโโโโโโโโดโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The brain is the middle box. Everything above and below it is just plumbing: getting the string in, getting the table out.
The brain (identify()) is structured as six numbered steps. Each step is a chance to short-circuit and return a verdict. If a step matches, the function returns immediately. If not, control falls through to the next step.
This shape โ "try the strongest signal first, fall back to weaker ones" โ is called a decision cascade or rule pipeline. You'll see this pattern all over security tooling: spam filters, IDS rules, antivirus heuristics, fingerprinting. They all share the same skeleton.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Step 1: PREFIX_RULES? โ HIGH confidence
โ Walk the prefix table. โ โโโโโโโโโบ return
โ Any prefix start match wins. โ first match
โโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโ
โ no match
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Step 2: special shapes? โ HIGH/MEDIUM
โ NetNTLMv2 / NetNTLMv1 (`::`) โ โโโโโโโโโบ return
โ MySQL5 (`*` + 40 upper hex) โ first match
โ DES crypt (13 chars) โ
โโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโ
โ no match
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Step 3: pure hex? โ MEDIUM/LOW
โ If yes, look up length in โ โโโโโโโโโบ return
โ HEX_LENGTH_RULES, return all โ ranked list
โ candidates ranked by likelihoodโ
โโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโ
โ not hex
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Step 4: generic `$algo$...`? โ LOW
โ Looks like a PHC string but โ โโโโโโโโโบ return
โ we have no specific rule. โ generic match
โ Report it as a generic PHC. โ
โโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโ
โ no
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Step 5: shape hint? โ LOW
โ Looks like a JWT (eyJ...) or โ โโโโโโโโโบ return
โ base64 (`+`, `/`, `=`)? โ "not a hash"
โ Tell the user it's not a hash. โ
โโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโ
โ no
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Step 6: give up. โ none
โ Return empty list. โ โโโโโโโโโบ []
โ CLI prints "could not identify"โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Order matters here, and the order isn't arbitrary. We always try the most specific test first and the most general test last:
- PHC prefixes are dead giveaways โ the hash itself names the algorithm.
- Special shapes (NetNTLM, MySQL5, DES crypt) are also strong: they have distinctive structures.
- Hex + length is a narrowing signal, not a definitive one โ it picks a family, not a member.
- Generic PHC fallback catches hashes that look PHC-shaped but aren't in our table.
- Shape hints handle the common "I pasted the wrong thing" case (people drop JWTs into hash identifiers all the time).
- Empty list = honest "I don't know."
If we reversed the order โ say, checked length before prefix โ we would misclassify a bcrypt hash as "60 hex chars, no length rule" because we'd never look at its $2b$ prefix. Order encodes priority.
The brain doesn't return a string โ it returns a list of HashCandidate objects. A candidate has three fields:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ HashCandidate โ
โ โ
โ algorithm: str e.g. "MD5", "bcrypt", "SHA-256" โ
โ confidence: Literal "high" | "medium" | "low" โ
โ reason: str "prefix `$2b$` โ bcrypt PHC..." โ
โ โ
โ frozen=True โโ immutable, can't be mutated after creationโ
โ slots=True โโ memory-efficient (no __dict__) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The shape is intentional. algorithm is what hashcat wants to know. confidence tells the human reader how much to trust this guess. reason is the evidence โ a one-line explanation of why the tool made this guess. The reason field is what makes the tool teachable: the user sees not just "bcrypt" but "prefix $2b$ โ bcrypt PHC string, 2b variant (current)."
We use @dataclass(frozen=True, slots=True) instead of writing a class with __init__ and __repr__ by hand:
frozen=Truemeans once you build aHashCandidate, you can't mutate it. If somewhere in the code triedcandidate.algorithm = "something else", Python would raiseFrozenInstanceError. This makes the data flow predictable: a candidate that comes out ofidentify()is the same candidate everywhere it shows up later.slots=Trueis a memory optimization. Without slots, every instance carries around a__dict__for adding attributes on the fly. We don't need that, so we turn it off and save memory.
Both flags also signal intent to a reader: "this is a value object, not a mutable bag of state." That signal matters more than the bytes saved.
If you wanted to add a new hash format to this tool, you would not write new logic. You would add a row to one of these tables:
PREFIX_RULES: list of (prefix, algorithm, note)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
("$argon2id$", "Argon2id", "modern PHC string..."),
("$2b$", "bcrypt", "bcrypt PHC string..."),
("$6$", "SHA-512 crypt", "Unix crypt..."),
... ~25 more rows
HEX_LENGTH_RULES: dict of {length_in_hex_chars: [algorithms]}
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
32: ["MD5", "NTLM", "MD4", "RIPEMD-128"],
40: ["SHA-1", "RIPEMD-160"],
64: ["SHA-256", "SHA3-256", "BLAKE2s-256", "RIPEMD-256"],
128: ["SHA-512", "SHA3-512", "BLAKE2b-512", "Whirlpool"],
... etc
HEX_CHARSET, _HEX_UPPER_CHARSET, _DESCRYPT_CHARSET
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The alphabets used by each format. frozenset for fast lookup.
This is called a data-driven design. The rules live in data, not code. Three benefits:
- Adding a new format is one line. No new function, no new test scaffolding to write.
- The rules are inspectable. You can read
PREFIX_RULESand immediately see every format the tool knows. - The rules are testable. The test file iterates over
PREFIX_RULESand confirms each prefix is recognized โ so the data and the behavior cannot drift out of sync.
When you read the implementation page next, watch how few of the function bodies have if/elif/elif chains. The decisions happen inside table lookups, not inside conditionals. That's the data-driven design at work.
The brain is one big function (identify), but two small helpers live next to it. They both answer yes/no questions about the input string:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ _is_hex(text) -> bool โ
โ "Is every character of text a valid hex digit?" โ
โ Used in step 3 to decide whether to look up length. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ _is_mysql5(text) -> bool โ
โ "Does text look like `*` + 40 uppercase hex chars?" โ
โ Used in step 2 for MySQL5 detection. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ _is_descrypt(text) -> bool โ
โ "Is text 13 chars from `./0-9A-Za-z`?" โ
โ Used in step 2 for legacy DES crypt detection. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The leading underscore (_is_hex, not is_hex) is a Python convention meaning "this is module-private." It says to other developers: "this is an implementation detail of hash_identifier.py; don't import it from somewhere else." Python doesn't enforce this โ you can still import private names โ but every linter and every reviewer will flag you if you do.
The helpers are tiny on purpose. Each one is a single boolean question. We pull them out of identify() not because they're complicated but because giving them a name makes identify() read like English: "if _is_hex(text), do hex-length matching." If we inlined the test, the eye would have to parse it.
The CLI layer is the part the human actually interacts with. It does three things:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ _build_argument_parser() โ
โ Sets up argparse โ defines that the program takes one โ
โ positional argument (`hash`) and an optional `--top N` โ
โ flag. Returns a configured parser. โ
โ โ
โ Pulled out of main() so tests can build the parser โ
โ without actually running the CLI. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ _render_table(raw_input, candidates, console) โ
โ Builds a rich.Table object, adds one row per candidate, โ
โ colors the confidence column (green/yellow/cyan), โ
โ and prints it. โ
โ โ
โ Takes the rich Console as an argument so tests can pass โ
โ a captured-output Console and verify what got printed. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ main() โ
โ Parses args, calls identify(), prints the table. โ
โ Returns an exit code: โ
โ 0 โ at least one candidate found โ
โ 1 โ no candidates (printed an error message) โ
โ โ
โ The exit code lets shell scripts do โ
โ `if hashid "$x"; then ...` โ
โ to react to whether identification succeeded. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The crucial design choice in the CLI layer is dependency injection. _render_table takes a Console object as a parameter instead of creating one inside. That sounds fancy, but it just means: "give me the printer to use." The function doesn't care if you give it a real terminal Console or a test Console that captures output to a string. This makes the function testable without writing to your real terminal during tests.
test_hash_identifier.py is structured to mirror hash_identifier.py. It tests every behavior the tool claims to have:
test_bcrypt_prefix_is_recognized โโ covers PREFIX_RULES row $2b$
test_argon2id_prefix_is_recognized โโ covers PREFIX_RULES row $argon2id$
test_apr1_prefix_is_recognized โโ covers PREFIX_RULES row $apr1$
test_sha512_crypt_prefix_is_recognized โโ covers PREFIX_RULES row $6$
test_django_pbkdf2_prefix_is_recognized โโ covers PREFIX_RULES row pbkdf2_sha256$
test_mysql5_format_is_recognized โโ covers Step 2 / _is_mysql5
test_mysql5_rejects_lowercase_body โโ covers the "be honest, don't lie" rule
test_netntlmv2_format_is_recognized โโ covers Step 2 / NetNTLMv2
test_netntlmv1_format_is_recognized โโ covers Step 2 / NetNTLMv1
test_descrypt_format_is_recognized โโ covers Step 2 / _is_descrypt
test_md5_length_returns_md5_first โโ covers Step 3 / 32 hex chars
test_sha1_length_returns_sha1_first โโ covers Step 3 / 40 hex chars
test_sha256_length_returns_sha256_first โโ covers Step 3 / 64 hex chars
test_mysql323_length_returns_mysql323_first โโ covers Step 3 / 16 hex chars
test_unknown_phc_string_falls_back_to_generic
โโ covers Step 4
test_jwt_input_is_called_out_as_not_a_hash โโ covers Step 5
test_base64_blob_is_called_out_as_not_a_hashโโ covers Step 5
test_empty_input_returns_no_candidates โโ covers Step 6 (edge case)
test_garbage_returns_no_candidates โโ covers Step 6
test_input_is_trimmed_of_whitespace โโ covers the .strip() at the top
test_hash_candidate_is_frozen โโ covers the @dataclass(frozen=True)
test_every_prefix_rule_is_recognized_with_high_confidence
โโ meta-test: iterates over
โโ PREFIX_RULES and asserts every
โโ row produces a HIGH-confidence
โโ match. Keeps data and code in sync.
The meta-test at the bottom is the most interesting one. It's a guard against future regressions: if you add a new row to PREFIX_RULES and forget to update the matching logic, this test fires. The test loops over the data, not over hardcoded inputs โ so the test grows automatically as the data table grows.
The brain (identify()) is what's called a pure function:
- Given the same input, it always returns the same output.
- It doesn't modify anything outside itself (no global variables, no files, no network).
- It doesn't depend on anything outside itself (no current time, no environment variables, no random numbers).
This sounds like a small thing. It's enormous. Pure functions are:
- Trivially testable.
assert identify("5f4d...") == [HashCandidate(...)]. No mocking, no setup, no teardown. - Trivially parallelizable. You could run
identify()on a million hashes across 16 CPU cores with zero coordination, because no two calls can interfere with each other. - Trivially cacheable. Same input โ same output โ memoize freely.
- Trivially understandable. You can read
identify()in isolation. You don't have to know what state the program is in.
Most real programs can't be all-pure โ they have to read files, send packets, write to databases. But you can almost always carve out a pure core and put a thin shell around it that does the side-effecty stuff. That's exactly the architecture here: pure brain in the middle, side-effecty CLI shell around it.
This is sometimes called the Functional Core, Imperative Shell pattern. It's worth learning the name because once you see it, you'll spot it everywhere.
You now know the shape: three layers, six steps, three data tables, three helpers, one HashCandidate record, one CLI shell. Read 03-IMPLEMENTATION.md next and we'll walk every line of hash_identifier.py together.