13 KiB
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_devicetables). 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 byserver.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:
{ "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) +
settingsgear icon button (right). Gear opens a dialog: handshake location (fromGET /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}}whereformatderives fromextension(pcap→PCAP,22000→Hashcat, elseUnknown) — e.g.Full PCAP,Hashcat. - Captured: localized date-time of
timestamp. - Message 1–4 / Beacon Frame: when
in_dbis true → green check if the bit (m1..m4,beacon) is set, red X if not. Whenin_dbis 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_downloadicon button +deleteicon button (warn color).
- Empty state:
"No Handshakes Available". - Component logic (from bundle):
getHandshakes()maps each record'sextension→formatand computesm1=part_mask&1 … m4=part_mask&8;deleteHandshake(hs)callsDELETE /api/pineap/handshakes/deletewith the record as body, then reloads and flashes success/error;downloadHandshakesaves the file as<mac>_<client>_<type>.<extension>.
3. Pager data model
- Loot files:
/root/loot/handshakes/(Pager native path; matches pager-webuiLOOT_HS_DIR). Native naming inferred from on-device payloads (handshake_sanitiser,deduplicate,handshake_2_usb) andpineapdstrings:- 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.
- full:
- recon.db
handshaketable:hash, scan, stahash, aphash, time, beacon BLOB, hs1 BLOB, hs2 BLOB, hs3 BLOB, hs4 BLOB. The pager-webui resolvesaphash/stahash→ MACs viawifi_device(pattern already used byrecon_scan_data). This suppliespart_mask,beacon,in_db, and a fallback timestamp. - Sources: the Pager has no Evil-WPA/2-Twin mode, so
sourceis always"Recon"andtypeisfull/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:
os.listdir(LOOT_HS_DIR)+os.statper file (existing).- If no files → return
{files, handshakes: []}immediately — zero DB reads (the compute mitigation). - Parse each filename (see §4.2) →
{ap, client, kind, ext, ts}. - 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_rowscall;handshake.timeis epoch seconds). Build a dict keyed by normalized(ap, sta)keeping the latest row. - 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 |
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 inLOOT_HS_DIR, return updatedhandshakes_data(). Mirrors Mark VII "delete all".- Existing
GET /api/pineap/handshakes/{name}(download) andDELETE /api/pineap/handshakes{name}(per-row delete) unchanged. Optionally set the download'sContent-Dispositionfilename 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 existinghsType()(PCAP / Hashcat / Unknown). - Captured:
fmtTime(timestamp). - Message 1–4:
!in_db→?icon withtitletooltip (Mark VII wording); else check icon whenpart_mask & bit, X icon when not. - Beacon Frame:
!in_db→?icon; else check whenbeacon, X when not. - Action:
file_downloadicon btn →window.location = apiBase + '/api/pineap/handshakes/' + encodeURIComponent(name);deleteicon 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_rowscall, scoped byWHERE 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_rowscalls 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.timeepoch-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_rowsnot 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/locationreturns the loot dir;DELETE …/handshakes/allremoves all files and returns updated data.
- Front-end: manual on-device pass — table renders, glyphs in both
in_dbstates, download filename, delete flash, settings dialog, delete-all, empty state, dark theme, dashboard unchanged (stillfiles). - Deploy via
scripts/deploy.ps1;curlthe 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_handshakerows in this table (Pager'sloghandshakeis EAPOL; PMKID handling is separate). - Changing the dashboard's handshake card/table (it keeps using
files).