Cybersecurity-Projects/PROJECTS/foundations/hash-identifier/learn/02-ARCHITECTURE.md

354 lines
26 KiB
Markdown

# Architecture
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.
## 1. The big picture
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.
## 2. Data flow on a single run
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.
## 3. The six-step decision pipeline
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**:
1. PHC prefixes are dead giveaways — the hash itself names the algorithm.
2. Special shapes (NetNTLM, MySQL5, DES crypt) are also strong: they have distinctive structures.
3. Hex + length is a *narrowing* signal, not a definitive one — it picks a family, not a member.
4. Generic PHC fallback catches hashes that *look* PHC-shaped but aren't in our table.
5. Shape hints handle the common "I pasted the wrong thing" case (people drop JWTs into hash identifiers all the time).
6. 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.
## 4. The HashCandidate object
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=True`** means once you build a `HashCandidate`, you can't mutate it. If somewhere in the code tried `candidate.algorithm = "something else"`, Python would raise `FrozenInstanceError`. This makes the data flow predictable: a candidate that comes out of `identify()` is the same candidate everywhere it shows up later.
- **`slots=True`** is 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.
## 5. The data tables as the source of truth
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:
1. **Adding a new format is one line.** No new function, no new test scaffolding to write.
2. **The rules are inspectable.** You can read `PREFIX_RULES` and immediately see every format the tool knows.
3. **The rules are testable.** The test file iterates over `PREFIX_RULES` and 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.
## 6. Two helper functions
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.
## 7. The CLI layer
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.
## 8. The test file mirrors the brain
`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.
## 9. Why pure functions matter here
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](https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell) pattern. It's worth learning the name because once you see it, you'll spot it everywhere.
## 10. Next up
You now know the shape: three layers, six steps, three data tables, three helpers, one `HashCandidate` record, one CLI shell. Read **[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** next and we'll walk every line of `hash_identifier.py` together.