287 lines
11 KiB
Markdown
287 lines
11 KiB
Markdown
# System Architecture
|
|
|
|
This document explains how the tool is put together and why it is split the way
|
|
it is.
|
|
|
|
## High Level Architecture
|
|
|
|
```
|
|
┌──────────────┐
|
|
token ───▶│ main.py │ Typer CLI: decode, audit, crack
|
|
└──────┬───────┘
|
|
│
|
|
▼
|
|
┌──────────────┐
|
|
│ decoder.py │ split into 3 parts, decode header/payload
|
|
└──────┬───────┘
|
|
│ DecodedToken
|
|
▼
|
|
┌──────────────┐ ┌──────────────┐
|
|
│ checks.py │─────▶│ signatures.py│ HMAC sign/verify/crack
|
|
└──────┬───────┘ └──────────────┘
|
|
│ list[Finding] ▲
|
|
▼ │ COMMON_SECRETS
|
|
┌──────────────┐ ┌──────────────┐
|
|
│ models.py │ │ wordlist.py │
|
|
│ AuditReport │ └──────────────┘
|
|
└──────┬───────┘
|
|
│
|
|
▼
|
|
┌──────────────┐
|
|
│ output.py │ Rich table or JSON
|
|
└──────────────┘
|
|
```
|
|
|
|
### Component Breakdown
|
|
|
|
**decoder.py**
|
|
- Purpose: turn a token string into structured data.
|
|
- Responsibilities: split on dots, base64url decode, JSON parse, expose the
|
|
signing input.
|
|
- Interfaces: `decode(str) -> DecodedToken`. Raises `InvalidTokenError`.
|
|
|
|
**signatures.py**
|
|
- Purpose: everything involving the HMAC primitive.
|
|
- Responsibilities: sign, verify in constant time, crack a wordlist, run the
|
|
key confusion test.
|
|
- Interfaces: pure functions that take a `DecodedToken` and bytes.
|
|
|
|
**checks.py**
|
|
- Purpose: the security policy. Each check is one rule.
|
|
- Responsibilities: inspect a `DecodedToken`, return `Finding` objects, and
|
|
orchestrate all checks in `audit()`.
|
|
- Interfaces: `audit(token, ...) -> AuditReport`, plus each `check_*` function.
|
|
|
|
**models.py**
|
|
- Purpose: the shared vocabulary.
|
|
- Responsibilities: define `Severity`, `Finding`, `AuditReport`, and compute the
|
|
risk score.
|
|
- Interfaces: dataclasses and one enum. No behavior beyond scoring.
|
|
|
|
**output.py**
|
|
- Purpose: presentation.
|
|
- Responsibilities: render a report as a Rich table or as a JSON dict.
|
|
- Interfaces: `render_report`, `render_decoded`, `report_to_dict`.
|
|
|
|
**main.py**
|
|
- Purpose: wire it together for a human.
|
|
- Responsibilities: parse arguments, read the token, call the right functions,
|
|
set the exit code.
|
|
|
|
## Data Flow
|
|
|
|
### Auditing a token
|
|
|
|
Step by step of what happens on `jwt-auditor audit <token>`:
|
|
|
|
```
|
|
1. main.audit_command reads the token (src/jwt_auditor/main.py)
|
|
resolves it from arg, --input-file, or stdin
|
|
|
|
2. decoder.decode parses it (src/jwt_auditor/decoder.py:90)
|
|
returns a DecodedToken, or exits 2 on a bad token
|
|
|
|
3. checks.audit runs every check (src/jwt_auditor/checks.py:391)
|
|
each check appends zero or more Finding objects
|
|
|
|
4. models.AuditReport scores the findings (src/jwt_auditor/models.py)
|
|
risk_score and highest_severity are computed properties
|
|
|
|
5. output renders the report (src/jwt_auditor/output.py)
|
|
table by default, JSON with --json
|
|
|
|
6. main sets the exit code (src/jwt_auditor/main.py)
|
|
non-zero if a finding reaches --fail-level
|
|
```
|
|
|
|
## Design Patterns
|
|
|
|
### Checks as small pure functions
|
|
|
|
**What it is:** every check is a standalone function of shape
|
|
`check_x(token, ...) -> list[Finding]`.
|
|
|
|
**Where we use it:** all of `checks.py`, for example `check_alg_none`
|
|
(`src/jwt_auditor/checks.py:76`) and `check_expiration`
|
|
(`src/jwt_auditor/checks.py:238`).
|
|
|
|
**Why we chose it:** a check that returns data instead of printing is trivial to
|
|
test. `test_checks.py` calls each one directly with a crafted token and asserts
|
|
on the returned findings. There is no need to capture stdout or mock a console.
|
|
|
|
**Trade-offs:**
|
|
- Pros: isolated, testable, easy to add a new check.
|
|
- Cons: `audit()` has to know the list of checks and call each one. That list
|
|
lives in one place (`src/jwt_auditor/checks.py:409`) so it is easy to find.
|
|
|
|
### Separating policy from presentation
|
|
|
|
The checks decide *what* is wrong. `output.py` decides *how* it looks. They
|
|
never mix. That is why the same `AuditReport` renders as a table for a human and
|
|
as JSON for a script with no duplicated logic.
|
|
|
|
## Layer Separation
|
|
|
|
```
|
|
┌───────────────────────────────────────┐
|
|
│ CLI layer: main.py │
|
|
│ - argument parsing, exit codes │
|
|
│ - does not implement any check │
|
|
└───────────────────────────────────────┘
|
|
↓
|
|
┌───────────────────────────────────────┐
|
|
│ Logic layer: checks.py, signatures.py │
|
|
│ - the actual security rules │
|
|
│ - no printing, no Typer, no Rich │
|
|
└───────────────────────────────────────┘
|
|
↓
|
|
┌───────────────────────────────────────┐
|
|
│ Data layer: decoder.py, models.py │
|
|
│ - parse the token, hold the results │
|
|
│ - no policy decisions │
|
|
└───────────────────────────────────────┘
|
|
```
|
|
|
|
### Why Layers?
|
|
|
|
- You can import `jwt_auditor.audit` in your own script and never touch the CLI.
|
|
- A test can build a `DecodedToken` and call one check with no I/O.
|
|
- Swapping the output format touches one file.
|
|
|
|
### What Lives Where
|
|
|
|
**Logic layer:**
|
|
- Files: `checks.py`, `signatures.py`.
|
|
- Imports: `decoder`, `models`, `wordlist`.
|
|
- Forbidden: importing `rich` or `typer`. If a check needs to print, the design
|
|
is wrong.
|
|
|
|
**Data layer:**
|
|
- Files: `decoder.py`, `models.py`.
|
|
- Forbidden: making security decisions. `decoder.decode` never rejects a token
|
|
for being insecure, only for being malformed. The `alg: none` token decodes
|
|
fine so a check can flag it.
|
|
|
|
## Data Models
|
|
|
|
### DecodedToken
|
|
|
|
```python
|
|
@dataclass
|
|
class DecodedToken:
|
|
raw: str
|
|
header: dict[str, Any]
|
|
payload: dict[str, Any]
|
|
signature: bytes
|
|
signing_input: bytes # header_b64 + "." + payload_b64, ASCII bytes
|
|
header_b64: str
|
|
payload_b64: str
|
|
signature_b64: str
|
|
```
|
|
|
|
**Fields explained:**
|
|
- `signing_input`: the exact bytes any signature is computed over. Storing it
|
|
here means `signatures.py` never re-derives it and cannot get it subtly wrong.
|
|
- `signature`: the raw decoded bytes, empty for an `alg: none` token.
|
|
|
|
### Finding and Severity
|
|
|
|
```python
|
|
class Severity(Enum):
|
|
CRITICAL = ("critical", 10.0)
|
|
HIGH = ("high", 7.0)
|
|
MEDIUM = ("medium", 4.0)
|
|
LOW = ("low", 2.0)
|
|
INFO = ("info", 0.5)
|
|
```
|
|
|
|
The weight drives the risk score. The rank (declaration order) drives sorting.
|
|
Keeping both on the enum means there is one source of truth.
|
|
|
|
## Security Architecture
|
|
|
|
### Threat Model
|
|
|
|
What the tool assumes about the person running it: they hold a token and want to
|
|
know if it is safe. What it protects the *user* from is shipping a bad token.
|
|
|
|
What we are analyzing for:
|
|
1. Forgeable tokens (`none`, weak secret, confusion).
|
|
2. Tokens that leak data (sensitive claims).
|
|
3. Tokens that live too long (missing or long `exp`).
|
|
|
|
Out of scope:
|
|
- Verifying real RSA or ECDSA signatures. That needs a crypto library and is a
|
|
different job. We audit configuration, not production traffic.
|
|
- Fetching keys or tokens over the network. Everything is offline by design.
|
|
|
|
## Configuration
|
|
|
|
There are no config files. All behavior comes from flags, parsed in `main.py`.
|
|
The two that change results rather than formatting are `--wordlist` (which
|
|
secrets to try) and `--public-key` (enables the proven confusion test).
|
|
|
|
## Performance Considerations
|
|
|
|
### Bottlenecks
|
|
|
|
The only loop that can get slow is the secret crack in `crack_hmac_secret`. It
|
|
is linear in the wordlist size, one HMAC per candidate. With `rockyou.txt` at
|
|
about 14 million lines that is 14 million HMACs, which still runs in seconds
|
|
because HMAC-SHA256 is fast and there is no I/O per candidate.
|
|
|
|
### Optimizations
|
|
|
|
The crack returns on the first match rather than scanning the whole list. For a
|
|
weak secret near the top of a list, it finishes almost immediately.
|
|
|
|
## Error Handling Strategy
|
|
|
|
### Error Types
|
|
|
|
1. **Malformed token** - `decoder.decode` raises `InvalidTokenError`. The CLI
|
|
catches it in `_decode_or_exit` and exits 2 with a clear message.
|
|
2. **Bad flag value** - for example an unknown `--fail-level`. Raised as
|
|
`typer.BadParameter`, which Typer renders as a usage error.
|
|
|
|
We never catch a broad `Exception` and continue. A malformed token is a real
|
|
answer ("this is not a JWT"), not something to paper over.
|
|
|
|
## Extensibility
|
|
|
|
### Where to Add a Check
|
|
|
|
1. Write `check_yourthing(token, ...) -> list[Finding]` in `checks.py`.
|
|
2. Add one line to `audit()` to call it
|
|
(`src/jwt_auditor/checks.py:409`).
|
|
3. Add a test in `tests/test_checks.py`.
|
|
|
|
That is the whole process. Because output and scoring are generic over
|
|
`Finding`, a new check shows up in the table, the JSON, and the risk score with
|
|
no other changes.
|
|
|
|
## Limitations
|
|
|
|
1. **No asymmetric verification.** We cannot tell you an RS256 signature is
|
|
valid, only reason about the algorithm handling. Fixing this means adding the
|
|
`cryptography` dependency, a conscious trade-off against staying standard
|
|
library only.
|
|
2. **Heuristic sensitive data check.** It matches claim names, not values. A
|
|
secret in a claim called `data` slips past. That is the cost of not guessing
|
|
at every string.
|
|
|
|
These are trade-offs, not bugs. `04-CHALLENGES.md` turns several of them into
|
|
exercises.
|
|
|
|
## Key Files Reference
|
|
|
|
- `src/jwt_auditor/decoder.py` - parsing.
|
|
- `src/jwt_auditor/signatures.py` - the HMAC primitive and attacks.
|
|
- `src/jwt_auditor/checks.py` - the security rules and `audit()`.
|
|
- `src/jwt_auditor/models.py` - types and risk scoring.
|
|
|
|
## Next Steps
|
|
|
|
Now that you understand the shape, read
|
|
[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) for the code itself.
|