275 lines
13 KiB
Markdown
275 lines
13 KiB
Markdown
# Handshakes Mark VII Parity — Design Spec
|
||
|
||
- **Date:** 2026-08-11
|
||
- **Status:** Draft (pending user review)
|
||
- **Owner:** WiFi Pineapple Pager expansion project
|
||
- **Applies to:** `payload/user/general/pager-webui/server.py`,
|
||
`payload/user/general/pager-webui/www/`, `tests/`
|
||
- **Supercedes §3.3 of:** `docs/superpowers/specs/2026-08-11-recon-markvii-design.md`
|
||
(that section restyled the file-listing table; this spec replaces it with the
|
||
real Mark VII handshake table)
|
||
|
||
## 1. Goal
|
||
|
||
Rework the Pager WebUI **Recon → Handshakes** tab (`#/recon/handshakes`) so it
|
||
looks and behaves like the stock Mark VII handshakes page: the same table
|
||
(**BSSID, Client, Source, Type, Captured, Message 1–4, Beacon Frame**, action
|
||
icons) with the same **Download** and **Delete** behavior, plus the **settings
|
||
dialog** (handshake location + "Delete All Handshakes").
|
||
|
||
Built **entirely from Pager-local data** (loot files in `/root/loot/handshakes`
|
||
+ the recon.db `handshake`/`wifi_device` tables). The Pager's Go daemon does
|
||
**not** expose `/api/pineap/handshakes` (verified 404 on-device +
|
||
`docs/specs/2026-08-11-pineapple-ui-clone-design.md`), so the Mark VII shape is
|
||
synthesized by `server.py`, matching how every other PineAP/Recon endpoint is
|
||
already implemented.
|
||
|
||
## 2. Mark VII reference (captured 2026-08-11)
|
||
|
||
Source of truth: live `GET /api/pineap/handshakes` on `172.16.42.1:1471`
|
||
(hak5pineapple) and the handshakes component in the cached Angular bundle
|
||
`%TEMP%\opencode\oldui-main.js`.
|
||
|
||
### 2.1 API shape
|
||
|
||
`GET /api/pineap/handshakes` → `{ "handshakes": [ { ... } ] }`. Each record:
|
||
|
||
```json
|
||
{ "file_exists": true,
|
||
"mac": "86:18:98:BE:CF:A5", // AP/BSSID (colon MAC, case as stored)
|
||
"client": "D6:9B:56:ED:28:82", // client MAC, or an Evil-Twin label
|
||
"source": "Recon", // "Recon" | "Evil WPA/2 Twin"
|
||
"type": "full", // "full" | "eviltwin"
|
||
"timestamp": "2023-12-05T16:03:18Z",
|
||
"in_db": true, // handshake also present in recon DB
|
||
"part_mask": 15, // bit 1=m1, 2=m2, 4=m3, 8=m4
|
||
"beacon": true,
|
||
"extension": "pcap", // pcap | 22000
|
||
"location": "/root/handshakes/86-18-98-BE-CF-A5_D6-9B-56-ED-28-82_full.pcap" }
|
||
```
|
||
|
||
### 2.2 Page
|
||
|
||
- Card title row: **"Captured WPA Handshakes"** (left) + `settings` gear icon
|
||
button (right). Gear opens a dialog: handshake **location** (from
|
||
`GET /api/pineap/handshakes/location`) and a **Delete All Handshakes** button
|
||
(`DELETE /api/pineap/handshakes/`). On success/error a small green check /
|
||
red X flashes beside the title for ~3s / ~5s.
|
||
- Table columns: `BSSID, Client, Source, Type, Captured, Message 1, Message 2,
|
||
Message 3, Message 4, Beacon Frame, [action]`.
|
||
- **BSSID / Client**: colon MACs as returned.
|
||
- **Source**: `"Recon"` or `"Evil WPA/2 Twin"`.
|
||
- **Type**: `{{type|titlecase}} {{format}}` where `format` derives from
|
||
`extension` (`pcap`→`PCAP`, `22000`→`Hashcat`, else `Unknown`) — e.g.
|
||
`Full PCAP`, `Hashcat`.
|
||
- **Captured**: localized date-time of `timestamp`.
|
||
- **Message 1–4 / Beacon Frame**: when `in_db` is true → green **check** if
|
||
the bit (`m1..m4`, `beacon`) is set, red **X** if not. When `in_db` is
|
||
false → grey **?** icon with tooltip "This information isn't available.
|
||
This is common when a handshake file has been found, but the associated
|
||
Recon scan has been lost or deleted."
|
||
- **[action]**: `file_download` icon button + `delete` icon button (warn
|
||
color).
|
||
- Empty state: `"No Handshakes Available"`.
|
||
- Component logic (from bundle): `getHandshakes()` maps each record's
|
||
`extension`→`format` and computes `m1=part_mask&1 … m4=part_mask&8`;
|
||
`deleteHandshake(hs)` calls `DELETE /api/pineap/handshakes/delete` with the
|
||
record as body, then reloads and flashes success/error; `downloadHandshake`
|
||
saves the file as `<mac>_<client>_<type>.<extension>`.
|
||
|
||
## 3. Pager data model
|
||
|
||
- **Loot files:** `/root/loot/handshakes/` (Pager native path; matches
|
||
pager-webui `LOOT_HS_DIR`). Native naming inferred from on-device payloads
|
||
(`handshake_sanitiser`, `deduplicate`, `handshake_2_usb`) and `pineapd`
|
||
strings:
|
||
- full: `<unix_ts>_<AP_MAC>_<CLIENT_MAC>_handshake.pcap|.22000`
|
||
- partial: `<unix_ts>_<AP_MAC>_<CLIENT_MAC>_handshake_partial.pcap|.22000`
|
||
- incomplete: `…_handshake_incomplete.pcap|.22000`
|
||
- MACs colon-separated (may be uppercase/lowercase); Windows-normalised
|
||
copies use dashes. The parser must accept both.
|
||
- **recon.db `handshake` table:** `hash, scan, stahash, aphash, time,
|
||
beacon BLOB, hs1 BLOB, hs2 BLOB, hs3 BLOB, hs4 BLOB`. The pager-webui
|
||
resolves `aphash/stahash` → MACs via `wifi_device` (pattern already used by
|
||
`recon_scan_data`). This supplies `part_mask`, `beacon`, `in_db`, and a
|
||
fallback timestamp.
|
||
- **Sources:** the Pager has no Evil-WPA/2-Twin mode, so `source` is always
|
||
`"Recon"` and `type` is `full` / `partial` / `incomplete`.
|
||
|
||
## 4. Backend (`server.py`)
|
||
|
||
### 4.1 `handshakes_data()` → `{ files, handshakes }`
|
||
|
||
Keep the existing `files` array unchanged (the dashboard reads it). Add a
|
||
`handshakes` array in the Mark VII shape. Build order:
|
||
|
||
1. `os.listdir(LOOT_HS_DIR)` + `os.stat` per file (existing).
|
||
2. **If no files → return `{files, handshakes: []}` immediately — zero DB
|
||
reads** (the compute mitigation).
|
||
3. Parse each filename (see §4.2) → `{ap, client, kind, ext, ts}`.
|
||
4. **One batched correlation read** (only when files exist):
|
||
`SELECT h.time, (h.hs1 IS NOT NULL AND length(h.hs1)>0) AS m1,
|
||
(h.hs2 IS NOT NULL AND length(h.hs2)>0) AS m2,
|
||
(h.hs3 IS NOT NULL AND length(h.hs3)>0) AS m3,
|
||
(h.hs4 IS NOT NULL AND length(h.hs4)>0) AS m4,
|
||
(h.beacon IS NOT NULL AND length(h.beacon)>0) AS beacon,
|
||
w1.mac AS ap, w2.mac AS sta
|
||
FROM handshake h
|
||
JOIN wifi_device w1 ON w1.hash = h.aphash
|
||
JOIN wifi_device w2 ON w2.hash = h.stahash
|
||
WHERE h.time >= <oldest parsed file ts>
|
||
ORDER BY h.time`
|
||
(a single `_db_rows` call; `handshake.time` is epoch seconds). Build a dict
|
||
keyed by normalized `(ap, sta)` keeping the latest row.
|
||
5. Compose one record per file (§4.3). Pure Python dict lookups — O(1) per
|
||
file. The join resolves hashes to MACs directly, so exactly **one** sqlite
|
||
read is needed.
|
||
|
||
Total added cost per request: at most 1 `_db_rows` call, only when the loot
|
||
dir is non-empty; filename parsing is microseconds.
|
||
|
||
### 4.2 Filename parser
|
||
|
||
Tolerant regex, accepts colon or dash MACs, optional `_handshake` token,
|
||
optional quality suffix, any extension:
|
||
|
||
```
|
||
^(\d+)_([0-9a-fA-F-:]+)_([0-9a-fA-F-:]+)_handshake(?:_(full|partial|incomplete))?\.([A-Za-z0-9]+)$
|
||
```
|
||
|
||
Fallback: any unparseable file still appears as a row with
|
||
`mac:'--', client:'--', type:'full', source:'Recon'`, `in_db:false` — it
|
||
keeps Download/Delete and shows "?" glyphs (matches Mark VII's
|
||
file-without-DB-record presentation).
|
||
|
||
### 4.3 Record composition (per file)
|
||
|
||
| field | source |
|
||
|---|---|
|
||
| `mac` | parsed AP MAC (colon, case as stored) |
|
||
| `client` | parsed client MAC (or `--`) |
|
||
| `source` | `"Recon"` |
|
||
| `type` | `full` / `partial` / `incomplete` (from filename; default `full`) |
|
||
| `timestamp` | epoch seconds (parsed `ts` prefix; fallback `st_mtime`; DB `time` wins if present) |
|
||
| `in_db` | bool — matching `(ap,sta)` row in the correlation dict |
|
||
| `part_mask` | `m1|m2|m3|m4` from DB row (0 when not in DB) |
|
||
| `beacon` | bool from DB row (false when not in DB) |
|
||
| `extension` | file extension |
|
||
| `name` | bare filename (pager-webui convenience field; used for download/delete) |
|
||
| `location` | `LOOT_HS_DIR + '/' + name` |
|
||
| `file_exists` | true |
|
||
|
||
### 4.4 New routes
|
||
|
||
- `GET /api/pineap/handshakes/location` → `{location: LOOT_HS_DIR}`
|
||
(mirrors Mark VII; read-only).
|
||
- `DELETE /api/pineap/handshakes/all` → remove every file in `LOOT_HS_DIR`,
|
||
return updated `handshakes_data()`. Mirrors Mark VII "delete all".
|
||
- Existing `GET /api/pineap/handshakes/{name}` (download) and
|
||
`DELETE /api/pineap/handshakes` `{name}` (per-row delete) unchanged.
|
||
Optionally set the download's `Content-Disposition` filename to the Mark VII
|
||
`<mac>_<client>_<type>.<ext>` form when the name parses.
|
||
|
||
## 5. Frontend (`www/`)
|
||
|
||
### 5.1 `views.recon_handshakes` (rewrite of `views.js:911`)
|
||
|
||
Keep: page title "Recon", `RECON_TABS` tab bar, card title row
|
||
"Captured WPA Handshakes" + gear, and the Pager-specific
|
||
`Download all (zip)` / `Archive` / `Refresh` row (user decision: keep, don't
|
||
match Mark VII pixel-for-pixel there). Replace the file table with the Mark
|
||
VII table:
|
||
|
||
- Custom table build (the generic `table()` helper only renders text; this
|
||
needs glyphs + icon buttons), header row exactly:
|
||
`BSSID, Client, Source, Type, Captured, Message 1, Message 2, Message 3,
|
||
Message 4, Beacon Frame, ""`.
|
||
- Cells:
|
||
- **BSSID / Client**: text.
|
||
- **Source**: `"Recon"`.
|
||
- **Type**: `${title(type)} ${format}` via existing `hsType()` (PCAP /
|
||
Hashcat / Unknown).
|
||
- **Captured**: `fmtTime(timestamp)`.
|
||
- **Message 1–4**: `!in_db` → `?` icon with `title` tooltip (Mark VII
|
||
wording); else check icon when `part_mask & bit`, X icon when not.
|
||
- **Beacon Frame**: `!in_db` → `?` icon; else check when `beacon`, X when
|
||
not.
|
||
- **Action**: `file_download` icon btn → `window.location =
|
||
apiBase + '/api/pineap/handshakes/' + encodeURIComponent(name)`; `delete`
|
||
icon btn (warn) → `PagerAPI.del('/api/pineap/handshakes', {name})` then
|
||
reload — no confirm (matches Mark VII).
|
||
- Delete / delete-all success → green check flash; failure → red X + error
|
||
text flash (replaces the current silent `.catch`).
|
||
- Empty state text: `"No Handshakes Available"`.
|
||
- Settings gear opens the dialog (§5.2). The previous "toast" behavior is
|
||
removed.
|
||
|
||
### 5.2 Settings dialog
|
||
|
||
Simple modal overlay (Mark VII uses a 600px mat-dialog; replicate visually):
|
||
- Title "Handshake Settings" (approximation of the Mark VII dialog).
|
||
- **Handshake Location**: read-only value from
|
||
`GET /api/pineap/handshakes/location`.
|
||
- **Delete All Handshakes** button → `PagerAPI.del('/api/pineap/handshakes/all')`
|
||
→ success flash + reload table + close.
|
||
- Close button / click-outside to dismiss.
|
||
|
||
### 5.3 `js/icons.js`
|
||
|
||
Add inline Material SVG paths: `check`, `close`, `question_mark` (the Mark VII
|
||
uses `assets/icons/question-mark.svg`; an inline `help` glyph is equivalent).
|
||
`settings`, `file_download`, `delete`, `refresh` already exist.
|
||
|
||
### 5.4 `css/app.css`
|
||
|
||
Add (light + `html.dark`): `.hs-cell-center` (M1–4/Beacon centering),
|
||
`.hs-ok` (green check), `.hs-bad` (red X), `.hs-na` (grey `?`),
|
||
`.hs-flash` / `.hs-flash-ok` / `.hs-flash-error` (title-row indicator),
|
||
`.modal-overlay`, `.modal`, `.modal-title`, `.modal-actions`, `.modal-body`.
|
||
Reuse existing `--surface`/`--border`/`--primary` tokens.
|
||
|
||
## 6. Compute-cost mitigation (user requirement)
|
||
|
||
- No handshake files → **no** DB reads, no subprocess spawns.
|
||
- Files exist → **exactly 1** `_db_rows` call, scoped by `WHERE h.time >=
|
||
<oldest file ts>` so a sparse capture never scans the full recon.db history.
|
||
- Correlation is dict-based (O(1) per file); filename parsing is pure string
|
||
work.
|
||
- No polling added to the view — loads on mount + manual Refresh (unchanged).
|
||
- Context: the recon scanning view already issues ~6 `_db_rows` calls every
|
||
10s; two extra on page load is negligible.
|
||
|
||
## 7. Verification notes (implementation-time, not blockers)
|
||
|
||
- **File naming / quality suffix**: inferred from on-device payloads, not yet
|
||
observed live (no handshakes captured). The tolerant parser + `--`/`?`
|
||
fallbacks keep the UI correct for any naming variant; a real capture should
|
||
be sanity-checked if one can be produced.
|
||
- **hs1–hs4/beacon population**: recon.db schema includes them; if the daemon
|
||
leaves them NULL the table still renders (all X for in-DB rows).
|
||
- `handshake.time` epoch-seconds assumption matches the existing recon reads.
|
||
|
||
## 8. Testing
|
||
|
||
- Python (`tests/test_recon.py`, same mock style as existing):
|
||
- `handshakes_data()` returns `{files, handshakes}`; empty dir → no
|
||
DB-helper calls (assert mocked `_db_rows` not invoked).
|
||
- Filename parser: full/partial/incomplete × pcap/22000, colon and dash
|
||
MACs, unparseable fallback.
|
||
- Correlation: canned handshake/wifi_device rows → correct `part_mask`,
|
||
`beacon`, `in_db`, `timestamp`.
|
||
- `GET …/handshakes/location` returns the loot dir; `DELETE
|
||
…/handshakes/all` removes all files and returns updated data.
|
||
- Front-end: manual on-device pass — table renders, glyphs in both
|
||
`in_db` states, download filename, delete flash, settings dialog,
|
||
delete-all, empty state, dark theme, dashboard unchanged (still `files`).
|
||
- Deploy via `scripts/deploy.ps1`; `curl` the changed JS/CSS for 200.
|
||
|
||
## 9. Out of scope
|
||
|
||
- Evil WPA/2 Twin handshakes (`source:"Evil WPA/2 Twin"`) — no Pager mode.
|
||
- Editable handshake location (`PUT /api/pineap/handshakes/location`).
|
||
- PMKID / `hostap_handshake` rows in this table (Pager's `loghandshake` is
|
||
EAPOL; PMKID handling is separate).
|
||
- Changing the dashboard's handshake card/table (it keeps using `files`).
|