mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-30 14:45:52 +00:00
fix: use packet hash instead of sender_timestamp for channel message dedup
Device clocks on MeshCore nodes are wildly inaccurate (off by hours or epoch-near values like 4). The channel messages endpoint was using sender_timestamp as part of the deduplication key, which could cause messages to fail deduplication or incorrectly collide. Changed dedupe key from sender:timestamp to sender:hash, which is the correct unique identifier for a transmission. Also added TIMESTAMP-AUDIT.md documenting all device timestamp usage.
This commit is contained in:
106
RELEASE-NOTES-DRAFT.md
Normal file
106
RELEASE-NOTES-DRAFT.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Release Notes Draft — v2.4.0 (WIP)
|
||||
|
||||
*Changes since v2.3.0 (March 21, 2026)*
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Map Improvements
|
||||
|
||||
- **Hash prefix labels on map** — repeater markers show their short hash ID (e.g. `5B`, `BEEF`) with byte-size indicator
|
||||
- **Label overlap prevention** — spiral deconfliction algorithm with callout lines for dense marker areas
|
||||
- **Hash prefix in node popup** — bold display with byte size
|
||||
- **Configurable map defaults** — center/zoom now configurable via `config.json` `mapDefaults` (#115)
|
||||
- **View on Map from distance leaderboard** — hop and path entries in distance analytics are clickable to view on map
|
||||
- **Route view improvements** — hide default markers, show origin node, label deconfliction on route markers
|
||||
- **ADVERT node names are clickable** — link to node detail page from map popups
|
||||
|
||||
## 📊 Analytics
|
||||
|
||||
- **Distance/Range analytics tab** — new tab with summary cards, link-type breakdown (R↔R, C↔R, C↔C), distance histogram, top 20 longest hops leaderboard, top 10 longest multi-hop paths
|
||||
- **300km max hop distance cap** — filters bogus hops from gateway artifacts (LoRa world record is ~250km)
|
||||
- **Channel hash displayed as hex** in analytics (#103)
|
||||
- **RF analytics region filtering fixed** — separated from SNR filtering, correct packet counts (#111)
|
||||
|
||||
## 🔒 Channels
|
||||
|
||||
- **Channel name resolution fixed** — uses decryption key, not just hash byte (#108)
|
||||
- **Simplified channel key scheme** — plain hash keys, no composite `ch_`/`unk_` prefixes
|
||||
- **`hashChannels` config** — derive channel keys from names via SHA256 instead of hardcoding hex keys
|
||||
- **Rainbow table** — pre-computed keys for ~30 common MeshCore channel names
|
||||
- **Encrypted messages hidden** — channel views only show successfully decrypted messages
|
||||
- **CHAN packet detail renderer** — dedicated display for decrypted channel messages
|
||||
- **Live channel updates** — channels page refreshes immediately on new messages via WebSocket
|
||||
|
||||
## 🌐 Regional Filters
|
||||
|
||||
- **Regional filters on all tabs** — packets, nodes, analytics, channels (#111)
|
||||
- **ADVERT-based node region filtering** — uses local broadcast data instead of data packet hashes for accurate geographic filtering
|
||||
- **Dropdown mode** — auto-switches to dropdown for >4 regions, forced dropdown on packets page
|
||||
- **Region filter UI** — labels, ARIA accessibility, consistent styling
|
||||
|
||||
## ⭐ Favorites
|
||||
|
||||
- **Favorites filter on live map** (#106) — filter packet animations and feed list for packets involving favorited nodes
|
||||
- **Packet-level filtering only** — all node markers stay visible regardless of filter
|
||||
|
||||
## 📦 Packet Handling
|
||||
|
||||
- **Realistic packet propagation mode** — "Realistic" toggle buffers WS packets by hash, animates all paths simultaneously
|
||||
- **Replay sends all observations** — ▶ button uses realistic propagation animation
|
||||
- **Propagation time in detail pane** — shows time spread across observers
|
||||
- **Paths-through section** — added to both desktop and mobile node detail panels
|
||||
- **Dedup observations** — UNIQUE constraint on (hash, observer_id, path_json), INSERT OR IGNORE
|
||||
- **ADVERT validation** — validates pubkey, lat/lon, name, role, timestamp before upserting nodes (#112)
|
||||
- **Tab backgrounding fix** — skip animations when tab hidden, resume cleanly (#114)
|
||||
|
||||
## 🚀 Performance
|
||||
|
||||
- **Client-side hop resolution** — eliminated all `/api/resolve-hops` server calls; hops resolved locally from cached node list
|
||||
- **3→1 API calls on packet group expand** — single fetch serves expand + detail panel
|
||||
- **In-memory packet store optimization** — build insert rows from incoming data instead of DB round-trip
|
||||
- **SQLite WAL auto-checkpoint disabled** — manual PASSIVE checkpoint every 5 minutes eliminates random 200ms+ event loop spikes
|
||||
- **Startup pre-warm deferred** — 5s delay lets initial client requests complete first; all pre-warm via HTTP self-requests with event loop yielding
|
||||
- **Shared cached node list** — replaced 8 separate `SELECT FROM nodes` queries across endpoints
|
||||
- **Cached path JSON parse** — avoid repeated JSON.parse on path_json fields
|
||||
- **Precomputed hash_size map** — `/api/nodes` from 50ms to 2ms
|
||||
- **Node paths endpoint rewrite** — uses `disambiguateHops()` with prefix index, 560ms→190ms
|
||||
- **Analytics distance optimization** — 3s→630ms cold cache
|
||||
- **Analytics topology optimization** — 289ms→193ms
|
||||
- **Observers endpoint optimization** — 3s→130ms
|
||||
- **Event loop max latency** — 3.2s→105ms (startup), steady-state p95 under 200ms
|
||||
|
||||
## 🔧 Infrastructure
|
||||
|
||||
- **`packets_v` SQL view** — JOINs transmissions+observations, replacing direct queries to legacy `packets` table (prep for table drop)
|
||||
- **Hash-based URLs** — all user-facing URLs use `#/` routing for stability across restarts
|
||||
- **API key required** for POST `/api/packets` and `/api/perf/reset`
|
||||
- **HTTPS support** merged (PR #105, lincomatic)
|
||||
- **Graceful shutdown** merged (PR #109, lincomatic)
|
||||
- **`hashChannels` config** merged (PR #107, lincomatic)
|
||||
- **`config.example.json` updated** with hashChannels examples
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
- Hash size display shows "?" for null instead of defaulting to "1B"
|
||||
- Hash size uses newest ADVERT, not oldest or first-seen
|
||||
- Feed panel position raised to clear VCR bar
|
||||
- Hop disambiguation anchored from sender origin, not just observer
|
||||
- Packet hash case normalized for deeplink lookups
|
||||
- Region filter no longer resets analytics tab to overview
|
||||
- Network status missing `?` in region query string fixed
|
||||
- Null safety for analytics stats in empty regions
|
||||
- BYOP button accessibility (aria-labels, focus styles)
|
||||
- btn-icon contrast on dark backgrounds
|
||||
|
||||
## ⚠️ Known Issues / Still TODO
|
||||
|
||||
- Legacy `packets` table (308K rows) and `paths` table (1.7M rows) still in DB — pending drop after bake period (will shrink DB from ~381MB to ~80MB)
|
||||
- Channel decryption in prod needs hashChannels config update
|
||||
- Three-tier cache TTLs agreed but not yet applied
|
||||
- Per-region subpath precomputation at startup not yet implemented
|
||||
- Hardcoded health thresholds in `home.js` and `observer-detail.js`
|
||||
- Issues #28-32 still open (dead code, Leaflet SRI, empty states, dark mode)
|
||||
|
||||
---
|
||||
|
||||
*67 commits since v2.3.0*
|
||||
61
TIMESTAMP-AUDIT.md
Normal file
61
TIMESTAMP-AUDIT.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Timestamp Audit — Device vs Server Timestamps
|
||||
|
||||
**Date:** 2026-03-21
|
||||
**Problem:** MeshCore nodes have wildly inaccurate clocks (off by hours, or epoch-near values like `4`). Device-originated timestamps (`sender_timestamp`, `advert.timestamp`) are unreliable and should not be used for logic, sorting, or deduplication.
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. `decoder.js:104-108` — Advert timestamp decoding
|
||||
- **What:** Parses 4-byte LE unix timestamp from ADVERT packets into `timestamp` and `timestampISO`
|
||||
- **Used for:** Decode output only. The value is stored in `decoded_json` but never used for node storage or sorting.
|
||||
- **Risk:** None — `server.js:773` replaces it with `Date.now()` when creating bridge adverts, and `validateAdvert()` (line 314) explicitly skips timestamp validation.
|
||||
- **Action:** None needed.
|
||||
|
||||
### 2. `decoder.js:158` — `sender_timestamp: result.data.timestamp`
|
||||
- **What:** Extracts device timestamp from decrypted channel messages (GRP_TXT)
|
||||
- **Used for:** Passed through to API responses
|
||||
- **Risk:** Low — just decoding. But downstream usage matters (see #3).
|
||||
- **Action:** None needed at decode layer.
|
||||
|
||||
### 3. ⚠️ `server.js:2214` — **FIXED** — Dedupe key used `sender_timestamp`
|
||||
- **What:** `const ts = decoded.sender_timestamp || pkt.timestamp; const dedupeKey = sender:${ts}`
|
||||
- **Risk:** **HIGH** — If device clock is wrong, messages could fail to deduplicate (different sender_timestamp for same message seen by multiple observers) or incorrectly collide (same sender_timestamp for different messages).
|
||||
- **Fix:** Changed dedupe key to use `pkt.hash` instead of any timestamp: `const dedupeKey = sender:${pkt.hash}`. The packet hash is the correct deduplication identifier.
|
||||
|
||||
### 4. `server.js:2238` — `sender_timestamp` in API response
|
||||
- **What:** Returns `sender_timestamp` as a field in channel message API responses
|
||||
- **Used for:** Informational display only (frontend shows it as metadata)
|
||||
- **Risk:** Low — labeled as `sender_timestamp`, not used for sorting or logic
|
||||
- **Action:** None needed. Keeping it for debugging/informational purposes is fine.
|
||||
|
||||
### 5. `public/packets.js:1047` — Hex viewer display
|
||||
- **What:** Shows `sender_timestamp` in packet hex breakdown
|
||||
- **Used for:** Developer-facing hex analysis display
|
||||
- **Risk:** None — purely informational in hex decoder view
|
||||
- **Action:** None needed.
|
||||
|
||||
### 6. `public/channels.js:454` — Stores `sender_timestamp` on live message objects
|
||||
- **What:** Stores `sender_timestamp` on WebSocket message objects
|
||||
- **Used for:** Not actively used for sorting or display — `timestamp` (server) is used for all time displays
|
||||
- **Risk:** None
|
||||
- **Action:** None needed.
|
||||
|
||||
### 7. `decoder.js:314` — validateAdvert skips timestamp
|
||||
- **What:** Comment explicitly says "timestamp: decoded but not currently used for node storage — skip validation"
|
||||
- **Risk:** None — already properly handled
|
||||
- **Action:** None needed.
|
||||
|
||||
## All other `timestamp` references
|
||||
|
||||
All other `.timestamp` references in the codebase (`pkt.timestamp`, `p.timestamp`, `row.timestamp`, etc.) refer to the **server observation timestamp** — the ISO string set at packet ingestion time (`db.js:222`, `db.js:243`, `server.js:682/784/817/863`). These are reliable and correctly used for sorting, filtering, display, and analytics.
|
||||
|
||||
## Summary
|
||||
|
||||
| File | Line | Field | Usage | Risk | Action |
|
||||
|------|------|-------|-------|------|--------|
|
||||
| decoder.js | 104-108 | advert.timestamp | Decode | None | — |
|
||||
| decoder.js | 158 | sender_timestamp | Decode | None | — |
|
||||
| server.js | 2214 | sender_timestamp | **Dedupe key** | **HIGH** | **FIXED** |
|
||||
| server.js | 2238 | sender_timestamp | API response | Low | — |
|
||||
| public/packets.js | 1047 | sender_timestamp | Hex display | None | — |
|
||||
| public/channels.js | 454 | sender_timestamp | Store only | None | — |
|
||||
BIN
meshcore-hashtag-cracker-1.11.0.tgz
Normal file
BIN
meshcore-hashtag-cracker-1.11.0.tgz
Normal file
Binary file not shown.
108
package/LICENSE.md
Executable file
108
package/LICENSE.md
Executable file
@@ -0,0 +1,108 @@
|
||||
# Licenses
|
||||
|
||||
This project includes bundled dependencies. Their licenses are listed below, followed by the license for this project.
|
||||
|
||||
---
|
||||
|
||||
## crypto-js
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2009-2013 Jeff Mott
|
||||
Copyright (c) 2013-2016 Evan Vosberg
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
## @michaelhart/meshcore-decoder
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Michael Hart <michaelhart@michaelhart.me>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
## @noble/ed25519
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Paul Miller (https://paulmillr.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
## meshcore-hashtag-cracker
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Jack Kingsman <jack@jackkingsman.me>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
152
package/README.md
Executable file
152
package/README.md
Executable file
@@ -0,0 +1,152 @@
|
||||

|
||||
|
||||
# MeshCore GroupText Hashtag Room Cracker
|
||||
|
||||
Standalone library for cracking MeshCore GroupText packets from hashtag rooms using WebGPU-accelerated brute force (with fallbacks for our non-GPU brethren and dictionary attack support).
|
||||
|
||||
**Note:** This tool is designed exclusively for cracking public hashtag rooms (e.g., `#general`, `#test`). It does not support private rooms or other MeshCore encryption schemes (or, rather, it will attempt to crack them, but nearly certainly fail)
|
||||
|
||||
This is an LLM-developed library and has borne out its correctness in various application uses, but caution should still be applied in any mission-critical contexts.
|
||||
|
||||
## Features
|
||||
|
||||
- WebGPU-accelerated brute force (100M+ keys/second on modern GPUs)
|
||||
- Dictionary attack support with built-in English wordlist (482k words)
|
||||
- Configurable filters (sender, UTF-8, timestamp) to handle MAC collisions with sanity checks
|
||||
- Progress callbacks with ETA
|
||||
- Resume support for interrupted searches
|
||||
|
||||
## Installation
|
||||
|
||||
### npm
|
||||
|
||||
```bash
|
||||
npm install meshcore-hashtag-cracker
|
||||
```
|
||||
|
||||
### Browser (Direct Include)
|
||||
|
||||
For direct browser usage without a bundler, download [`browser/meshcore_cracker.min.js`](./browser/meshcore_cracker.min.js) and include it:
|
||||
|
||||
```html
|
||||
<script src="meshcore_cracker.min.js"></script>
|
||||
<script>
|
||||
const cracker = new MeshCoreCracker.GroupTextCracker();
|
||||
|
||||
// Optional: load a wordlist for dictionary attack (tried before GPU brute force)
|
||||
// await cracker.loadWordlist('https://example.com/words.txt');
|
||||
|
||||
cracker.crack('150013752F15A1BF3C018EB1FC4F26B5FAEB417BB0F1AE8FF07655484EBAA05CB9A927D689', {
|
||||
maxLength: 4
|
||||
}).then(result => {
|
||||
if (result.found) {
|
||||
console.log('Room:', result.roomName);
|
||||
console.log('Message:', result.decryptedMessage);
|
||||
}
|
||||
cracker.destroy();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { GroupTextCracker } from 'meshcore-hashtag-cracker';
|
||||
// Built-in 482k word English dictionary (tree-shakeable, ~4MB)
|
||||
// Dictionary is checked BEFORE GPU brute force - a room like #football
|
||||
// takes hours to brute force but milliseconds via dictionary lookup
|
||||
import { ENGLISH_WORDLIST } from 'meshcore-hashtag-cracker/wordlist';
|
||||
|
||||
const cracker = new GroupTextCracker();
|
||||
cracker.setWordlist(ENGLISH_WORDLIST);
|
||||
|
||||
// Example GroupText packet (hex string, no spaces or 0x prefix)
|
||||
const packetHex = '150013752F15A1BF3C018EB1FC4F26B5FAEB417BB0F1AE8FF07655484EBAA05CB9A927D689';
|
||||
|
||||
const result = await cracker.crack(packetHex, {
|
||||
maxLength: 6,
|
||||
});
|
||||
|
||||
if (result.found) {
|
||||
console.log(`Room: #${result.roomName}`);
|
||||
console.log(`Key: ${result.key}`);
|
||||
console.log(`Message: ${result.decryptedMessage}`);
|
||||
}
|
||||
|
||||
cracker.destroy();
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
Room: #aa
|
||||
Key: e147f36926b7b509af9b41b65304dc30
|
||||
Message: SenderName: Hello world!
|
||||
```
|
||||
|
||||
Note: When a sender is detected in the message, `decryptedMessage` includes the full "sender: message" format.
|
||||
|
||||
### Options
|
||||
|
||||
```typescript
|
||||
const result = await cracker.crack(packetHex, {
|
||||
maxLength: 8, // Max room name length to try (default: 8)
|
||||
startingLength: 1, // Min room name length to try (default: 1)
|
||||
useDictionary: true, // Try dictionary words first (default: true); needs cracker.setWordlist() called first
|
||||
useSenderFilter: true, // Reject messages without sender (default: true)
|
||||
useUtf8Filter: true, // Reject invalid UTF-8 (default: true)
|
||||
useTimestampFilter: true, // Reject old timestamps (default: true)
|
||||
validSeconds: 2592000, // Timestamp window in seconds (default: 30 days)
|
||||
forceCpu: false, // Force CPU mode, skip GPU (default: false)
|
||||
startFrom: 'abc', // Resume after this position (optional)
|
||||
startFromType: 'bruteforce', // 'dictionary', 'dictionary-pair', or 'bruteforce' (default: 'bruteforce')
|
||||
});
|
||||
```
|
||||
|
||||
For detailed API documentation, see [API.md](./API.md).
|
||||
|
||||
## Browser Requirements
|
||||
|
||||
- WebGPU support (Chrome 113+, Edge 113+, or other Chromium-based browsers)
|
||||
- HTTPS connection for non-localhost hostnames (falls back gracefully with an error if WebGPU is not available)
|
||||
|
||||
## Performance
|
||||
|
||||
Typical performance on modern hardware:
|
||||
- **GPU (RTX 3080)**: ~500M keys/second
|
||||
- **GPU (integrated)**: ~50M keys/second
|
||||
|
||||
Search space by room name length:
|
||||
| Length | Candidates | Time @ 100M/s |
|
||||
|--------|------------|---------------|
|
||||
| 1 | 36 | instant |
|
||||
| 2 | 1,296 | instant |
|
||||
| 3 | 47,952 | instant |
|
||||
| 4 | 1,774,224 | <1s |
|
||||
| 5 | 65,646,288 | <1s |
|
||||
| 6 | 2,428,912,656 | ~24s |
|
||||
| 7 | 89,869,768,272 | ~15min |
|
||||
| 8 | 3,325,181,426,064 | ~9h |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Build
|
||||
npm run build
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
|
||||
# Run tests in watch mode
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
This library uses town and placenames in the word list sourced from https://simplemaps.com/data/us-cities, used under CC BY 4.0.
|
||||
|
||||
This library also uses airport codes sourced from https://en.wikipedia.org/wiki/List_of_airports_in_the_United_States.
|
||||
744
package/browser/meshcore_cracker.min.js
vendored
Executable file
744
package/browser/meshcore_cracker.min.js
vendored
Executable file
File diff suppressed because one or more lines are too long
7
package/browser/meshcore_cracker.min.js.map
Executable file
7
package/browser/meshcore_cracker.min.js.map
Executable file
File diff suppressed because one or more lines are too long
653
package/browser/testbed.html
Executable file
653
package/browser/testbed.html
Executable file
@@ -0,0 +1,653 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MeshCore Cracker Testbed</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
}
|
||||
h1 { color: #00d4ff; margin-bottom: 5px; }
|
||||
.subtitle { color: #888; margin-bottom: 20px; }
|
||||
label { display: block; margin: 10px 0 5px; color: #aaa; }
|
||||
input[type="text"], input[type="number"] {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
background: #16213e;
|
||||
color: #fff;
|
||||
font-family: monospace;
|
||||
}
|
||||
.options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
margin: 15px 0;
|
||||
padding: 15px;
|
||||
background: #16213e;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.option input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.option input[type="number"] {
|
||||
width: 80px;
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
button {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
#crackBtn { background: #00d4ff; color: #000; }
|
||||
#continueBtn { background: #ff9f00; color: #000; }
|
||||
#abortBtn { background: #ff4757; color: #fff; }
|
||||
#clearBtn { background: #444; color: #fff; }
|
||||
.status {
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
.status.info { background: #16213e; border-left: 3px solid #00d4ff; }
|
||||
.status.success { background: #1e3a2f; border-left: 3px solid #2ed573; }
|
||||
.status.error { background: #3a1e1e; border-left: 3px solid #ff4757; }
|
||||
.progress {
|
||||
background: #16213e;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: #333;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00d4ff, #00ff88);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.results {
|
||||
background: #16213e;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.result-item {
|
||||
padding: 10px;
|
||||
margin: 5px 0;
|
||||
background: #1a1a2e;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #2ed573;
|
||||
}
|
||||
.result-item.false-positive {
|
||||
border-left-color: #ff9f00;
|
||||
}
|
||||
.result-label { color: #888; font-size: 12px; }
|
||||
.result-value { font-family: monospace; margin-top: 3px; word-break: break-all; }
|
||||
.log {
|
||||
background: #0a0a15;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.log-entry { padding: 2px 0; color: #888; }
|
||||
.log-entry.match { color: #2ed573; }
|
||||
.log-entry.error { color: #ff4757; }
|
||||
.gpu-status {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.gpu-status.available { background: #1e3a2f; color: #2ed573; }
|
||||
.gpu-status.unavailable { background: #3a2e1e; color: #ff9f00; }
|
||||
.preset-btn {
|
||||
padding: 6px 12px;
|
||||
background: #2a2a4a;
|
||||
color: #aaa;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.preset-btn:hover { background: #3a3a5a; color: #fff; }
|
||||
#autotestBtn {
|
||||
background: #2ed573;
|
||||
color: #000;
|
||||
font-size: 16px;
|
||||
padding: 14px 32px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
#autotestBtn:disabled { background: #555; color: #999; }
|
||||
#autotestPanel {
|
||||
background: #16213e;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
display: none;
|
||||
}
|
||||
#autotestPanel h3 { margin: 0 0 10px; color: #00d4ff; }
|
||||
.at-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
margin: 3px 0;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
.at-row .at-badge {
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
font-size: 11px;
|
||||
}
|
||||
.at-badge.pass { background: #2ed573; color: #000; }
|
||||
.at-badge.fail { background: #ff4757; color: #fff; }
|
||||
.at-badge.run { background: #ff9f00; color: #000; }
|
||||
.at-badge.wait { background: #444; color: #888; }
|
||||
.at-row .at-name { flex: 1; }
|
||||
.at-row .at-time { color: #888; font-size: 12px; flex-shrink: 0; }
|
||||
#autotestSummary {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
display: none;
|
||||
}
|
||||
#autotestSummary.all-pass { background: #1e3a2f; color: #2ed573; }
|
||||
#autotestSummary.has-fail { background: #3a1e1e; color: #ff4757; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>MeshCore Cracker Testbed <span id="gpuStatus" class="gpu-status">Checking...</span></h1>
|
||||
<p class="subtitle">Test GPU vs CPU cracking with arbitrary packets</p>
|
||||
|
||||
<button id="autotestBtn" disabled>Autotest (waiting for wordlist...)</button>
|
||||
<div id="autotestPanel">
|
||||
<h3>Autotest Results</h3>
|
||||
<div id="autotestRows"></div>
|
||||
<div id="autotestSummary"></div>
|
||||
</div>
|
||||
|
||||
<label for="packet">Packet (hex):</label>
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 8px;">
|
||||
<button type="button" class="preset-btn" data-packet="1502D36386F3BD71BEEB075B6BBBF1D747144E2383B11C6637531C1294DC66658DE02263558087C1AA966E5E26852CCC4688A7F387101D88C5B6997927A1127063D09501A132CC">Unknown</button>
|
||||
<button type="button" class="preset-btn" data-packet="1503A6DB9DCA168D85E6C402C0E0AF422FA633B990C6946DF20842B7F03E16531B2573B76BEE7FE1">Wordlist (#bot)</button>
|
||||
<button type="button" class="preset-btn" data-packet="150064F62ADF41BE89A05AC9BCFE04BA85D54BD402EDB6D973DE6F7B1811FB1FA8FD8888882429C57B8FC622C9E613F60890C46AF4">len=6 gibberish (#uetfwf)</button>
|
||||
<button type="button" class="preset-btn" data-packet="1503e653aeb374a86670654924ffbf1e535b549856b6caf87aff479a3d34c6cf9c29dc2e04ccd8be1e3a1af4c8c8a4f54ca18211a56466515fe29fa53e62d7290fde841fc42cc90682c864a80191725c71ea12fd979ed35b1c82915f459d54ce655d715e20fbe15fdd7835c60da6100914c360d281af168729b5b9d60189b6cb888869ca5306f4a9">Two-word key</button>
|
||||
</div>
|
||||
<input type="text" id="packet" placeholder="15001234abcd...">
|
||||
|
||||
<div style="font-size: 12px; color: #888; margin: 10px 0;">Wordlist: <span id="wordCount">0</span> words</div>
|
||||
|
||||
<div class="options">
|
||||
<div class="option">
|
||||
<input type="checkbox" id="useDictionary" checked>
|
||||
<label for="useDictionary">Dictionary first</label>
|
||||
</div>
|
||||
<div class="option">
|
||||
<input type="checkbox" id="useTimestamp" checked>
|
||||
<label for="useTimestamp">Timestamp filter</label>
|
||||
</div>
|
||||
<div class="option">
|
||||
<input type="checkbox" id="useUtf8" checked>
|
||||
<label for="useUtf8">UTF-8 filter</label>
|
||||
</div>
|
||||
<div class="option">
|
||||
<input type="checkbox" id="forceCpu">
|
||||
<label for="forceCpu">Force CPU</label>
|
||||
</div>
|
||||
<div class="option">
|
||||
<input type="checkbox" id="useTwoWordCombinations">
|
||||
<label for="useTwoWordCombinations">Two-word combos (experimental)</label>
|
||||
</div>
|
||||
<div class="option">
|
||||
<label for="maxLength">Max length:</label>
|
||||
<input type="number" id="maxLength" value="6" min="1" max="12">
|
||||
</div>
|
||||
<div class="option">
|
||||
<label for="gpuDispatchMs">GPU dispatch (ms):</label>
|
||||
<input type="number" id="gpuDispatchMs" value="1000" min="100" max="30000" style="width: 100px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<button id="crackBtn">Crack</button>
|
||||
<button id="continueBtn" disabled>Continue (find next)</button>
|
||||
<button id="abortBtn" disabled>Abort</button>
|
||||
<button id="clearBtn">Clear</button>
|
||||
</div>
|
||||
|
||||
<div id="statusArea"></div>
|
||||
|
||||
<div id="progressArea" style="display: none;">
|
||||
<div class="progress">
|
||||
<div>Phase: <span id="phase">-</span> | Length: <span id="currentLength">-</span></div>
|
||||
<div>Position: <span id="currentPosition">-</span></div>
|
||||
<div>Checked: <span id="checked">0</span> / <span id="total">0</span></div>
|
||||
<div class="progress-bar"><div class="progress-bar-fill" id="progressBar" style="width: 0%"></div></div>
|
||||
<div>Progress: <span id="percent">0</span>% | Rate: <span id="rate">0</span> keys/s | ETA: <span id="eta">-</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="resultsArea" class="results" style="display: none;">
|
||||
<h3>Results</h3>
|
||||
<div id="resultsList"></div>
|
||||
</div>
|
||||
|
||||
<div class="log" id="log"></div>
|
||||
|
||||
<script src="meshcore_cracker.min.js"></script>
|
||||
<script>
|
||||
const { GroupTextCracker } = MeshCoreCracker;
|
||||
|
||||
let cracker = null;
|
||||
let lastResult = null;
|
||||
let results = [];
|
||||
let wordlist = [];
|
||||
|
||||
// Elements
|
||||
const packetInput = document.getElementById('packet');
|
||||
const wordCountSpan = document.getElementById('wordCount');
|
||||
const useDictionary = document.getElementById('useDictionary');
|
||||
const useTimestamp = document.getElementById('useTimestamp');
|
||||
const useUtf8 = document.getElementById('useUtf8');
|
||||
const forceCpu = document.getElementById('forceCpu');
|
||||
const maxLength = document.getElementById('maxLength');
|
||||
|
||||
const crackBtn = document.getElementById('crackBtn');
|
||||
const continueBtn = document.getElementById('continueBtn');
|
||||
const abortBtn = document.getElementById('abortBtn');
|
||||
const clearBtn = document.getElementById('clearBtn');
|
||||
const statusArea = document.getElementById('statusArea');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const resultsArea = document.getElementById('resultsArea');
|
||||
const resultsList = document.getElementById('resultsList');
|
||||
const logArea = document.getElementById('log');
|
||||
const gpuStatus = document.getElementById('gpuStatus');
|
||||
|
||||
// Initialize cracker and check GPU
|
||||
function initCracker() {
|
||||
if (cracker) cracker.destroy();
|
||||
cracker = new GroupTextCracker();
|
||||
|
||||
const hasGpu = cracker.isGpuAvailable();
|
||||
gpuStatus.textContent = hasGpu ? 'GPU Available' : 'CPU Only';
|
||||
gpuStatus.className = 'gpu-status ' + (hasGpu ? 'available' : 'unavailable');
|
||||
|
||||
return cracker;
|
||||
}
|
||||
|
||||
initCracker();
|
||||
|
||||
// Load wordlist from GitHub
|
||||
async function loadWordlist() {
|
||||
wordCountSpan.textContent = 'loading...';
|
||||
try {
|
||||
const response = await fetch('https://raw.githubusercontent.com/dwyl/english-words/master/words_alpha.txt');
|
||||
const text = await response.text();
|
||||
wordlist = text.split(/\r?\n/)
|
||||
.map(w => w.trim().toLowerCase())
|
||||
.filter(w => w.length > 0 && /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(w) && !w.includes('--'));
|
||||
wordCountSpan.textContent = wordlist.length.toLocaleString();
|
||||
log(`Loaded ${wordlist.length.toLocaleString()} words from GitHub`);
|
||||
} catch (err) {
|
||||
wordCountSpan.textContent = 'failed';
|
||||
log(`Failed to load wordlist: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
loadWordlist();
|
||||
|
||||
// Preset packet buttons
|
||||
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
packetInput.value = btn.dataset.packet;
|
||||
});
|
||||
});
|
||||
|
||||
function log(msg, type = '') {
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'log-entry ' + type;
|
||||
entry.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
|
||||
logArea.appendChild(entry);
|
||||
logArea.scrollTop = logArea.scrollHeight;
|
||||
}
|
||||
|
||||
function setStatus(msg, type = 'info') {
|
||||
statusArea.innerHTML = `<div class="status ${type}">${msg}</div>`;
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (seconds < 60) return `${seconds.toFixed(0)}s`;
|
||||
if (seconds < 3600) return `${(seconds / 60).toFixed(1)}m`;
|
||||
return `${(seconds / 3600).toFixed(1)}h`;
|
||||
}
|
||||
|
||||
function formatRate(rate) {
|
||||
if (rate >= 1e9) return `${(rate / 1e9).toFixed(2)} Gkeys/s`;
|
||||
if (rate >= 1e6) return `${(rate / 1e6).toFixed(2)} Mkeys/s`;
|
||||
if (rate >= 1e3) return `${(rate / 1e3).toFixed(2)} Kkeys/s`;
|
||||
return `${rate.toFixed(0)} keys/s`;
|
||||
}
|
||||
|
||||
function updateProgress(p) {
|
||||
document.getElementById('phase').textContent = p.phase;
|
||||
document.getElementById('currentLength').textContent = p.currentLength;
|
||||
document.getElementById('currentPosition').textContent = p.currentPosition || '-';
|
||||
document.getElementById('checked').textContent = p.checked.toLocaleString();
|
||||
document.getElementById('total').textContent = p.total.toLocaleString();
|
||||
document.getElementById('percent').textContent = p.percent.toFixed(2);
|
||||
document.getElementById('progressBar').style.width = `${p.percent}%`;
|
||||
document.getElementById('rate').textContent = formatRate(p.rateKeysPerSec);
|
||||
document.getElementById('eta').textContent = formatTime(p.etaSeconds);
|
||||
}
|
||||
|
||||
function addResult(result) {
|
||||
results.push(result);
|
||||
resultsArea.style.display = 'block';
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'result-item';
|
||||
item.innerHTML = `
|
||||
<div class="result-label">Room Name</div>
|
||||
<div class="result-value">#${result.roomName}</div>
|
||||
<div class="result-label">Decrypted Message</div>
|
||||
<div class="result-value">${result.decryptedMessage || '(empty)'}</div>
|
||||
<div class="result-label">Key</div>
|
||||
<div class="result-value">${result.key}</div>
|
||||
<div class="result-label">Resume Info</div>
|
||||
<div class="result-value">type: ${result.resumeType}, from: ${result.resumeFrom}</div>
|
||||
`;
|
||||
resultsList.appendChild(item);
|
||||
}
|
||||
|
||||
async function doCrack(resumeFrom = null, resumeType = null) {
|
||||
const packet = packetInput.value.trim();
|
||||
if (!packet) {
|
||||
setStatus('Please enter a packet hex string', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-init cracker to ensure clean state
|
||||
initCracker();
|
||||
|
||||
// Set wordlist if dictionary is enabled and loaded
|
||||
if (useDictionary.checked && wordlist.length > 0) {
|
||||
cracker.setWordlist(wordlist);
|
||||
log(`Using dictionary with ${wordlist.length.toLocaleString()} words`);
|
||||
if (document.getElementById('useTwoWordCombinations').checked) {
|
||||
const shortWords = wordlist.filter(w => w.length <= 15);
|
||||
const pairCount = shortWords.length * shortWords.length;
|
||||
log(`Two-word combinations enabled: ${shortWords.length.toLocaleString()} short words (≤15 chars)`);
|
||||
log(`Estimated pairs to check: ~${pairCount.toLocaleString()} (filtered by length)`);
|
||||
}
|
||||
}
|
||||
|
||||
const gpuDispatchMsValue = parseInt(document.getElementById('gpuDispatchMs').value) || 1000;
|
||||
const useTwoWordCombinations = document.getElementById('useTwoWordCombinations').checked;
|
||||
const options = {
|
||||
maxLength: parseInt(maxLength.value) || 6,
|
||||
useTimestampFilter: useTimestamp.checked,
|
||||
useUtf8Filter: useUtf8.checked,
|
||||
forceCpu: forceCpu.checked,
|
||||
useDictionary: useDictionary.checked,
|
||||
useTwoWordCombinations: useTwoWordCombinations,
|
||||
gpuDispatchMs: gpuDispatchMsValue,
|
||||
};
|
||||
|
||||
if (resumeFrom) {
|
||||
options.startFrom = resumeFrom;
|
||||
options.startFromType = resumeType || 'bruteforce';
|
||||
log(`Resuming from "${resumeFrom}" (${options.startFromType})`);
|
||||
}
|
||||
|
||||
crackBtn.disabled = true;
|
||||
continueBtn.disabled = true;
|
||||
abortBtn.disabled = false;
|
||||
progressArea.style.display = 'block';
|
||||
|
||||
const startMsg = resumeFrom ? `Continuing search...` : `Starting crack (${forceCpu.checked ? 'CPU' : 'GPU'})...`;
|
||||
setStatus(startMsg, 'info');
|
||||
log(startMsg);
|
||||
|
||||
try {
|
||||
const result = await cracker.crack(packet, options, updateProgress);
|
||||
|
||||
lastResult = result;
|
||||
|
||||
if (result.found) {
|
||||
setStatus(`Found room: #${result.roomName}`, 'success');
|
||||
log(`MATCH: #${result.roomName} -> "${result.decryptedMessage}"`, 'match');
|
||||
addResult(result);
|
||||
continueBtn.disabled = false;
|
||||
} else if (result.aborted) {
|
||||
setStatus(`Aborted at position: ${result.resumeFrom}`, 'info');
|
||||
log(`Aborted. Resume from: ${result.resumeFrom} (${result.resumeType})`);
|
||||
continueBtn.disabled = false;
|
||||
} else {
|
||||
setStatus('No match found in search space', 'error');
|
||||
log('Search complete - no match found');
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
setStatus(`Error: ${result.error}`, 'error');
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
setStatus(`Error: ${err.message}`, 'error');
|
||||
log(`Exception: ${err.message}`, 'error');
|
||||
}
|
||||
|
||||
crackBtn.disabled = false;
|
||||
abortBtn.disabled = true;
|
||||
}
|
||||
|
||||
crackBtn.addEventListener('click', () => {
|
||||
results = [];
|
||||
resultsList.innerHTML = '';
|
||||
resultsArea.style.display = 'none';
|
||||
lastResult = null;
|
||||
doCrack();
|
||||
});
|
||||
|
||||
continueBtn.addEventListener('click', () => {
|
||||
if (lastResult && lastResult.resumeFrom) {
|
||||
doCrack(lastResult.resumeFrom, lastResult.resumeType);
|
||||
}
|
||||
});
|
||||
|
||||
abortBtn.addEventListener('click', () => {
|
||||
if (cracker) {
|
||||
cracker.abort();
|
||||
log('Abort requested...');
|
||||
}
|
||||
});
|
||||
|
||||
clearBtn.addEventListener('click', () => {
|
||||
results = [];
|
||||
resultsList.innerHTML = '';
|
||||
resultsArea.style.display = 'none';
|
||||
progressArea.style.display = 'none';
|
||||
statusArea.innerHTML = '';
|
||||
logArea.innerHTML = '';
|
||||
lastResult = null;
|
||||
continueBtn.disabled = true;
|
||||
log('Cleared');
|
||||
});
|
||||
|
||||
// ── Autotest ──────────────────────────────────────────────────────
|
||||
|
||||
const AUTOTEST_PACKETS = {
|
||||
wordlist: '1503A6DB9DCA168D85E6C402C0E0AF422FA633B990C6946DF20842B7F03E16531B2573B76BEE7FE1',
|
||||
twoWord: '1503e653aeb374a86670654924ffbf1e535b549856b6caf87aff479a3d34c6cf9c29dc2e04ccd8be1e3a1af4c8c8a4f54ca18211a56466515fe29fa53e62d7290fde841fc42cc90682c864a80191725c71ea12fd979ed35b1c82915f459d54ce655d715e20fbe15fdd7835c60da6100914c360d281af168729b5b9d60189b6cb888869ca5306f4a9',
|
||||
bruteForce: '150064F62ADF41BE89A05AC9BCFE04BA85D54BD402EDB6D973DE6F7B1811FB1FA8FD8888882429C57B8FC622C9E613F60890C46AF4',
|
||||
};
|
||||
|
||||
const AUTOTEST_CASES = [
|
||||
// [label, packetKey, expectedRoom, opts]
|
||||
// expectedRoom: exact string to match, or null to just check found===true
|
||||
...([1000, 5000, 10000].map(d => [`Wordlist #bot (dispatch ${d}ms)`, 'wordlist', 'bot',
|
||||
{ useDictionary: true, useTwoWordCombinations: false, maxLength: 6, gpuDispatchMs: d }])),
|
||||
...([1000, 5000, 10000].map(d => [`Two-word combo (dispatch ${d}ms)`, 'twoWord', 'hamradio',
|
||||
{ useDictionary: true, useTwoWordCombinations: true, maxLength: 6, gpuDispatchMs: d }])),
|
||||
...([1000, 5000, 10000].map(d => [`Brute #uetfwf (dispatch ${d}ms)`, 'bruteForce', 'uetfwf',
|
||||
{ useDictionary: false, useTwoWordCombinations: false, maxLength: 6, gpuDispatchMs: d }])),
|
||||
];
|
||||
|
||||
const autotestBtn = document.getElementById('autotestBtn');
|
||||
const autotestPanel = document.getElementById('autotestPanel');
|
||||
const autotestRows = document.getElementById('autotestRows');
|
||||
const autotestSummary = document.getElementById('autotestSummary');
|
||||
let autotestRunning = false;
|
||||
|
||||
// Enable button once wordlist is loaded
|
||||
function checkAutotestReady() {
|
||||
if (wordlist.length > 0) {
|
||||
autotestBtn.disabled = false;
|
||||
autotestBtn.textContent = 'Autotest';
|
||||
}
|
||||
}
|
||||
// Poll until wordlist loads (loadWordlist is already running)
|
||||
const _atReadyInterval = setInterval(() => {
|
||||
if (wordlist.length > 0) {
|
||||
clearInterval(_atReadyInterval);
|
||||
checkAutotestReady();
|
||||
}
|
||||
}, 200);
|
||||
|
||||
function atBuildRows() {
|
||||
autotestRows.innerHTML = '';
|
||||
return AUTOTEST_CASES.map(([label], i) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'at-row';
|
||||
row.innerHTML = `<span class="at-badge wait">WAIT</span><span class="at-name">${label}</span><span class="at-time"></span>`;
|
||||
autotestRows.appendChild(row);
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
function atSetRow(row, status, timeStr) {
|
||||
const badge = row.querySelector('.at-badge');
|
||||
badge.className = 'at-badge ' + status;
|
||||
badge.textContent = status.toUpperCase();
|
||||
if (timeStr != null) row.querySelector('.at-time').textContent = timeStr;
|
||||
}
|
||||
|
||||
async function runAutotest() {
|
||||
if (autotestRunning) return;
|
||||
autotestRunning = true;
|
||||
autotestBtn.disabled = true;
|
||||
autotestBtn.textContent = 'Running...';
|
||||
autotestPanel.style.display = 'block';
|
||||
autotestSummary.style.display = 'none';
|
||||
|
||||
const rows = atBuildRows();
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let i = 0; i < AUTOTEST_CASES.length; i++) {
|
||||
const [label, packetKey, expectedRoom, opts] = AUTOTEST_CASES[i];
|
||||
atSetRow(rows[i], 'run', '');
|
||||
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
// Fresh cracker per test
|
||||
const tc = new GroupTextCracker();
|
||||
if (opts.useDictionary) tc.setWordlist(wordlist);
|
||||
|
||||
const result = await tc.crack(AUTOTEST_PACKETS[packetKey], {
|
||||
maxLength: opts.maxLength || 6,
|
||||
useTimestampFilter: false,
|
||||
useUtf8Filter: true,
|
||||
forceCpu: false,
|
||||
useDictionary: opts.useDictionary,
|
||||
useTwoWordCombinations: opts.useTwoWordCombinations,
|
||||
gpuDispatchMs: opts.gpuDispatchMs,
|
||||
}, updateProgress);
|
||||
|
||||
tc.destroy();
|
||||
|
||||
const elapsed = ((performance.now() - t0) / 1000).toFixed(1) + 's';
|
||||
|
||||
const ok = result.found && (expectedRoom === null || result.roomName === expectedRoom);
|
||||
if (ok) {
|
||||
passed++;
|
||||
atSetRow(rows[i], 'pass', `${elapsed} -> #${result.roomName}`);
|
||||
} else {
|
||||
failed++;
|
||||
const reason = !result.found
|
||||
? `not found${result.error ? ': ' + result.error : ''}`
|
||||
: `expected #${expectedRoom}, got #${result.roomName}`;
|
||||
atSetRow(rows[i], 'fail', `${elapsed} (${reason})`);
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
const elapsed = ((performance.now() - t0) / 1000).toFixed(1) + 's';
|
||||
atSetRow(rows[i], 'fail', `${elapsed} (${err.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
autotestSummary.style.display = 'block';
|
||||
autotestSummary.textContent = `${passed}/${AUTOTEST_CASES.length} passed, ${failed} failed`;
|
||||
autotestSummary.className = failed === 0 ? 'all-pass' : 'has-fail';
|
||||
|
||||
autotestBtn.disabled = false;
|
||||
autotestBtn.textContent = 'Autotest';
|
||||
autotestRunning = false;
|
||||
log(`Autotest complete: ${passed}/${AUTOTEST_CASES.length} passed`);
|
||||
}
|
||||
|
||||
autotestBtn.addEventListener('click', runAutotest);
|
||||
|
||||
// ── End Autotest ─────────────────────────────────────────────────
|
||||
|
||||
// Ready message
|
||||
log('Ready. Select a preset packet above or paste your own.');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
79
package/dist/core.d.ts
vendored
Executable file
79
package/dist/core.d.ts
vendored
Executable file
@@ -0,0 +1,79 @@
|
||||
export declare const CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
export declare const CHARS_LEN: number;
|
||||
export declare const CHARS_WITH_DASH: string;
|
||||
export declare const PUBLIC_ROOM_NAME = "[[public room]]";
|
||||
export declare const PUBLIC_KEY = "8b3387e9c5cdea6ac9e5edbaa115cd72";
|
||||
export declare const DEFAULT_VALID_SECONDS: number;
|
||||
/**
|
||||
* Convert room name to (length, index) for resuming/skipping.
|
||||
* Index encoding: LSB-first (first character = least significant digit).
|
||||
*/
|
||||
export declare function roomNameToIndex(name: string): {
|
||||
length: number;
|
||||
index: number;
|
||||
} | null;
|
||||
/**
|
||||
* Convert (length, index) to room name.
|
||||
* Index encoding: LSB-first (first character = least significant digit).
|
||||
*/
|
||||
export declare function indexToRoomName(length: number, idx: number): string | null;
|
||||
/**
|
||||
* Derive 128-bit key from room name using SHA256.
|
||||
* Room names are prefixed with '#' before hashing.
|
||||
*/
|
||||
export declare function deriveKeyFromRoomName(roomName: string): string;
|
||||
/**
|
||||
* Compute channel hash (first byte of SHA256(key)).
|
||||
*/
|
||||
export declare function getChannelHash(keyHex: string): string;
|
||||
/**
|
||||
* Verify MAC using HMAC-SHA256 with 32-byte padded key.
|
||||
*/
|
||||
export declare function verifyMac(ciphertext: string, cipherMac: string, keyHex: string): boolean;
|
||||
/**
|
||||
* Total index space for a given length (including invalid consecutive-dash indices).
|
||||
* This is the full mixed-radix space: 36 * 37^(len-2) * 36 for len >= 3.
|
||||
* Use this as the brute-force iteration bound (indexToRoomName returns null for holes).
|
||||
*/
|
||||
export declare function indexSpaceForLength(len: number): number;
|
||||
/**
|
||||
* Count valid room names for a given length.
|
||||
* Accounts for dash rules (no start/end dash, no consecutive dashes).
|
||||
*/
|
||||
export declare function countNamesForLength(len: number): number;
|
||||
/**
|
||||
* Check if timestamp is within the validity window.
|
||||
* @param timestamp - Unix timestamp to validate
|
||||
* @param validSeconds - Validity window in seconds (default: 30 days)
|
||||
* @param now - Current time for testing (default: current time)
|
||||
*/
|
||||
export declare function isTimestampValid(timestamp: number, validSeconds?: number, now?: number): boolean;
|
||||
/**
|
||||
* Check for valid UTF-8 (no replacement characters).
|
||||
*/
|
||||
export declare function isValidUtf8(text: string): boolean;
|
||||
/**
|
||||
* Check if text contains a colon character.
|
||||
*/
|
||||
export declare function hasColon(text: string): boolean;
|
||||
/**
|
||||
* Room name generator - iterates through all valid room names.
|
||||
*/
|
||||
export declare class RoomNameGenerator {
|
||||
private length;
|
||||
private indices;
|
||||
private done;
|
||||
private currentInLength;
|
||||
private totalForLength;
|
||||
current(): string;
|
||||
getLength(): number;
|
||||
getCurrentInLength(): number;
|
||||
getTotalForLength(): number;
|
||||
getRemainingInLength(): number;
|
||||
isDone(): boolean;
|
||||
next(): boolean;
|
||||
private isValid;
|
||||
nextValid(): boolean;
|
||||
skipTo(targetLength: number, targetIndex: number): void;
|
||||
}
|
||||
//# sourceMappingURL=core.d.ts.map
|
||||
1
package/dist/core.d.ts.map
vendored
Executable file
1
package/dist/core.d.ts.map
vendored
Executable file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"AAOA,eAAO,MAAM,KAAK,yCAAyC,CAAC;AAC5D,eAAO,MAAM,SAAS,QAAe,CAAC;AACtC,eAAO,MAAM,eAAe,QAAc,CAAC;AAG3C,eAAO,MAAM,gBAAgB,oBAAoB,CAAC;AAClD,eAAO,MAAM,UAAU,qCAAqC,CAAC;AAG7D,eAAO,MAAM,qBAAqB,QAAoB,CAAC;AAEvD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAuCtF;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA0B1E;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAM9D;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAGrD;AAED;;GAEG;AACH,wBAAgB,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAKxF;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAKvD;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAwBvD;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,EACjB,YAAY,GAAE,MAA8B,EAC5C,GAAG,CAAC,EAAE,MAAM,GACX,OAAO,CAGT;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEjD;AAED;;GAEG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAE9C;AAED;;GAEG;AACH,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,OAAO,CAAiB;IAChC,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,eAAe,CAAK;IAC5B,OAAO,CAAC,cAAc,CAAa;IAEnC,OAAO,IAAI,MAAM;IAIjB,SAAS,IAAI,MAAM;IAInB,kBAAkB,IAAI,MAAM;IAI5B,iBAAiB,IAAI,MAAM;IAI3B,oBAAoB,IAAI,MAAM;IAI9B,MAAM,IAAI,OAAO;IAIjB,IAAI,IAAI,OAAO;IA6Cf,OAAO,CAAC,OAAO;IAcf,SAAS,IAAI,OAAO;IAWpB,MAAM,CAAC,YAAY,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,IAAI;CAiBxD"}
|
||||
271
package/dist/core.js
vendored
Executable file
271
package/dist/core.js
vendored
Executable file
@@ -0,0 +1,271 @@
|
||||
// Core logic for MeshCore packet cracker - pure functions
|
||||
import SHA256 from 'crypto-js/sha256.js';
|
||||
import HmacSHA256 from 'crypto-js/hmac-sha256.js';
|
||||
import Hex from 'crypto-js/enc-hex.js';
|
||||
// Room name character set
|
||||
export const CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
export const CHARS_LEN = CHARS.length; // 36
|
||||
export const CHARS_WITH_DASH = CHARS + '-';
|
||||
// Public room special case
|
||||
export const PUBLIC_ROOM_NAME = '[[public room]]';
|
||||
export const PUBLIC_KEY = '8b3387e9c5cdea6ac9e5edbaa115cd72';
|
||||
// Default timestamp validity window (30 days in seconds)
|
||||
export const DEFAULT_VALID_SECONDS = 30 * 24 * 60 * 60;
|
||||
/**
|
||||
* Convert room name to (length, index) for resuming/skipping.
|
||||
* Index encoding: LSB-first (first character = least significant digit).
|
||||
*/
|
||||
export function roomNameToIndex(name) {
|
||||
if (!name || name.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const length = name.length;
|
||||
let index = 0;
|
||||
let multiplier = 1;
|
||||
// Process from left to right (first char is LSB, matching indexToRoomName)
|
||||
let prevWasDash = false;
|
||||
for (let i = 0; i < length; i++) {
|
||||
const c = name[i];
|
||||
const charIdx = CHARS_WITH_DASH.indexOf(c);
|
||||
if (charIdx === -1) {
|
||||
return null;
|
||||
} // Invalid character
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === length - 1;
|
||||
const charCount = isFirst || isLast ? 36 : 37;
|
||||
const isDash = charIdx === 36;
|
||||
// Dash not allowed at start/end
|
||||
if ((isFirst || isLast) && isDash) {
|
||||
return null;
|
||||
}
|
||||
// No consecutive dashes
|
||||
if (isDash && prevWasDash) {
|
||||
return null;
|
||||
}
|
||||
prevWasDash = isDash;
|
||||
index += charIdx * multiplier;
|
||||
multiplier *= charCount;
|
||||
}
|
||||
return { length, index };
|
||||
}
|
||||
/**
|
||||
* Convert (length, index) to room name.
|
||||
* Index encoding: LSB-first (first character = least significant digit).
|
||||
*/
|
||||
export function indexToRoomName(length, idx) {
|
||||
if (length <= 0) {
|
||||
return null;
|
||||
}
|
||||
let result = '';
|
||||
let remaining = idx;
|
||||
let prevWasDash = false;
|
||||
for (let i = 0; i < length; i++) {
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === length - 1;
|
||||
const charCount = isFirst || isLast ? 36 : 37;
|
||||
const charIdx = remaining % charCount;
|
||||
remaining = Math.floor(remaining / charCount);
|
||||
const isDash = charIdx === 36;
|
||||
if (isDash && prevWasDash) {
|
||||
return null;
|
||||
} // Invalid: consecutive dashes
|
||||
prevWasDash = isDash;
|
||||
result += CHARS_WITH_DASH[charIdx];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Derive 128-bit key from room name using SHA256.
|
||||
* Room names are prefixed with '#' before hashing.
|
||||
*/
|
||||
export function deriveKeyFromRoomName(roomName) {
|
||||
if (roomName === PUBLIC_ROOM_NAME) {
|
||||
return PUBLIC_KEY;
|
||||
}
|
||||
const hash = SHA256(roomName);
|
||||
return hash.toString(Hex).substring(0, 32);
|
||||
}
|
||||
/**
|
||||
* Compute channel hash (first byte of SHA256(key)).
|
||||
*/
|
||||
export function getChannelHash(keyHex) {
|
||||
const hash = SHA256(Hex.parse(keyHex));
|
||||
return hash.toString(Hex).substring(0, 2);
|
||||
}
|
||||
/**
|
||||
* Verify MAC using HMAC-SHA256 with 32-byte padded key.
|
||||
*/
|
||||
export function verifyMac(ciphertext, cipherMac, keyHex) {
|
||||
const paddedKey = keyHex.padEnd(64, '0');
|
||||
const hmac = HmacSHA256(Hex.parse(ciphertext), Hex.parse(paddedKey));
|
||||
const computed = hmac.toString(Hex).substring(0, 4).toLowerCase();
|
||||
return computed === cipherMac.toLowerCase();
|
||||
}
|
||||
/**
|
||||
* Total index space for a given length (including invalid consecutive-dash indices).
|
||||
* This is the full mixed-radix space: 36 * 37^(len-2) * 36 for len >= 3.
|
||||
* Use this as the brute-force iteration bound (indexToRoomName returns null for holes).
|
||||
*/
|
||||
export function indexSpaceForLength(len) {
|
||||
if (len <= 0)
|
||||
return 0;
|
||||
if (len === 1)
|
||||
return CHARS_LEN;
|
||||
if (len === 2)
|
||||
return CHARS_LEN * CHARS_LEN;
|
||||
return CHARS_LEN * CHARS_LEN * Math.pow(CHARS_LEN + 1, len - 2);
|
||||
}
|
||||
/**
|
||||
* Count valid room names for a given length.
|
||||
* Accounts for dash rules (no start/end dash, no consecutive dashes).
|
||||
*/
|
||||
export function countNamesForLength(len) {
|
||||
if (len === 1) {
|
||||
return CHARS_LEN;
|
||||
}
|
||||
if (len === 2) {
|
||||
return CHARS_LEN * CHARS_LEN;
|
||||
}
|
||||
// For length >= 3: first and last are CHARS (36), middle follows no-consecutive-dash rule
|
||||
// Middle length = len - 2
|
||||
// Use DP: count sequences of length k with no consecutive dashes
|
||||
// endsWithNonDash[k], endsWithDash[k]
|
||||
let endsNonDash = CHARS_LEN; // length 1 middle
|
||||
let endsDash = 1;
|
||||
for (let i = 2; i <= len - 2; i++) {
|
||||
const newEndsNonDash = (endsNonDash + endsDash) * CHARS_LEN;
|
||||
const newEndsDash = endsNonDash; // dash can only follow non-dash
|
||||
endsNonDash = newEndsNonDash;
|
||||
endsDash = newEndsDash;
|
||||
}
|
||||
const middleCount = len > 2 ? endsNonDash + endsDash : 1;
|
||||
return CHARS_LEN * middleCount * CHARS_LEN;
|
||||
}
|
||||
/**
|
||||
* Check if timestamp is within the validity window.
|
||||
* @param timestamp - Unix timestamp to validate
|
||||
* @param validSeconds - Validity window in seconds (default: 30 days)
|
||||
* @param now - Current time for testing (default: current time)
|
||||
*/
|
||||
export function isTimestampValid(timestamp, validSeconds = DEFAULT_VALID_SECONDS, now) {
|
||||
const currentTime = now ?? Math.floor(Date.now() / 1000);
|
||||
return timestamp <= currentTime && timestamp >= currentTime - validSeconds;
|
||||
}
|
||||
/**
|
||||
* Check for valid UTF-8 (no replacement characters).
|
||||
*/
|
||||
export function isValidUtf8(text) {
|
||||
return !text.includes('\uFFFD');
|
||||
}
|
||||
/**
|
||||
* Check if text contains a colon character.
|
||||
*/
|
||||
export function hasColon(text) {
|
||||
return text.includes(':');
|
||||
}
|
||||
/**
|
||||
* Room name generator - iterates through all valid room names.
|
||||
*/
|
||||
export class RoomNameGenerator {
|
||||
constructor() {
|
||||
this.length = 1;
|
||||
this.indices = [0];
|
||||
this.done = false;
|
||||
this.currentInLength = 0;
|
||||
this.totalForLength = CHARS_LEN;
|
||||
}
|
||||
current() {
|
||||
return this.indices.map((i) => (i === CHARS_LEN ? '-' : CHARS[i])).join('');
|
||||
}
|
||||
getLength() {
|
||||
return this.length;
|
||||
}
|
||||
getCurrentInLength() {
|
||||
return this.currentInLength;
|
||||
}
|
||||
getTotalForLength() {
|
||||
return this.totalForLength;
|
||||
}
|
||||
getRemainingInLength() {
|
||||
return this.totalForLength - this.currentInLength;
|
||||
}
|
||||
isDone() {
|
||||
return this.done;
|
||||
}
|
||||
next() {
|
||||
if (this.done) {
|
||||
return false;
|
||||
}
|
||||
this.currentInLength++;
|
||||
// Increment with carry, respecting dash rules
|
||||
let pos = this.length - 1;
|
||||
while (pos >= 0) {
|
||||
const isFirst = pos === 0;
|
||||
const isLast = pos === this.length - 1;
|
||||
const maxVal = isFirst || isLast ? CHARS_LEN - 1 : CHARS_LEN; // CHARS_LEN = dash index
|
||||
if (this.indices[pos] < maxVal) {
|
||||
this.indices[pos]++;
|
||||
// Check dash rule: no consecutive dashes
|
||||
if (this.indices[pos] === CHARS_LEN && pos > 0 && this.indices[pos - 1] === CHARS_LEN) {
|
||||
// Would create consecutive dashes, continue incrementing
|
||||
continue;
|
||||
}
|
||||
// Reset all positions after this one
|
||||
for (let i = pos + 1; i < this.length; i++) {
|
||||
this.indices[i] = 0;
|
||||
}
|
||||
// Validate: check no consecutive dashes in reset portion
|
||||
if (this.isValid()) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
pos--;
|
||||
}
|
||||
// Overflow - increase length
|
||||
this.length++;
|
||||
this.indices = new Array(this.length).fill(0);
|
||||
this.currentInLength = 0;
|
||||
this.totalForLength = countNamesForLength(this.length);
|
||||
return true;
|
||||
}
|
||||
isValid() {
|
||||
for (let i = 0; i < this.length; i++) {
|
||||
const isDash = this.indices[i] === CHARS_LEN;
|
||||
if (isDash && (i === 0 || i === this.length - 1)) {
|
||||
return false;
|
||||
}
|
||||
if (isDash && i > 0 && this.indices[i - 1] === CHARS_LEN) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Skip invalid combinations efficiently
|
||||
nextValid() {
|
||||
do {
|
||||
if (!this.next()) {
|
||||
return false;
|
||||
}
|
||||
} while (!this.isValid());
|
||||
return true;
|
||||
}
|
||||
// Skip to a specific (length, index) position
|
||||
// Index encoding: first char is LSB (consistent with indexToRoomName)
|
||||
skipTo(targetLength, targetIndex) {
|
||||
this.length = targetLength;
|
||||
this.indices = new Array(targetLength).fill(0);
|
||||
this.totalForLength = countNamesForLength(targetLength);
|
||||
// Convert index to indices array (LSB first = position 0)
|
||||
let remaining = targetIndex;
|
||||
for (let i = 0; i < targetLength; i++) {
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === targetLength - 1;
|
||||
const charCount = isFirst || isLast ? CHARS_LEN : CHARS_LEN + 1;
|
||||
this.indices[i] = remaining % charCount;
|
||||
remaining = Math.floor(remaining / charCount);
|
||||
}
|
||||
this.currentInLength = targetIndex;
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=core.js.map
|
||||
1
package/dist/core.js.map
vendored
Executable file
1
package/dist/core.js.map
vendored
Executable file
File diff suppressed because one or more lines are too long
13
package/dist/cpu-bruteforce.d.ts
vendored
Executable file
13
package/dist/cpu-bruteforce.d.ts
vendored
Executable file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* CPU-based brute force implementation.
|
||||
* Much slower than GPU but works everywhere.
|
||||
*/
|
||||
export declare class CpuBruteForce {
|
||||
/**
|
||||
* Run a batch of candidates on CPU.
|
||||
* Returns indices of candidates that match the channel hash and MAC.
|
||||
*/
|
||||
runBatch(targetChannelHash: number, nameLength: number, batchOffset: number, batchSize: number, ciphertextHex?: string, targetMacHex?: string): number[];
|
||||
destroy(): void;
|
||||
}
|
||||
//# sourceMappingURL=cpu-bruteforce.d.ts.map
|
||||
1
package/dist/cpu-bruteforce.d.ts.map
vendored
Executable file
1
package/dist/cpu-bruteforce.d.ts.map
vendored
Executable file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"cpu-bruteforce.d.ts","sourceRoot":"","sources":["../src/cpu-bruteforce.ts"],"names":[],"mappings":"AAUA;;;GAGG;AACH,qBAAa,aAAa;IACxB;;;OAGG;IACH,QAAQ,CACN,iBAAiB,EAAE,MAAM,EACzB,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,aAAa,CAAC,EAAE,MAAM,EACtB,YAAY,CAAC,EAAE,MAAM,GACpB,MAAM,EAAE;IAoCX,OAAO,IAAI,IAAI;CAGhB"}
|
||||
45
package/dist/cpu-bruteforce.js
vendored
Executable file
45
package/dist/cpu-bruteforce.js
vendored
Executable file
@@ -0,0 +1,45 @@
|
||||
// CPU-based brute force key cracking for MeshCore packets
|
||||
// Fallback for environments without WebGPU support
|
||||
import { indexToRoomName, deriveKeyFromRoomName, getChannelHash, verifyMac, } from './core.js';
|
||||
/**
|
||||
* CPU-based brute force implementation.
|
||||
* Much slower than GPU but works everywhere.
|
||||
*/
|
||||
export class CpuBruteForce {
|
||||
/**
|
||||
* Run a batch of candidates on CPU.
|
||||
* Returns indices of candidates that match the channel hash and MAC.
|
||||
*/
|
||||
runBatch(targetChannelHash, nameLength, batchOffset, batchSize, ciphertextHex, targetMacHex) {
|
||||
const matches = [];
|
||||
const targetHashHex = targetChannelHash.toString(16).padStart(2, '0');
|
||||
const verifyMacEnabled = !!(ciphertextHex && targetMacHex);
|
||||
for (let i = 0; i < batchSize; i++) {
|
||||
const nameIdx = batchOffset + i;
|
||||
const roomName = indexToRoomName(nameLength, nameIdx);
|
||||
if (!roomName) {
|
||||
continue; // Invalid index (e.g., consecutive dashes)
|
||||
}
|
||||
// Derive key from room name (with # prefix)
|
||||
const key = deriveKeyFromRoomName('#' + roomName);
|
||||
// Check channel hash
|
||||
const channelHash = getChannelHash(key);
|
||||
if (channelHash !== targetHashHex) {
|
||||
continue;
|
||||
}
|
||||
// Channel hash matches - verify MAC if enabled
|
||||
if (verifyMacEnabled) {
|
||||
if (!verifyMac(ciphertextHex, targetMacHex, key)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Found a match
|
||||
matches.push(nameIdx);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
destroy() {
|
||||
// No resources to clean up for CPU implementation
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=cpu-bruteforce.js.map
|
||||
1
package/dist/cpu-bruteforce.js.map
vendored
Executable file
1
package/dist/cpu-bruteforce.js.map
vendored
Executable file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"cpu-bruteforce.js","sourceRoot":"","sources":["../src/cpu-bruteforce.ts"],"names":[],"mappings":"AAAA,0DAA0D;AAC1D,mDAAmD;AAEnD,OAAO,EACL,eAAe,EACf,qBAAqB,EACrB,cAAc,EACd,SAAS,GACV,MAAM,WAAW,CAAC;AAEnB;;;GAGG;AACH,MAAM,OAAO,aAAa;IACxB;;;OAGG;IACH,QAAQ,CACN,iBAAyB,EACzB,UAAkB,EAClB,WAAmB,EACnB,SAAiB,EACjB,aAAsB,EACtB,YAAqB;QAErB,MAAM,OAAO,GAAa,EAAE,CAAC;QAC7B,MAAM,aAAa,GAAG,iBAAiB,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;QACtE,MAAM,gBAAgB,GAAG,CAAC,CAAC,CAAC,aAAa,IAAI,YAAY,CAAC,CAAC;QAE3D,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,SAAS,EAAE,CAAC,EAAE,EAAE,CAAC;YACnC,MAAM,OAAO,GAAG,WAAW,GAAG,CAAC,CAAC;YAChC,MAAM,QAAQ,GAAG,eAAe,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;YAEtD,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACd,SAAS,CAAC,2CAA2C;YACvD,CAAC;YAED,4CAA4C;YAC5C,MAAM,GAAG,GAAG,qBAAqB,CAAC,GAAG,GAAG,QAAQ,CAAC,CAAC;YAElD,qBAAqB;YACrB,MAAM,WAAW,GAAG,cAAc,CAAC,GAAG,CAAC,CAAC;YACxC,IAAI,WAAW,KAAK,aAAa,EAAE,CAAC;gBAClC,SAAS;YACX,CAAC;YAED,+CAA+C;YAC/C,IAAI,gBAAgB,EAAE,CAAC;gBACrB,IAAI,CAAC,SAAS,CAAC,aAAc,EAAE,YAAa,EAAE,GAAG,CAAC,EAAE,CAAC;oBACnD,SAAS;gBACX,CAAC;YACH,CAAC;YAED,gBAAgB;YAChB,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACxB,CAAC;QAED,OAAO,OAAO,CAAC;IACjB,CAAC;IAED,OAAO;QACL,kDAAkD;IACpD,CAAC;CACF"}
|
||||
68
package/dist/cracker.d.ts
vendored
Executable file
68
package/dist/cracker.d.ts
vendored
Executable file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* GroupTextCracker - Standalone MeshCore GroupText packet cracker
|
||||
*
|
||||
* Cracks encrypted GroupText packets by trying room names until the
|
||||
* correct encryption key is found.
|
||||
*/
|
||||
import type { CrackOptions, CrackResult, ProgressCallback, DecodedPacket } from './types.js';
|
||||
/**
|
||||
* Main cracker class for MeshCore GroupText packets.
|
||||
*/
|
||||
export declare class GroupTextCracker {
|
||||
private gpuInstance;
|
||||
private gpuWordPairs;
|
||||
private cpuInstance;
|
||||
private wordlist;
|
||||
private abortFlag;
|
||||
private useTimestampFilter;
|
||||
private useUtf8Filter;
|
||||
private useSenderFilter;
|
||||
private validSeconds;
|
||||
private useCpu;
|
||||
/**
|
||||
* Load a wordlist from a URL for dictionary attacks.
|
||||
* The wordlist should be a text file with one word per line.
|
||||
*
|
||||
* @param url - URL to fetch the wordlist from
|
||||
*/
|
||||
loadWordlist(url: string): Promise<void>;
|
||||
/**
|
||||
* Set the wordlist directly from an array of words.
|
||||
*
|
||||
* @param words - Array of room names to try
|
||||
*/
|
||||
setWordlist(words: string[]): void;
|
||||
/**
|
||||
* Abort the current cracking operation.
|
||||
* The crack() method will return with aborted: true.
|
||||
*/
|
||||
abort(): void;
|
||||
/**
|
||||
* Check if WebGPU is available in the current environment.
|
||||
*/
|
||||
isGpuAvailable(): boolean;
|
||||
/**
|
||||
* Decode a packet and extract the information needed for cracking.
|
||||
* Delegates to MeshCorePacketDecoder which handles both single-byte
|
||||
* and multi-byte path hops (v1.11+ path_len encoding).
|
||||
*
|
||||
* @param packetHex - The packet data as a hex string
|
||||
* @returns Decoded packet info or null if not a GroupText packet
|
||||
*/
|
||||
decodePacket(packetHex: string): Promise<DecodedPacket | null>;
|
||||
/**
|
||||
* Crack a GroupText packet to find the room name and decrypt the message.
|
||||
*
|
||||
* @param packetHex - The packet data as a hex string
|
||||
* @param options - Cracking options
|
||||
* @param onProgress - Optional callback for progress updates
|
||||
* @returns The cracking result
|
||||
*/
|
||||
crack(packetHex: string, options?: CrackOptions, onProgress?: ProgressCallback): Promise<CrackResult>;
|
||||
/**
|
||||
* Clean up resources.
|
||||
* Call this when you're done using the cracker.
|
||||
*/
|
||||
destroy(): void;
|
||||
}
|
||||
//# sourceMappingURL=cracker.d.ts.map
|
||||
1
package/dist/cracker.d.ts.map
vendored
Executable file
1
package/dist/cracker.d.ts.map
vendored
Executable file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"cracker.d.ts","sourceRoot":"","sources":["../src/cracker.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAoBH,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAkB,gBAAgB,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAe7G;;GAEG;AACH,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,WAAW,CAA8B;IACjD,OAAO,CAAC,YAAY,CAA6B;IACjD,OAAO,CAAC,WAAW,CAA8B;IACjD,OAAO,CAAC,QAAQ,CAAgB;IAChC,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,kBAAkB,CAAQ;IAClC,OAAO,CAAC,aAAa,CAAQ;IAC7B,OAAO,CAAC,eAAe,CAAQ;IAC/B,OAAO,CAAC,YAAY,CAAyB;IAC7C,OAAO,CAAC,MAAM,CAAS;IAEvB;;;;;OAKG;IACG,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAgB9C;;;;OAIG;IACH,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI;IAMlC;;;OAGG;IACH,KAAK,IAAI,IAAI;IAIb;;OAEG;IACH,cAAc,IAAI,OAAO;IAIzB;;;;;;;OAOG;IACG,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC;IA8BpE;;;;;;;OAOG;IACG,KAAK,CACT,SAAS,EAAE,MAAM,EACjB,OAAO,CAAC,EAAE,YAAY,EACtB,UAAU,CAAC,EAAE,gBAAgB,GAC5B,OAAO,CAAC,WAAW,CAAC;IAmkBvB;;;OAGG;IACH,OAAO,IAAI,IAAI;CAchB"}
|
||||
625
package/dist/cracker.js
vendored
Executable file
625
package/dist/cracker.js
vendored
Executable file
@@ -0,0 +1,625 @@
|
||||
/**
|
||||
* GroupTextCracker - Standalone MeshCore GroupText packet cracker
|
||||
*
|
||||
* Cracks encrypted GroupText packets by trying room names until the
|
||||
* correct encryption key is found.
|
||||
*/
|
||||
import { MeshCorePacketDecoder, ChannelCrypto } from '@michaelhart/meshcore-decoder';
|
||||
import { GpuBruteForce, isWebGpuSupported } from './gpu-bruteforce.js';
|
||||
import { GpuWordPairs } from './gpu-wordpairs.js';
|
||||
import { CpuBruteForce } from './cpu-bruteforce.js';
|
||||
import { PUBLIC_ROOM_NAME, PUBLIC_KEY, DEFAULT_VALID_SECONDS, indexToRoomName, roomNameToIndex, deriveKeyFromRoomName, getChannelHash, verifyMac, indexSpaceForLength, isTimestampValid, isValidUtf8, } from './core.js';
|
||||
// Valid room name characters (for wordlist filtering)
|
||||
const VALID_CHARS = /^[a-z0-9-]+$/;
|
||||
const NO_DASH_AT_ENDS = /^[a-z0-9].*[a-z0-9]$|^[a-z0-9]$/;
|
||||
const NO_CONSECUTIVE_DASHES = /--/;
|
||||
function isValidRoomName(name) {
|
||||
if (!name || name.length === 0)
|
||||
return false;
|
||||
if (!VALID_CHARS.test(name))
|
||||
return false;
|
||||
if (name.length > 1 && !NO_DASH_AT_ENDS.test(name))
|
||||
return false;
|
||||
if (NO_CONSECUTIVE_DASHES.test(name))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Main cracker class for MeshCore GroupText packets.
|
||||
*/
|
||||
export class GroupTextCracker {
|
||||
constructor() {
|
||||
this.gpuInstance = null;
|
||||
this.gpuWordPairs = null;
|
||||
this.cpuInstance = null;
|
||||
this.wordlist = [];
|
||||
this.abortFlag = false;
|
||||
this.useTimestampFilter = true;
|
||||
this.useUtf8Filter = true;
|
||||
this.useSenderFilter = true;
|
||||
this.validSeconds = DEFAULT_VALID_SECONDS;
|
||||
this.useCpu = false;
|
||||
}
|
||||
/**
|
||||
* Load a wordlist from a URL for dictionary attacks.
|
||||
* The wordlist should be a text file with one word per line.
|
||||
*
|
||||
* @param url - URL to fetch the wordlist from
|
||||
*/
|
||||
async loadWordlist(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load wordlist: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
const allWords = text
|
||||
.split('\n')
|
||||
.map((w) => w.trim().toLowerCase())
|
||||
.filter((w) => w.length > 0);
|
||||
// Filter to valid room names only
|
||||
this.wordlist = allWords.filter(isValidRoomName);
|
||||
}
|
||||
/**
|
||||
* Set the wordlist directly from an array of words.
|
||||
*
|
||||
* @param words - Array of room names to try
|
||||
*/
|
||||
setWordlist(words) {
|
||||
this.wordlist = words
|
||||
.map((w) => w.trim().toLowerCase())
|
||||
.filter(isValidRoomName);
|
||||
}
|
||||
/**
|
||||
* Abort the current cracking operation.
|
||||
* The crack() method will return with aborted: true.
|
||||
*/
|
||||
abort() {
|
||||
this.abortFlag = true;
|
||||
}
|
||||
/**
|
||||
* Check if WebGPU is available in the current environment.
|
||||
*/
|
||||
isGpuAvailable() {
|
||||
return isWebGpuSupported();
|
||||
}
|
||||
/**
|
||||
* Decode a packet and extract the information needed for cracking.
|
||||
* Delegates to MeshCorePacketDecoder which handles both single-byte
|
||||
* and multi-byte path hops (v1.11+ path_len encoding).
|
||||
*
|
||||
* @param packetHex - The packet data as a hex string
|
||||
* @returns Decoded packet info or null if not a GroupText packet
|
||||
*/
|
||||
async decodePacket(packetHex) {
|
||||
const cleanHex = packetHex.trim().replace(/\s+/g, '').replace(/^0x/i, '');
|
||||
if (!cleanHex || !/^[0-9a-fA-F]+$/.test(cleanHex)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const decoded = await MeshCorePacketDecoder.decodeWithVerification(cleanHex, {});
|
||||
const payload = decoded.payload?.decoded;
|
||||
if (!payload?.channelHash || !payload?.ciphertext || !payload?.cipherMac) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
channelHash: payload.channelHash.toLowerCase(),
|
||||
ciphertext: payload.ciphertext.toLowerCase(),
|
||||
cipherMac: payload.cipherMac.toLowerCase(),
|
||||
isGroupText: true,
|
||||
};
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Crack a GroupText packet to find the room name and decrypt the message.
|
||||
*
|
||||
* @param packetHex - The packet data as a hex string
|
||||
* @param options - Cracking options
|
||||
* @param onProgress - Optional callback for progress updates
|
||||
* @returns The cracking result
|
||||
*/
|
||||
async crack(packetHex, options, onProgress) {
|
||||
this.abortFlag = false;
|
||||
this.useTimestampFilter = options?.useTimestampFilter ?? true;
|
||||
this.useUtf8Filter = options?.useUtf8Filter ?? true;
|
||||
this.useSenderFilter = options?.useSenderFilter ?? true;
|
||||
this.validSeconds = options?.validSeconds ?? DEFAULT_VALID_SECONDS;
|
||||
this.useCpu = options?.forceCpu ?? false;
|
||||
const maxLength = options?.maxLength ?? 8;
|
||||
const startingLength = options?.startingLength ?? 1;
|
||||
const useDictionary = options?.useDictionary ?? true;
|
||||
const useTwoWordCombinations = options?.useTwoWordCombinations ?? false;
|
||||
const startFromType = options?.startFromType ?? 'bruteforce';
|
||||
// Normalize packet hex to lowercase for consistent processing
|
||||
const normalizedPacketHex = packetHex.toLowerCase();
|
||||
// Decode packet
|
||||
const decoded = await this.decodePacket(normalizedPacketHex);
|
||||
if (!decoded) {
|
||||
return { found: false, error: 'Invalid packet or not a GroupText packet' };
|
||||
}
|
||||
const { channelHash, ciphertext, cipherMac } = decoded;
|
||||
const targetHashByte = parseInt(channelHash, 16);
|
||||
// Initialize GPU or CPU instance
|
||||
if (this.useCpu) {
|
||||
// Use CPU fallback
|
||||
if (!this.cpuInstance) {
|
||||
this.cpuInstance = new CpuBruteForce();
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Try GPU, fall back to CPU if not available
|
||||
if (!this.gpuInstance) {
|
||||
this.gpuInstance = new GpuBruteForce();
|
||||
const gpuOk = await this.gpuInstance.init();
|
||||
if (!gpuOk) {
|
||||
// GPU not available, fall back to CPU
|
||||
this.useCpu = true;
|
||||
this.cpuInstance = new CpuBruteForce();
|
||||
}
|
||||
}
|
||||
}
|
||||
const startTime = performance.now();
|
||||
let totalChecked = 0;
|
||||
let lastProgressUpdate = performance.now();
|
||||
// Determine starting position for brute force
|
||||
let startFromLength = startingLength;
|
||||
let startFromOffset = 0;
|
||||
let dictionaryStartIndex = 0;
|
||||
let skipDictionary = false;
|
||||
let skipWordPairs = false;
|
||||
let wordPairStartI = 0;
|
||||
let wordPairStartJ = 0;
|
||||
if (options?.startFrom) {
|
||||
// Normalize to lowercase for consistent matching
|
||||
const normalizedStartFrom = options.startFrom.toLowerCase();
|
||||
if (startFromType === 'dictionary') {
|
||||
// Find the word in the dictionary and start AFTER it (like brute force does)
|
||||
const wordIndex = this.wordlist.indexOf(normalizedStartFrom);
|
||||
if (wordIndex >= 0) {
|
||||
dictionaryStartIndex = wordIndex + 1; // Start after the given word
|
||||
}
|
||||
// If word not found, start dictionary from beginning
|
||||
}
|
||||
else if (startFromType === 'dictionary-pair') {
|
||||
// Resume from a two-word combination (format: "word1+word2")
|
||||
// Note: actual indices will be resolved later after shortWords is built
|
||||
skipDictionary = true;
|
||||
// Store the words to find later - indices will be set after shortWords is populated
|
||||
const plusIndex = normalizedStartFrom.indexOf('+');
|
||||
if (plusIndex > 0) {
|
||||
// Store for later resolution
|
||||
options._pairResumeWord1 = normalizedStartFrom.substring(0, plusIndex);
|
||||
options._pairResumeWord2 = normalizedStartFrom.substring(plusIndex + 1);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Brute force resume: skip dictionary and word pairs entirely
|
||||
skipDictionary = true;
|
||||
skipWordPairs = true;
|
||||
const pos = roomNameToIndex(normalizedStartFrom);
|
||||
if (pos) {
|
||||
startFromLength = Math.max(startingLength, pos.length);
|
||||
startFromOffset = pos.index + 1; // Start after the given position
|
||||
if (startFromOffset >= indexSpaceForLength(startFromLength)) {
|
||||
startFromLength++;
|
||||
startFromOffset = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Calculate total candidates for progress
|
||||
// Include remaining dictionary words if not skipping dictionary
|
||||
let totalCandidates = 0;
|
||||
if (useDictionary && !skipDictionary && this.wordlist.length > 0) {
|
||||
totalCandidates += this.wordlist.length - dictionaryStartIndex;
|
||||
}
|
||||
// For two-word combinations, pre-filter to short words only (max length 15 each for 30 combined)
|
||||
// This dramatically reduces the search space and makes counting O(N) instead of O(N²)
|
||||
const MAX_COMBINED_LENGTH = 30;
|
||||
const MAX_SINGLE_WORD_LENGTH = 15;
|
||||
let shortWords = [];
|
||||
let shortWordLengths = [];
|
||||
let wordPairCount = 0;
|
||||
// Build length buckets for O(N) pair counting: lengthBuckets[len] = count of words with that length
|
||||
let lengthBuckets = [];
|
||||
// Cumulative counts: wordsAtMostLength[len] = count of words with length <= len
|
||||
let wordsAtMostLength = [];
|
||||
if (useDictionary && useTwoWordCombinations && !skipWordPairs && this.wordlist.length > 0) {
|
||||
// Filter to short words only
|
||||
shortWords = this.wordlist.filter(w => w.length <= MAX_SINGLE_WORD_LENGTH);
|
||||
shortWordLengths = shortWords.map(w => w.length);
|
||||
// Resolve dictionary-pair resume indices now that shortWords is built
|
||||
const pairOpts = options;
|
||||
if (pairOpts._pairResumeWord1 && pairOpts._pairResumeWord2) {
|
||||
const idx1 = shortWords.indexOf(pairOpts._pairResumeWord1);
|
||||
const idx2 = shortWords.indexOf(pairOpts._pairResumeWord2);
|
||||
if (idx1 >= 0 && idx2 >= 0) {
|
||||
wordPairStartI = idx1;
|
||||
wordPairStartJ = idx2 + 1;
|
||||
if (wordPairStartJ >= shortWords.length) {
|
||||
wordPairStartI++;
|
||||
wordPairStartJ = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Build length buckets
|
||||
lengthBuckets = new Array(MAX_SINGLE_WORD_LENGTH + 1).fill(0);
|
||||
for (const len of shortWordLengths) {
|
||||
lengthBuckets[len]++;
|
||||
}
|
||||
// Build cumulative counts
|
||||
wordsAtMostLength = new Array(MAX_COMBINED_LENGTH + 1).fill(0);
|
||||
let cumulative = 0;
|
||||
for (let len = 0; len <= MAX_COMBINED_LENGTH; len++) {
|
||||
if (len <= MAX_SINGLE_WORD_LENGTH) {
|
||||
cumulative += lengthBuckets[len];
|
||||
}
|
||||
wordsAtMostLength[len] = cumulative;
|
||||
}
|
||||
// Count pairs efficiently: for each word of length L, it can pair with any word of length <= (30 - L)
|
||||
// This is O(N) instead of O(N²)
|
||||
for (let i = wordPairStartI; i < shortWords.length; i++) {
|
||||
const len1 = shortWordLengths[i];
|
||||
const maxLen2 = MAX_COMBINED_LENGTH - len1;
|
||||
const countForThisWord = wordsAtMostLength[Math.min(maxLen2, MAX_SINGLE_WORD_LENGTH)];
|
||||
if (i === wordPairStartI && wordPairStartJ > 0) {
|
||||
// Partial first row - subtract words we're skipping
|
||||
wordPairCount += Math.max(0, countForThisWord - wordPairStartJ);
|
||||
}
|
||||
else {
|
||||
wordPairCount += countForThisWord;
|
||||
}
|
||||
}
|
||||
totalCandidates += wordPairCount;
|
||||
}
|
||||
// Add brute force candidates (use full index space to cover all valid names)
|
||||
for (let l = startFromLength; l <= maxLength; l++) {
|
||||
totalCandidates += indexSpaceForLength(l);
|
||||
}
|
||||
totalCandidates -= startFromOffset;
|
||||
// Helper to report progress
|
||||
const reportProgress = (phase, currentLength, currentPosition) => {
|
||||
if (!onProgress)
|
||||
return;
|
||||
const now = performance.now();
|
||||
const elapsed = (now - startTime) / 1000;
|
||||
const rate = elapsed > 0 ? Math.round(totalChecked / elapsed) : 0;
|
||||
const remaining = totalCandidates - totalChecked;
|
||||
const eta = rate > 0 ? remaining / rate : 0;
|
||||
onProgress({
|
||||
checked: totalChecked,
|
||||
total: totalCandidates,
|
||||
percent: totalCandidates > 0 ? Math.min(100, (totalChecked / totalCandidates) * 100) : 0,
|
||||
rateKeysPerSec: rate,
|
||||
etaSeconds: eta,
|
||||
elapsedSeconds: elapsed,
|
||||
currentLength,
|
||||
currentPosition,
|
||||
phase,
|
||||
});
|
||||
};
|
||||
// Helper to verify MAC and filters
|
||||
const verifyMacAndFilters = (key) => {
|
||||
if (!verifyMac(ciphertext, cipherMac, key)) {
|
||||
return { valid: false };
|
||||
}
|
||||
const result = ChannelCrypto.decryptGroupTextMessage(ciphertext, cipherMac, key);
|
||||
if (!result.success || !result.data) {
|
||||
return { valid: false };
|
||||
}
|
||||
if (this.useTimestampFilter && !isTimestampValid(result.data.timestamp, this.validSeconds)) {
|
||||
return { valid: false };
|
||||
}
|
||||
if (this.useUtf8Filter && !isValidUtf8(result.data.message)) {
|
||||
return { valid: false };
|
||||
}
|
||||
if (this.useSenderFilter && !result.data.sender) {
|
||||
return { valid: false };
|
||||
}
|
||||
// Format message with sender prefix if available
|
||||
const fullMessage = result.data.sender
|
||||
? `${result.data.sender}: ${result.data.message}`
|
||||
: result.data.message;
|
||||
return { valid: true, message: fullMessage };
|
||||
};
|
||||
// Phase 1: Try public key (only if not resuming)
|
||||
if (!skipDictionary && dictionaryStartIndex === 0 && startFromLength === startingLength && startFromOffset === 0) {
|
||||
reportProgress('public-key', 0, PUBLIC_ROOM_NAME);
|
||||
const publicChannelHash = getChannelHash(PUBLIC_KEY);
|
||||
if (channelHash === publicChannelHash) {
|
||||
const result = verifyMacAndFilters(PUBLIC_KEY);
|
||||
if (result.valid) {
|
||||
return {
|
||||
found: true,
|
||||
roomName: PUBLIC_ROOM_NAME,
|
||||
key: PUBLIC_KEY,
|
||||
decryptedMessage: result.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// Track last processed position for abort/resume (bug #2 fix).
|
||||
// On abort, we return this so resume doesn't skip an untested candidate.
|
||||
let lastResumeFrom;
|
||||
let lastResumeType;
|
||||
const abortResult = () => ({
|
||||
found: false,
|
||||
aborted: true,
|
||||
resumeFrom: lastResumeFrom,
|
||||
resumeType: lastResumeType,
|
||||
});
|
||||
// Phase 2: Dictionary attack
|
||||
if (useDictionary && !skipDictionary && this.wordlist.length > 0) {
|
||||
for (let i = dictionaryStartIndex; i < this.wordlist.length; i++) {
|
||||
if (this.abortFlag) {
|
||||
return abortResult();
|
||||
}
|
||||
const word = this.wordlist[i];
|
||||
const key = deriveKeyFromRoomName('#' + word);
|
||||
const wordChannelHash = getChannelHash(key);
|
||||
if (parseInt(wordChannelHash, 16) === targetHashByte) {
|
||||
const result = verifyMacAndFilters(key);
|
||||
if (result.valid) {
|
||||
return {
|
||||
found: true,
|
||||
roomName: word,
|
||||
key,
|
||||
decryptedMessage: result.message,
|
||||
// Include resume info so caller can skip this result and continue
|
||||
resumeFrom: word,
|
||||
resumeType: 'dictionary',
|
||||
};
|
||||
}
|
||||
}
|
||||
totalChecked++;
|
||||
lastResumeFrom = word;
|
||||
lastResumeType = 'dictionary';
|
||||
// Progress update
|
||||
const now = performance.now();
|
||||
if (now - lastProgressUpdate >= 200) {
|
||||
reportProgress('wordlist', word.length, word);
|
||||
lastProgressUpdate = now;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Phase 2.5: Two-word combinations (EXPERIMENTAL)
|
||||
// Uses pre-filtered shortWords (words with length <= 15) for efficiency
|
||||
// GPU-accelerated when available
|
||||
if (useDictionary && useTwoWordCombinations && !skipWordPairs && shortWords.length > 0) {
|
||||
const totalPairs = shortWords.length * shortWords.length;
|
||||
const startPairIdx = wordPairStartI * shortWords.length + wordPairStartJ;
|
||||
// Try GPU acceleration for word pairs
|
||||
let useGpuForPairs = !this.useCpu;
|
||||
if (useGpuForPairs) {
|
||||
if (!this.gpuWordPairs) {
|
||||
this.gpuWordPairs = new GpuWordPairs();
|
||||
const gpuOk = await this.gpuWordPairs.init();
|
||||
if (!gpuOk) {
|
||||
useGpuForPairs = false;
|
||||
this.gpuWordPairs = null;
|
||||
}
|
||||
}
|
||||
if (this.gpuWordPairs) {
|
||||
this.gpuWordPairs.uploadWords(shortWords);
|
||||
}
|
||||
}
|
||||
if (useGpuForPairs && this.gpuWordPairs) {
|
||||
// GPU-accelerated word pair checking
|
||||
// Start with 1M pairs (like Python/OpenCL version) for better throughput
|
||||
const INITIAL_PAIR_BATCH_SIZE = 1048576; // 1M
|
||||
// WebGPU limits dispatchWorkgroups to 65535 per dimension
|
||||
// With workgroup_size(256) and 32 pairs/thread: 65535 * 256 * 32 = 536,870,880
|
||||
const MAX_PAIR_BATCH_SIZE = 65535 * 256 * 32;
|
||||
const TARGET_PAIR_DISPATCH_MS = options?.gpuDispatchMs ?? 1000;
|
||||
let pairBatchSize = INITIAL_PAIR_BATCH_SIZE;
|
||||
let pairBatchTuned = false;
|
||||
let pairOffset = startPairIdx;
|
||||
while (pairOffset < totalPairs) {
|
||||
if (this.abortFlag) {
|
||||
return abortResult();
|
||||
}
|
||||
const batchSize = Math.min(pairBatchSize, totalPairs - pairOffset);
|
||||
const dispatchStart = performance.now();
|
||||
const matches = await this.gpuWordPairs.runBatch(targetHashByte, pairOffset, batchSize, ciphertext, cipherMac);
|
||||
const dispatchTime = performance.now() - dispatchStart;
|
||||
totalChecked += batchSize;
|
||||
// Auto-tune batch size
|
||||
if (!pairBatchTuned && batchSize >= INITIAL_PAIR_BATCH_SIZE && dispatchTime > 0) {
|
||||
const scaleFactor = TARGET_PAIR_DISPATCH_MS / dispatchTime;
|
||||
const optimalBatchSize = Math.round(batchSize * scaleFactor);
|
||||
const rounded = Math.pow(2, Math.round(Math.log2(Math.max(INITIAL_PAIR_BATCH_SIZE, optimalBatchSize))));
|
||||
pairBatchSize = Math.min(Math.max(INITIAL_PAIR_BATCH_SIZE, rounded), MAX_PAIR_BATCH_SIZE);
|
||||
pairBatchTuned = true;
|
||||
}
|
||||
// Check matches
|
||||
for (const [i, j] of matches) {
|
||||
const word1 = shortWords[i];
|
||||
const word2 = shortWords[j];
|
||||
const combined = word1 + word2;
|
||||
const key = deriveKeyFromRoomName('#' + combined);
|
||||
const result = verifyMacAndFilters(key);
|
||||
if (result.valid) {
|
||||
return {
|
||||
found: true,
|
||||
roomName: combined,
|
||||
key,
|
||||
decryptedMessage: result.message,
|
||||
resumeFrom: `${word1}+${word2}`,
|
||||
resumeType: 'dictionary-pair',
|
||||
};
|
||||
}
|
||||
}
|
||||
pairOffset += batchSize;
|
||||
// Update resume position to end of processed batch
|
||||
const lastPairIdx = pairOffset - 1;
|
||||
const li = Math.floor(lastPairIdx / shortWords.length);
|
||||
const lj = lastPairIdx % shortWords.length;
|
||||
if (li < shortWords.length) {
|
||||
lastResumeFrom = `${shortWords[li]}+${shortWords[lj]}`;
|
||||
lastResumeType = 'dictionary-pair';
|
||||
}
|
||||
// Progress update
|
||||
const now = performance.now();
|
||||
if (now - lastProgressUpdate >= 200) {
|
||||
const i = Math.floor(Math.min(pairOffset, totalPairs - 1) / shortWords.length);
|
||||
const j = Math.min(pairOffset, totalPairs - 1) % shortWords.length;
|
||||
reportProgress('wordlist-pairs', 0, `${shortWords[i]}+${shortWords[j]}`);
|
||||
lastProgressUpdate = now;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// CPU fallback for word pairs
|
||||
for (let i = wordPairStartI; i < shortWords.length; i++) {
|
||||
const word1 = shortWords[i];
|
||||
const len1 = shortWordLengths[i];
|
||||
const maxLen2 = MAX_COMBINED_LENGTH - len1;
|
||||
const startJ = i === wordPairStartI ? wordPairStartJ : 0;
|
||||
for (let j = startJ; j < shortWords.length; j++) {
|
||||
if (this.abortFlag) {
|
||||
return abortResult();
|
||||
}
|
||||
const len2 = shortWordLengths[j];
|
||||
if (len2 > maxLen2)
|
||||
continue;
|
||||
const word2 = shortWords[j];
|
||||
const combined = word1 + word2;
|
||||
// Validate combined name (check for consecutive dashes at join point)
|
||||
if (!isValidRoomName(combined))
|
||||
continue;
|
||||
const key = deriveKeyFromRoomName('#' + combined);
|
||||
const pairChannelHash = getChannelHash(key);
|
||||
if (parseInt(pairChannelHash, 16) === targetHashByte) {
|
||||
const result = verifyMacAndFilters(key);
|
||||
if (result.valid) {
|
||||
return {
|
||||
found: true,
|
||||
roomName: combined,
|
||||
key,
|
||||
decryptedMessage: result.message,
|
||||
resumeFrom: `${word1}+${word2}`,
|
||||
resumeType: 'dictionary-pair',
|
||||
};
|
||||
}
|
||||
}
|
||||
totalChecked++;
|
||||
lastResumeFrom = `${word1}+${word2}`;
|
||||
lastResumeType = 'dictionary-pair';
|
||||
// Progress update
|
||||
const now = performance.now();
|
||||
if (now - lastProgressUpdate >= 200) {
|
||||
reportProgress('wordlist-pairs', len1 + len2, `${word1}+${word2}`);
|
||||
lastProgressUpdate = now;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Phase 3: Brute force (GPU or CPU)
|
||||
// Use full index space to cover all valid names including dashed ones (bug #1 fix)
|
||||
const INITIAL_BATCH_SIZE = this.useCpu ? 1024 : 32768;
|
||||
// WebGPU limits dispatchWorkgroups to 65535 per dimension
|
||||
// With workgroup_size(256) and 32 candidates/thread: 65535 * 256 * 32 = 536,870,880
|
||||
const MAX_BATCH_SIZE = 65535 * 256 * 32;
|
||||
const TARGET_DISPATCH_MS = options?.gpuDispatchMs ?? 1000;
|
||||
let currentBatchSize = INITIAL_BATCH_SIZE;
|
||||
let batchSizeTuned = false;
|
||||
for (let length = startFromLength; length <= maxLength; length++) {
|
||||
if (this.abortFlag) {
|
||||
return abortResult();
|
||||
}
|
||||
const totalForLength = indexSpaceForLength(length);
|
||||
let offset = length === startFromLength ? startFromOffset : 0;
|
||||
while (offset < totalForLength) {
|
||||
if (this.abortFlag) {
|
||||
return abortResult();
|
||||
}
|
||||
const batchSize = Math.min(currentBatchSize, totalForLength - offset);
|
||||
const dispatchStart = performance.now();
|
||||
// Run batch on GPU or CPU
|
||||
let matches;
|
||||
if (this.useCpu) {
|
||||
matches = this.cpuInstance.runBatch(targetHashByte, length, offset, batchSize, ciphertext, cipherMac);
|
||||
}
|
||||
else {
|
||||
matches = await this.gpuInstance.runBatch(targetHashByte, length, offset, batchSize, ciphertext, cipherMac);
|
||||
}
|
||||
const dispatchTime = performance.now() - dispatchStart;
|
||||
totalChecked += batchSize;
|
||||
// Auto-tune batch size (GPU only)
|
||||
if (!this.useCpu && !batchSizeTuned && batchSize >= INITIAL_BATCH_SIZE && dispatchTime > 0) {
|
||||
const scaleFactor = TARGET_DISPATCH_MS / dispatchTime;
|
||||
const optimalBatchSize = Math.round(batchSize * scaleFactor);
|
||||
const rounded = Math.pow(2, Math.round(Math.log2(Math.max(INITIAL_BATCH_SIZE, optimalBatchSize))));
|
||||
currentBatchSize = Math.min(Math.max(INITIAL_BATCH_SIZE, rounded), MAX_BATCH_SIZE);
|
||||
batchSizeTuned = true;
|
||||
}
|
||||
// Check matches
|
||||
for (const matchIdx of matches) {
|
||||
const roomName = indexToRoomName(length, matchIdx);
|
||||
if (!roomName)
|
||||
continue;
|
||||
const key = deriveKeyFromRoomName('#' + roomName);
|
||||
const result = verifyMacAndFilters(key);
|
||||
if (result.valid) {
|
||||
return {
|
||||
found: true,
|
||||
roomName,
|
||||
key,
|
||||
decryptedMessage: result.message,
|
||||
// Include resume info so caller can skip this result and continue
|
||||
resumeFrom: roomName,
|
||||
resumeType: 'bruteforce',
|
||||
};
|
||||
}
|
||||
}
|
||||
offset += batchSize;
|
||||
// Update resume position to last index of processed batch
|
||||
const endIdx = Math.min(offset - 1, totalForLength - 1);
|
||||
const endName = indexToRoomName(length, endIdx);
|
||||
if (endName) {
|
||||
lastResumeFrom = endName;
|
||||
lastResumeType = 'bruteforce';
|
||||
}
|
||||
// Progress update
|
||||
const now = performance.now();
|
||||
if (now - lastProgressUpdate >= 200) {
|
||||
const currentPos = indexToRoomName(length, Math.min(offset, totalForLength - 1)) || '';
|
||||
reportProgress('bruteforce', length, currentPos);
|
||||
lastProgressUpdate = now;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Not found
|
||||
return {
|
||||
found: false,
|
||||
resumeFrom: lastResumeFrom,
|
||||
resumeType: lastResumeType,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Clean up resources.
|
||||
* Call this when you're done using the cracker.
|
||||
*/
|
||||
destroy() {
|
||||
if (this.gpuInstance) {
|
||||
this.gpuInstance.destroy();
|
||||
this.gpuInstance = null;
|
||||
}
|
||||
if (this.gpuWordPairs) {
|
||||
this.gpuWordPairs.destroy();
|
||||
this.gpuWordPairs = null;
|
||||
}
|
||||
if (this.cpuInstance) {
|
||||
this.cpuInstance.destroy();
|
||||
this.cpuInstance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=cracker.js.map
|
||||
1
package/dist/cracker.js.map
vendored
Executable file
1
package/dist/cracker.js.map
vendored
Executable file
File diff suppressed because one or more lines are too long
42
package/dist/dictionary-index.d.ts
vendored
Executable file
42
package/dist/dictionary-index.d.ts
vendored
Executable file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* DictionaryIndex - Precomputed hash-indexed wordlist for O(1) lookup
|
||||
*
|
||||
* Instead of checking every word in the dictionary for each packet,
|
||||
* we precompute the channel hash for each word and group them by hash.
|
||||
* This reduces dictionary lookup from O(n) to O(n/256) on average.
|
||||
*/
|
||||
export interface IndexedWord {
|
||||
word: string;
|
||||
key: string;
|
||||
}
|
||||
export declare class DictionaryIndex {
|
||||
private byHash;
|
||||
private totalWords;
|
||||
constructor();
|
||||
/**
|
||||
* Build index from wordlist. This precomputes keys and channel hashes.
|
||||
* @param words - Array of room names (without # prefix)
|
||||
* @param onProgress - Optional progress callback
|
||||
*/
|
||||
build(words: string[], onProgress?: (indexed: number, total: number) => void): void;
|
||||
/**
|
||||
* Look up all words matching a channel hash byte.
|
||||
* @param channelHash - The target channel hash (0-255)
|
||||
* @returns Array of words and their precomputed keys
|
||||
*/
|
||||
lookup(channelHash: number): IndexedWord[];
|
||||
/**
|
||||
* Get the total number of indexed words.
|
||||
*/
|
||||
size(): number;
|
||||
/**
|
||||
* Get statistics about the index distribution.
|
||||
*/
|
||||
getStats(): {
|
||||
total: number;
|
||||
buckets: number;
|
||||
avgPerBucket: number;
|
||||
maxBucket: number;
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=dictionary-index.d.ts.map
|
||||
1
package/dist/dictionary-index.d.ts.map
vendored
Executable file
1
package/dist/dictionary-index.d.ts.map
vendored
Executable file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"dictionary-index.d.ts","sourceRoot":"","sources":["../src/dictionary-index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;CACb;AAED,qBAAa,eAAe;IAE1B,OAAO,CAAC,MAAM,CAAyC;IACvD,OAAO,CAAC,UAAU,CAAa;;IAS/B;;;;OAIG;IACH,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI;IAsBnF;;;;OAIG;IACH,MAAM,CAAC,WAAW,EAAE,MAAM,GAAG,WAAW,EAAE;IAI1C;;OAEG;IACH,IAAI,IAAI,MAAM;IAId;;OAEG;IACH,QAAQ,IAAI;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE;CAkBxF"}
|
||||
75
package/dist/dictionary-index.js
vendored
Executable file
75
package/dist/dictionary-index.js
vendored
Executable file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* DictionaryIndex - Precomputed hash-indexed wordlist for O(1) lookup
|
||||
*
|
||||
* Instead of checking every word in the dictionary for each packet,
|
||||
* we precompute the channel hash for each word and group them by hash.
|
||||
* This reduces dictionary lookup from O(n) to O(n/256) on average.
|
||||
*/
|
||||
import { deriveKeyFromRoomName, getChannelHash } from './core';
|
||||
export class DictionaryIndex {
|
||||
constructor() {
|
||||
// Map from channel hash byte (0-255) to words with that hash
|
||||
this.byHash = new Map();
|
||||
this.totalWords = 0;
|
||||
// Initialize empty buckets
|
||||
for (let i = 0; i < 256; i++) {
|
||||
this.byHash.set(i, []);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Build index from wordlist. This precomputes keys and channel hashes.
|
||||
* @param words - Array of room names (without # prefix)
|
||||
* @param onProgress - Optional progress callback
|
||||
*/
|
||||
build(words, onProgress) {
|
||||
this.totalWords = words.length;
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const word = words[i];
|
||||
const key = deriveKeyFromRoomName('#' + word);
|
||||
const channelHashHex = getChannelHash(key);
|
||||
const channelHash = parseInt(channelHashHex, 16);
|
||||
this.byHash.get(channelHash).push({ word, key });
|
||||
// Report progress every 10000 words
|
||||
if (onProgress && i % 10000 === 0) {
|
||||
onProgress(i, words.length);
|
||||
}
|
||||
}
|
||||
if (onProgress) {
|
||||
onProgress(words.length, words.length);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Look up all words matching a channel hash byte.
|
||||
* @param channelHash - The target channel hash (0-255)
|
||||
* @returns Array of words and their precomputed keys
|
||||
*/
|
||||
lookup(channelHash) {
|
||||
return this.byHash.get(channelHash) ?? [];
|
||||
}
|
||||
/**
|
||||
* Get the total number of indexed words.
|
||||
*/
|
||||
size() {
|
||||
return this.totalWords;
|
||||
}
|
||||
/**
|
||||
* Get statistics about the index distribution.
|
||||
*/
|
||||
getStats() {
|
||||
let maxBucket = 0;
|
||||
let nonEmpty = 0;
|
||||
for (const [, words] of this.byHash) {
|
||||
if (words.length > 0) {
|
||||
nonEmpty++;
|
||||
maxBucket = Math.max(maxBucket, words.length);
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: this.totalWords,
|
||||
buckets: nonEmpty,
|
||||
avgPerBucket: this.totalWords / 256,
|
||||
maxBucket,
|
||||
};
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=dictionary-index.js.map
|
||||
1
package/dist/dictionary-index.js.map
vendored
Executable file
1
package/dist/dictionary-index.js.map
vendored
Executable file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"dictionary-index.js","sourceRoot":"","sources":["../src/dictionary-index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,qBAAqB,EAAE,cAAc,EAAE,MAAM,QAAQ,CAAC;AAO/D,MAAM,OAAO,eAAe;IAK1B;QAJA,6DAA6D;QACrD,WAAM,GAA+B,IAAI,GAAG,EAAE,CAAC;QAC/C,eAAU,GAAW,CAAC,CAAC;QAG7B,2BAA2B;QAC3B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;YAC7B,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;QACzB,CAAC;IACH,CAAC;IAED;;;;OAIG;IACH,KAAK,CAAC,KAAe,EAAE,UAAqD;QAC1E,IAAI,CAAC,UAAU,GAAG,KAAK,CAAC,MAAM,CAAC;QAE/B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACtC,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;YACtB,MAAM,GAAG,GAAG,qBAAqB,CAAC,GAAG,GAAG,IAAI,CAAC,CAAC;YAC9C,MAAM,cAAc,GAAG,cAAc,CAAC,GAAG,CAAC,CAAC;YAC3C,MAAM,WAAW,GAAG,QAAQ,CAAC,cAAc,EAAE,EAAE,CAAC,CAAC;YAEjD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,WAAW,CAAE,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC;YAElD,oCAAoC;YACpC,IAAI,UAAU,IAAI,CAAC,GAAG,KAAK,KAAK,CAAC,EAAE,CAAC;gBAClC,UAAU,CAAC,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;YAC9B,CAAC;QACH,CAAC;QAED,IAAI,UAAU,EAAE,CAAC;YACf,UAAU,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;QACzC,CAAC;IACH,CAAC;IAED;;;;OAIG;IACH,MAAM,CAAC,WAAmB;QACxB,OAAO,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC;IAC5C,CAAC;IAED;;OAEG;IACH,IAAI;QACF,OAAO,IAAI,CAAC,UAAU,CAAC;IACzB,CAAC;IAED;;OAEG;IACH,QAAQ;QACN,IAAI,SAAS,GAAG,CAAC,CAAC;QAClB,IAAI,QAAQ,GAAG,CAAC,CAAC;QAEjB,KAAK,MAAM,CAAC,EAAE,KAAK,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YACpC,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBACrB,QAAQ,EAAE,CAAC;gBACX,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;YAChD,CAAC;QACH,CAAC;QAED,OAAO;YACL,KAAK,EAAE,IAAI,CAAC,UAAU;YACtB,OAAO,EAAE,QAAQ;YACjB,YAAY,EAAE,IAAI,CAAC,UAAU,GAAG,GAAG;YACnC,SAAS;SACV,CAAC;IACJ,CAAC;CACF"}
|
||||
34
package/dist/gpu-bruteforce.d.ts
vendored
Executable file
34
package/dist/gpu-bruteforce.d.ts
vendored
Executable file
@@ -0,0 +1,34 @@
|
||||
export interface GpuBruteForceResult {
|
||||
found: boolean;
|
||||
roomName?: string;
|
||||
key?: string;
|
||||
candidateIndices?: number[];
|
||||
}
|
||||
export declare class GpuBruteForce {
|
||||
private device;
|
||||
private pipeline;
|
||||
private bindGroupLayout;
|
||||
private paramsBuffer;
|
||||
private matchCountBuffer;
|
||||
private matchIndicesBuffer;
|
||||
private ciphertextBuffer;
|
||||
private ciphertextBufferSize;
|
||||
private matchCountReadBuffers;
|
||||
private matchIndicesReadBuffers;
|
||||
private currentReadBufferIndex;
|
||||
private bindGroup;
|
||||
private bindGroupDirty;
|
||||
private static readonly ZERO_DATA;
|
||||
private shaderCode;
|
||||
init(): Promise<boolean>;
|
||||
isAvailable(): boolean;
|
||||
indexToRoomName(idx: number, length: number): string | null;
|
||||
countNamesForLength(len: number): number;
|
||||
runBatch(targetChannelHash: number, nameLength: number, batchOffset: number, batchSize: number, ciphertextHex?: string, targetMacHex?: string): Promise<number[]>;
|
||||
destroy(): void;
|
||||
}
|
||||
/**
|
||||
* Check if WebGPU is supported in the current browser.
|
||||
*/
|
||||
export declare function isWebGpuSupported(): boolean;
|
||||
//# sourceMappingURL=gpu-bruteforce.d.ts.map
|
||||
1
package/dist/gpu-bruteforce.d.ts.map
vendored
Executable file
1
package/dist/gpu-bruteforce.d.ts.map
vendored
Executable file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"gpu-bruteforce.d.ts","sourceRoot":"","sources":["../src/gpu-bruteforce.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,OAAO,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;CAC7B;AAED,qBAAa,aAAa;IACxB,OAAO,CAAC,MAAM,CAA0B;IACxC,OAAO,CAAC,QAAQ,CAAmC;IACnD,OAAO,CAAC,eAAe,CAAmC;IAG1D,OAAO,CAAC,YAAY,CAA0B;IAC9C,OAAO,CAAC,gBAAgB,CAA0B;IAClD,OAAO,CAAC,kBAAkB,CAA0B;IACpD,OAAO,CAAC,gBAAgB,CAA0B;IAClD,OAAO,CAAC,oBAAoB,CAAa;IAGzC,OAAO,CAAC,qBAAqB,CAAsD;IACnF,OAAO,CAAC,uBAAuB,CAAsD;IACrF,OAAO,CAAC,sBAAsB,CAAa;IAG3C,OAAO,CAAC,SAAS,CAA6B;IAC9C,OAAO,CAAC,cAAc,CAAiB;IAGvC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAwB;IAGzD,OAAO,CAAC,UAAU,CAkYlB;IAEM,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC;IA8E9B,WAAW,IAAI,OAAO;IAKtB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAK3D,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM;IAIlC,QAAQ,CACZ,iBAAiB,EAAE,MAAM,EACzB,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,aAAa,CAAC,EAAE,MAAM,EACtB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,MAAM,EAAE,CAAC;IAkJpB,OAAO,IAAI,IAAI;CA+BhB;AAED;;GAEG;AACH,wBAAgB,iBAAiB,IAAI,OAAO,CAE3C"}
|
||||
645
package/dist/gpu-bruteforce.js
vendored
Executable file
645
package/dist/gpu-bruteforce.js
vendored
Executable file
@@ -0,0 +1,645 @@
|
||||
// WebGPU-accelerated brute force key cracking for MeshCore packets
|
||||
import { indexToRoomName, countNamesForLength } from './core.js';
|
||||
export class GpuBruteForce {
|
||||
constructor() {
|
||||
this.device = null;
|
||||
this.pipeline = null;
|
||||
this.bindGroupLayout = null;
|
||||
// Persistent buffers for reuse between batches
|
||||
this.paramsBuffer = null;
|
||||
this.matchCountBuffer = null;
|
||||
this.matchIndicesBuffer = null;
|
||||
this.ciphertextBuffer = null;
|
||||
this.ciphertextBufferSize = 0;
|
||||
// Double-buffered staging buffers for overlapping GPU/CPU work
|
||||
this.matchCountReadBuffers = [null, null];
|
||||
this.matchIndicesReadBuffers = [null, null];
|
||||
this.currentReadBufferIndex = 0;
|
||||
// Cached bind group (recreated only when ciphertext buffer changes)
|
||||
this.bindGroup = null;
|
||||
this.bindGroupDirty = true;
|
||||
// Shader for SHA256 computation
|
||||
this.shaderCode = `
|
||||
// SHA256 round constants
|
||||
const K: array<u32, 64> = array<u32, 64>(
|
||||
0x428a2f98u, 0x71374491u, 0xb5c0fbcfu, 0xe9b5dba5u, 0x3956c25bu, 0x59f111f1u, 0x923f82a4u, 0xab1c5ed5u,
|
||||
0xd807aa98u, 0x12835b01u, 0x243185beu, 0x550c7dc3u, 0x72be5d74u, 0x80deb1feu, 0x9bdc06a7u, 0xc19bf174u,
|
||||
0xe49b69c1u, 0xefbe4786u, 0x0fc19dc6u, 0x240ca1ccu, 0x2de92c6fu, 0x4a7484aau, 0x5cb0a9dcu, 0x76f988dau,
|
||||
0x983e5152u, 0xa831c66du, 0xb00327c8u, 0xbf597fc7u, 0xc6e00bf3u, 0xd5a79147u, 0x06ca6351u, 0x14292967u,
|
||||
0x27b70a85u, 0x2e1b2138u, 0x4d2c6dfcu, 0x53380d13u, 0x650a7354u, 0x766a0abbu, 0x81c2c92eu, 0x92722c85u,
|
||||
0xa2bfe8a1u, 0xa81a664bu, 0xc24b8b70u, 0xc76c51a3u, 0xd192e819u, 0xd6990624u, 0xf40e3585u, 0x106aa070u,
|
||||
0x19a4c116u, 0x1e376c08u, 0x2748774cu, 0x34b0bcb5u, 0x391c0cb3u, 0x4ed8aa4au, 0x5b9cca4fu, 0x682e6ff3u,
|
||||
0x748f82eeu, 0x78a5636fu, 0x84c87814u, 0x8cc70208u, 0x90befffau, 0xa4506cebu, 0xbef9a3f7u, 0xc67178f2u
|
||||
);
|
||||
|
||||
// Character lookup table (a-z = 0-25, 0-9 = 26-35, dash = 36)
|
||||
const CHARS: array<u32, 37> = array<u32, 37>(
|
||||
0x61u, 0x62u, 0x63u, 0x64u, 0x65u, 0x66u, 0x67u, 0x68u, 0x69u, 0x6au, // a-j
|
||||
0x6bu, 0x6cu, 0x6du, 0x6eu, 0x6fu, 0x70u, 0x71u, 0x72u, 0x73u, 0x74u, // k-t
|
||||
0x75u, 0x76u, 0x77u, 0x78u, 0x79u, 0x7au, // u-z
|
||||
0x30u, 0x31u, 0x32u, 0x33u, 0x34u, 0x35u, 0x36u, 0x37u, 0x38u, 0x39u, // 0-9
|
||||
0x2du // dash
|
||||
);
|
||||
|
||||
struct Params {
|
||||
target_channel_hash: u32,
|
||||
batch_offset: u32,
|
||||
name_length: u32,
|
||||
batch_size: u32,
|
||||
target_mac: u32, // First 2 bytes of target MAC (in high 16 bits)
|
||||
ciphertext_words: u32, // Number of 32-bit words in ciphertext
|
||||
ciphertext_len_bits: u32, // Length of ciphertext in bits
|
||||
verify_mac: u32, // 1 to verify MAC, 0 to skip
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<storage, read_write> match_count: atomic<u32>;
|
||||
@group(0) @binding(2) var<storage, read_write> match_indices: array<u32>;
|
||||
@group(0) @binding(3) var<storage, read> ciphertext: array<u32>; // Ciphertext data
|
||||
|
||||
fn rotr(x: u32, n: u32) -> u32 {
|
||||
return (x >> n) | (x << (32u - n));
|
||||
}
|
||||
|
||||
fn ch(x: u32, y: u32, z: u32) -> u32 {
|
||||
return (x & y) ^ (~x & z);
|
||||
}
|
||||
|
||||
fn maj(x: u32, y: u32, z: u32) -> u32 {
|
||||
return (x & y) ^ (x & z) ^ (y & z);
|
||||
}
|
||||
|
||||
fn sigma0(x: u32) -> u32 {
|
||||
return rotr(x, 2u) ^ rotr(x, 13u) ^ rotr(x, 22u);
|
||||
}
|
||||
|
||||
fn sigma1(x: u32) -> u32 {
|
||||
return rotr(x, 6u) ^ rotr(x, 11u) ^ rotr(x, 25u);
|
||||
}
|
||||
|
||||
fn gamma0(x: u32) -> u32 {
|
||||
return rotr(x, 7u) ^ rotr(x, 18u) ^ (x >> 3u);
|
||||
}
|
||||
|
||||
fn gamma1(x: u32) -> u32 {
|
||||
return rotr(x, 17u) ^ rotr(x, 19u) ^ (x >> 10u);
|
||||
}
|
||||
|
||||
// Convert index to room name bytes, returns the hash as a u32 for the first byte check
|
||||
fn index_to_room_name(idx: u32, length: u32, msg: ptr<function, array<u32, 16>>) -> bool {
|
||||
// Message starts with '#' (0x23)
|
||||
var byte_pos = 0u;
|
||||
var word_idx = 0u;
|
||||
var current_word = 0x23000000u; // '#' in big-endian position 0
|
||||
byte_pos = 1u;
|
||||
|
||||
var remaining = idx;
|
||||
var prev_was_dash = false;
|
||||
|
||||
// Generate room name from index
|
||||
for (var i = 0u; i < length; i++) {
|
||||
let char_count = select(37u, 36u, i == 0u || i == length - 1u); // no dash at start/end
|
||||
var char_idx = remaining % char_count;
|
||||
remaining = remaining / char_count;
|
||||
|
||||
// Check for consecutive dashes (invalid)
|
||||
let is_dash = char_idx == 36u && i > 0u && i < length - 1u;
|
||||
if (is_dash && prev_was_dash) {
|
||||
return false; // Invalid: consecutive dashes
|
||||
}
|
||||
prev_was_dash = is_dash;
|
||||
|
||||
// Map char index to actual character
|
||||
let c = CHARS[char_idx];
|
||||
|
||||
// Pack byte into current word (big-endian)
|
||||
let shift = (3u - byte_pos % 4u) * 8u;
|
||||
if (byte_pos % 4u == 0u && byte_pos > 0u) {
|
||||
(*msg)[word_idx] = current_word;
|
||||
word_idx = word_idx + 1u;
|
||||
current_word = 0u;
|
||||
}
|
||||
current_word = current_word | (c << shift);
|
||||
byte_pos = byte_pos + 1u;
|
||||
}
|
||||
|
||||
// Add padding: 0x80 followed by zeros, then length in bits
|
||||
let msg_len_bits = (length + 1u) * 8u; // +1 for '#'
|
||||
|
||||
// Add 0x80 padding byte
|
||||
let shift = (3u - byte_pos % 4u) * 8u;
|
||||
if (byte_pos % 4u == 0u) {
|
||||
(*msg)[word_idx] = current_word;
|
||||
word_idx = word_idx + 1u;
|
||||
current_word = 0x80000000u;
|
||||
} else {
|
||||
current_word = current_word | (0x80u << shift);
|
||||
}
|
||||
byte_pos = byte_pos + 1u;
|
||||
|
||||
// Store current word
|
||||
if (byte_pos % 4u == 0u || word_idx < 14u) {
|
||||
(*msg)[word_idx] = current_word;
|
||||
word_idx = word_idx + 1u;
|
||||
}
|
||||
|
||||
// Zero-fill until word 14
|
||||
for (var i = word_idx; i < 14u; i++) {
|
||||
(*msg)[i] = 0u;
|
||||
}
|
||||
|
||||
// Length in bits (64-bit, but we only use lower 32 bits for short messages)
|
||||
(*msg)[14u] = 0u;
|
||||
(*msg)[15u] = msg_len_bits;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
fn sha256_block(msg: ptr<function, array<u32, 16>>) -> array<u32, 8> {
|
||||
// Initialize hash values
|
||||
var h: array<u32, 8> = array<u32, 8>(
|
||||
0x6a09e667u, 0xbb67ae85u, 0x3c6ef372u, 0xa54ff53au,
|
||||
0x510e527fu, 0x9b05688cu, 0x1f83d9abu, 0x5be0cd19u
|
||||
);
|
||||
|
||||
// Message schedule
|
||||
var w: array<u32, 64>;
|
||||
for (var i = 0u; i < 16u; i++) {
|
||||
w[i] = (*msg)[i];
|
||||
}
|
||||
for (var i = 16u; i < 64u; i++) {
|
||||
w[i] = gamma1(w[i-2u]) + w[i-7u] + gamma0(w[i-15u]) + w[i-16u];
|
||||
}
|
||||
|
||||
// Compression
|
||||
var a = h[0]; var b = h[1]; var c = h[2]; var d = h[3];
|
||||
var e = h[4]; var f = h[5]; var g = h[6]; var hh = h[7];
|
||||
|
||||
for (var i = 0u; i < 64u; i++) {
|
||||
let t1 = hh + sigma1(e) + ch(e, f, g) + K[i] + w[i];
|
||||
let t2 = sigma0(a) + maj(a, b, c);
|
||||
hh = g; g = f; f = e; e = d + t1;
|
||||
d = c; c = b; b = a; a = t1 + t2;
|
||||
}
|
||||
|
||||
h[0] = h[0] + a; h[1] = h[1] + b; h[2] = h[2] + c; h[3] = h[3] + d;
|
||||
h[4] = h[4] + e; h[5] = h[5] + f; h[6] = h[6] + g; h[7] = h[7] + hh;
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
// Compute SHA256 of the key (16 bytes) to get channel hash
|
||||
fn sha256_key(key: array<u32, 4>) -> u32 {
|
||||
var msg: array<u32, 16>;
|
||||
|
||||
// Key bytes (16 bytes = 4 words)
|
||||
msg[0] = key[0];
|
||||
msg[1] = key[1];
|
||||
msg[2] = key[2];
|
||||
msg[3] = key[3];
|
||||
|
||||
// Padding: 0x80 followed by zeros
|
||||
msg[4] = 0x80000000u;
|
||||
for (var i = 5u; i < 14u; i++) {
|
||||
msg[i] = 0u;
|
||||
}
|
||||
|
||||
// Length: 128 bits
|
||||
msg[14] = 0u;
|
||||
msg[15] = 128u;
|
||||
|
||||
let hash = sha256_block(&msg);
|
||||
|
||||
// Return first byte of hash (big-endian)
|
||||
return hash[0] >> 24u;
|
||||
}
|
||||
|
||||
// HMAC-SHA256 for MAC verification
|
||||
// Key is 16 bytes (4 words), padded to 32 bytes with zeros for MeshCore
|
||||
// Returns first 2 bytes of HMAC (as u32 in high 16 bits)
|
||||
fn hmac_sha256_mac(key: array<u32, 4>, ciphertext_len: u32) -> u32 {
|
||||
// HMAC: H((K' ^ opad) || H((K' ^ ipad) || message))
|
||||
// K' is 64 bytes (32 bytes key + 32 bytes zero padding for MeshCore, then padded to 64)
|
||||
// ipad = 0x36 repeated, opad = 0x5c repeated
|
||||
|
||||
// Build padded key (64 bytes = 16 words)
|
||||
// MeshCore uses 32-byte secret: 16-byte key + 16 zero bytes
|
||||
var k_pad: array<u32, 16>;
|
||||
k_pad[0] = key[0];
|
||||
k_pad[1] = key[1];
|
||||
k_pad[2] = key[2];
|
||||
k_pad[3] = key[3];
|
||||
for (var i = 4u; i < 16u; i++) {
|
||||
k_pad[i] = 0u;
|
||||
}
|
||||
|
||||
// Inner hash: SHA256((K' ^ ipad) || message)
|
||||
// First block: K' ^ ipad (64 bytes)
|
||||
var inner_block: array<u32, 16>;
|
||||
for (var i = 0u; i < 16u; i++) {
|
||||
inner_block[i] = k_pad[i] ^ 0x36363636u;
|
||||
}
|
||||
|
||||
// Initialize hash state with first block
|
||||
var h: array<u32, 8> = sha256_block(&inner_block);
|
||||
|
||||
// Process ciphertext blocks (continuing from h state)
|
||||
let ciphertext_words = params.ciphertext_words;
|
||||
var word_idx = 0u;
|
||||
|
||||
// Process full 64-byte blocks of ciphertext
|
||||
while (word_idx + 16u <= ciphertext_words) {
|
||||
var block: array<u32, 16>;
|
||||
for (var i = 0u; i < 16u; i++) {
|
||||
block[i] = ciphertext[word_idx + i];
|
||||
}
|
||||
h = sha256_block_continue(&block, h);
|
||||
word_idx = word_idx + 16u;
|
||||
}
|
||||
|
||||
// Final block with remaining ciphertext + padding
|
||||
var final_block: array<u32, 16>;
|
||||
var remaining = ciphertext_words - word_idx;
|
||||
for (var i = 0u; i < 16u; i++) {
|
||||
if (i < remaining) {
|
||||
final_block[i] = ciphertext[word_idx + i];
|
||||
} else if (i == remaining) {
|
||||
// Add 0x80 padding
|
||||
final_block[i] = 0x80000000u;
|
||||
} else {
|
||||
final_block[i] = 0u;
|
||||
}
|
||||
}
|
||||
|
||||
// Add length (64 bytes of ipad + ciphertext length)
|
||||
let total_bits = 512u + params.ciphertext_len_bits;
|
||||
if (remaining < 14u) {
|
||||
final_block[14] = 0u;
|
||||
final_block[15] = total_bits;
|
||||
h = sha256_block_continue(&final_block, h);
|
||||
} else {
|
||||
// Need extra block for length
|
||||
h = sha256_block_continue(&final_block, h);
|
||||
var len_block: array<u32, 16>;
|
||||
for (var i = 0u; i < 14u; i++) {
|
||||
len_block[i] = 0u;
|
||||
}
|
||||
len_block[14] = 0u;
|
||||
len_block[15] = total_bits;
|
||||
h = sha256_block_continue(&len_block, h);
|
||||
}
|
||||
|
||||
let inner_hash = h;
|
||||
|
||||
// Outer hash: SHA256((K' ^ opad) || inner_hash)
|
||||
var outer_block: array<u32, 16>;
|
||||
for (var i = 0u; i < 16u; i++) {
|
||||
outer_block[i] = k_pad[i] ^ 0x5c5c5c5cu;
|
||||
}
|
||||
h = sha256_block(&outer_block);
|
||||
|
||||
// Second block: inner_hash (32 bytes) + padding
|
||||
var hash_block: array<u32, 16>;
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
hash_block[i] = inner_hash[i];
|
||||
}
|
||||
hash_block[8] = 0x80000000u;
|
||||
for (var i = 9u; i < 14u; i++) {
|
||||
hash_block[i] = 0u;
|
||||
}
|
||||
hash_block[14] = 0u;
|
||||
hash_block[15] = 512u + 256u; // 64 bytes opad + 32 bytes inner hash
|
||||
|
||||
h = sha256_block_continue(&hash_block, h);
|
||||
|
||||
// Return first 2 bytes (high 16 bits of first word)
|
||||
return h[0] & 0xFFFF0000u;
|
||||
}
|
||||
|
||||
// SHA256 block computation continuing from existing state
|
||||
fn sha256_block_continue(msg: ptr<function, array<u32, 16>>, h_in: array<u32, 8>) -> array<u32, 8> {
|
||||
var h = h_in;
|
||||
|
||||
// Message schedule
|
||||
var w: array<u32, 64>;
|
||||
for (var i = 0u; i < 16u; i++) {
|
||||
w[i] = (*msg)[i];
|
||||
}
|
||||
for (var i = 16u; i < 64u; i++) {
|
||||
w[i] = gamma1(w[i-2u]) + w[i-7u] + gamma0(w[i-15u]) + w[i-16u];
|
||||
}
|
||||
|
||||
// Compression
|
||||
var a = h[0]; var b = h[1]; var c = h[2]; var d = h[3];
|
||||
var e = h[4]; var f = h[5]; var g = h[6]; var hh = h[7];
|
||||
|
||||
for (var i = 0u; i < 64u; i++) {
|
||||
let t1 = hh + sigma1(e) + ch(e, f, g) + K[i] + w[i];
|
||||
let t2 = sigma0(a) + maj(a, b, c);
|
||||
hh = g; g = f; f = e; e = d + t1;
|
||||
d = c; c = b; b = a; a = t1 + t2;
|
||||
}
|
||||
|
||||
h[0] = h[0] + a; h[1] = h[1] + b; h[2] = h[2] + c; h[3] = h[3] + d;
|
||||
h[4] = h[4] + e; h[5] = h[5] + f; h[6] = h[6] + g; h[7] = h[7] + hh;
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
// Process a single candidate and record match if found
|
||||
fn process_candidate(name_idx: u32) {
|
||||
// Generate message for this room name
|
||||
var msg: array<u32, 16>;
|
||||
let valid = index_to_room_name(name_idx, params.name_length, &msg);
|
||||
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute SHA256("#roomname") - this gives us the key
|
||||
let key_hash = sha256_block(&msg);
|
||||
|
||||
// Take first 16 bytes (4 words) as the key
|
||||
var key: array<u32, 4>;
|
||||
key[0] = key_hash[0];
|
||||
key[1] = key_hash[1];
|
||||
key[2] = key_hash[2];
|
||||
key[3] = key_hash[3];
|
||||
|
||||
// Compute SHA256(key) to get channel hash
|
||||
let channel_hash = sha256_key(key);
|
||||
|
||||
// Check if channel hash matches target
|
||||
if (channel_hash != params.target_channel_hash) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Channel hash matches - verify MAC if enabled
|
||||
if (params.verify_mac == 1u) {
|
||||
let computed_mac = hmac_sha256_mac(key, params.ciphertext_len_bits);
|
||||
if (computed_mac != params.target_mac) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Found a match - record the index
|
||||
let match_idx = atomicAdd(&match_count, 1u);
|
||||
if (match_idx < 1024u) { // Limit stored matches
|
||||
match_indices[match_idx] = name_idx;
|
||||
}
|
||||
}
|
||||
|
||||
// Each thread processes 32 candidates to amortize thread overhead
|
||||
const CANDIDATES_PER_THREAD: u32 = 32u;
|
||||
|
||||
@compute @workgroup_size(256)
|
||||
fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
|
||||
let base_idx = global_id.x * CANDIDATES_PER_THREAD;
|
||||
|
||||
for (var i = 0u; i < CANDIDATES_PER_THREAD; i++) {
|
||||
let idx = base_idx + i;
|
||||
if (idx >= params.batch_size) {
|
||||
return;
|
||||
}
|
||||
let name_idx = params.batch_offset + idx;
|
||||
process_candidate(name_idx);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
async init() {
|
||||
if (!navigator.gpu) {
|
||||
console.warn('WebGPU not supported');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const adapter = await navigator.gpu.requestAdapter();
|
||||
if (!adapter) {
|
||||
console.warn('No GPU adapter found');
|
||||
return false;
|
||||
}
|
||||
this.device = await adapter.requestDevice();
|
||||
// Create bind group layout
|
||||
this.bindGroupLayout = this.device.createBindGroupLayout({
|
||||
entries: [
|
||||
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
|
||||
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
|
||||
{ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
|
||||
{ binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
|
||||
],
|
||||
});
|
||||
// Create persistent buffers
|
||||
this.paramsBuffer = this.device.createBuffer({
|
||||
size: 32, // 8 u32s
|
||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
this.matchCountBuffer = this.device.createBuffer({
|
||||
size: 4,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
this.matchIndicesBuffer = this.device.createBuffer({
|
||||
size: 1024 * 4, // Max 1024 matches per batch
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
|
||||
});
|
||||
// Double-buffered staging buffers
|
||||
for (let i = 0; i < 2; i++) {
|
||||
this.matchCountReadBuffers[i] = this.device.createBuffer({
|
||||
size: 4,
|
||||
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
this.matchIndicesReadBuffers[i] = this.device.createBuffer({
|
||||
size: 1024 * 4,
|
||||
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
}
|
||||
// Create pipeline
|
||||
const shaderModule = this.device.createShaderModule({
|
||||
code: this.shaderCode,
|
||||
});
|
||||
const pipelineLayout = this.device.createPipelineLayout({
|
||||
bindGroupLayouts: [this.bindGroupLayout],
|
||||
});
|
||||
this.pipeline = this.device.createComputePipeline({
|
||||
layout: pipelineLayout,
|
||||
compute: {
|
||||
module: shaderModule,
|
||||
entryPoint: 'main',
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
catch (e) {
|
||||
console.error('WebGPU initialization failed:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
isAvailable() {
|
||||
return this.device !== null && this.pipeline !== null;
|
||||
}
|
||||
// Convert room name index to actual room name string (delegates to core)
|
||||
indexToRoomName(idx, length) {
|
||||
return indexToRoomName(length, idx);
|
||||
}
|
||||
// Count valid names for a given length (delegates to core)
|
||||
countNamesForLength(len) {
|
||||
return countNamesForLength(len);
|
||||
}
|
||||
async runBatch(targetChannelHash, nameLength, batchOffset, batchSize, ciphertextHex, targetMacHex) {
|
||||
if (!this.device ||
|
||||
!this.pipeline ||
|
||||
!this.bindGroupLayout ||
|
||||
!this.paramsBuffer ||
|
||||
!this.matchCountBuffer ||
|
||||
!this.matchIndicesBuffer ||
|
||||
!this.matchCountReadBuffers[0] ||
|
||||
!this.matchCountReadBuffers[1] ||
|
||||
!this.matchIndicesReadBuffers[0] ||
|
||||
!this.matchIndicesReadBuffers[1]) {
|
||||
throw new Error('GPU not initialized');
|
||||
}
|
||||
// Swap to alternate staging buffer set (double-buffering)
|
||||
const readBufferIdx = this.currentReadBufferIndex;
|
||||
this.currentReadBufferIndex = 1 - this.currentReadBufferIndex;
|
||||
const matchCountReadBuffer = this.matchCountReadBuffers[readBufferIdx];
|
||||
const matchIndicesReadBuffer = this.matchIndicesReadBuffers[readBufferIdx];
|
||||
// Parse ciphertext if provided
|
||||
const verifyMac = ciphertextHex && targetMacHex ? 1 : 0;
|
||||
let ciphertextWords;
|
||||
let ciphertextLenBits = 0;
|
||||
let targetMac = 0;
|
||||
if (verifyMac) {
|
||||
// Convert hex to bytes then to big-endian u32 words
|
||||
const ciphertextBytes = new Uint8Array(ciphertextHex.length / 2);
|
||||
for (let i = 0; i < ciphertextBytes.length; i++) {
|
||||
ciphertextBytes[i] = parseInt(ciphertextHex.substr(i * 2, 2), 16);
|
||||
}
|
||||
ciphertextLenBits = ciphertextBytes.length * 8;
|
||||
// Pad to 4-byte boundary and convert to big-endian u32
|
||||
const paddedLen = Math.ceil(ciphertextBytes.length / 4) * 4;
|
||||
const padded = new Uint8Array(paddedLen);
|
||||
padded.set(ciphertextBytes);
|
||||
ciphertextWords = new Uint32Array(paddedLen / 4);
|
||||
for (let i = 0; i < ciphertextWords.length; i++) {
|
||||
ciphertextWords[i] =
|
||||
(padded[i * 4] << 24) |
|
||||
(padded[i * 4 + 1] << 16) |
|
||||
(padded[i * 4 + 2] << 8) |
|
||||
padded[i * 4 + 3];
|
||||
}
|
||||
// Parse target MAC (2 bytes in high 16 bits)
|
||||
const macByte0 = parseInt(targetMacHex.substr(0, 2), 16);
|
||||
const macByte1 = parseInt(targetMacHex.substr(2, 2), 16);
|
||||
targetMac = (macByte0 << 24) | (macByte1 << 16);
|
||||
}
|
||||
else {
|
||||
ciphertextWords = new Uint32Array([0]); // Dummy
|
||||
}
|
||||
// Resize ciphertext buffer if needed (marks bind group as dirty)
|
||||
const requiredCiphertextSize = Math.max(ciphertextWords.length * 4, 4);
|
||||
if (!this.ciphertextBuffer || this.ciphertextBufferSize < requiredCiphertextSize) {
|
||||
if (this.ciphertextBuffer) {
|
||||
this.ciphertextBuffer.destroy();
|
||||
}
|
||||
this.ciphertextBuffer = this.device.createBuffer({
|
||||
size: requiredCiphertextSize,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
this.ciphertextBufferSize = requiredCiphertextSize;
|
||||
this.bindGroupDirty = true;
|
||||
}
|
||||
// Write params
|
||||
const paramsData = new Uint32Array([
|
||||
targetChannelHash,
|
||||
batchOffset,
|
||||
nameLength,
|
||||
batchSize,
|
||||
targetMac,
|
||||
ciphertextWords.length,
|
||||
ciphertextLenBits,
|
||||
verifyMac,
|
||||
]);
|
||||
this.device.queue.writeBuffer(this.paramsBuffer, 0, paramsData);
|
||||
// Write ciphertext
|
||||
this.device.queue.writeBuffer(this.ciphertextBuffer, 0, ciphertextWords);
|
||||
// Reset match count (reuse static zero buffer)
|
||||
this.device.queue.writeBuffer(this.matchCountBuffer, 0, GpuBruteForce.ZERO_DATA);
|
||||
// Recreate bind group only if needed
|
||||
if (this.bindGroupDirty || !this.bindGroup) {
|
||||
this.bindGroup = this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.paramsBuffer } },
|
||||
{ binding: 1, resource: { buffer: this.matchCountBuffer } },
|
||||
{ binding: 2, resource: { buffer: this.matchIndicesBuffer } },
|
||||
{ binding: 3, resource: { buffer: this.ciphertextBuffer } },
|
||||
],
|
||||
});
|
||||
this.bindGroupDirty = false;
|
||||
}
|
||||
// Create command encoder
|
||||
const commandEncoder = this.device.createCommandEncoder();
|
||||
const passEncoder = commandEncoder.beginComputePass();
|
||||
passEncoder.setPipeline(this.pipeline);
|
||||
passEncoder.setBindGroup(0, this.bindGroup);
|
||||
// Each workgroup has 256 threads, each processing 32 candidates
|
||||
const CANDIDATES_PER_THREAD = 32;
|
||||
passEncoder.dispatchWorkgroups(Math.ceil(batchSize / (256 * CANDIDATES_PER_THREAD)));
|
||||
passEncoder.end();
|
||||
// Copy results to current staging buffers
|
||||
commandEncoder.copyBufferToBuffer(this.matchCountBuffer, 0, matchCountReadBuffer, 0, 4);
|
||||
commandEncoder.copyBufferToBuffer(this.matchIndicesBuffer, 0, matchIndicesReadBuffer, 0, 1024 * 4);
|
||||
// Submit
|
||||
this.device.queue.submit([commandEncoder.finish()]);
|
||||
// Read results from current staging buffers
|
||||
await matchCountReadBuffer.mapAsync(GPUMapMode.READ);
|
||||
const matchCount = new Uint32Array(matchCountReadBuffer.getMappedRange())[0];
|
||||
matchCountReadBuffer.unmap();
|
||||
const matches = [];
|
||||
if (matchCount > 0) {
|
||||
await matchIndicesReadBuffer.mapAsync(GPUMapMode.READ);
|
||||
const indices = new Uint32Array(matchIndicesReadBuffer.getMappedRange());
|
||||
for (let i = 0; i < Math.min(matchCount, 1024); i++) {
|
||||
matches.push(indices[i]);
|
||||
}
|
||||
matchIndicesReadBuffer.unmap();
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
destroy() {
|
||||
// Clean up persistent buffers
|
||||
this.paramsBuffer?.destroy();
|
||||
this.matchCountBuffer?.destroy();
|
||||
this.matchIndicesBuffer?.destroy();
|
||||
this.ciphertextBuffer?.destroy();
|
||||
// Clean up double-buffered staging buffers
|
||||
this.matchCountReadBuffers[0]?.destroy();
|
||||
this.matchCountReadBuffers[1]?.destroy();
|
||||
this.matchIndicesReadBuffers[0]?.destroy();
|
||||
this.matchIndicesReadBuffers[1]?.destroy();
|
||||
this.paramsBuffer = null;
|
||||
this.matchCountBuffer = null;
|
||||
this.matchIndicesBuffer = null;
|
||||
this.ciphertextBuffer = null;
|
||||
this.ciphertextBufferSize = 0;
|
||||
this.matchCountReadBuffers = [null, null];
|
||||
this.matchIndicesReadBuffers = [null, null];
|
||||
this.currentReadBufferIndex = 0;
|
||||
this.bindGroup = null;
|
||||
this.bindGroupDirty = true;
|
||||
if (this.device) {
|
||||
this.device.destroy();
|
||||
this.device = null;
|
||||
}
|
||||
this.pipeline = null;
|
||||
this.bindGroupLayout = null;
|
||||
}
|
||||
}
|
||||
// Reusable zero buffer for resetting match count
|
||||
GpuBruteForce.ZERO_DATA = new Uint32Array([0]);
|
||||
/**
|
||||
* Check if WebGPU is supported in the current browser.
|
||||
*/
|
||||
export function isWebGpuSupported() {
|
||||
return typeof navigator !== 'undefined' && 'gpu' in navigator;
|
||||
}
|
||||
//# sourceMappingURL=gpu-bruteforce.js.map
|
||||
1
package/dist/gpu-bruteforce.js.map
vendored
Executable file
1
package/dist/gpu-bruteforce.js.map
vendored
Executable file
File diff suppressed because one or more lines are too long
38
package/dist/gpu-wordpairs.d.ts
vendored
Executable file
38
package/dist/gpu-wordpairs.d.ts
vendored
Executable file
@@ -0,0 +1,38 @@
|
||||
export declare class GpuWordPairs {
|
||||
private device;
|
||||
private pipeline;
|
||||
private bindGroupLayout;
|
||||
private paramsBuffer;
|
||||
private matchCountBuffer;
|
||||
private matchIBuffer;
|
||||
private matchJBuffer;
|
||||
private ciphertextBuffer;
|
||||
private wordDataBuffer;
|
||||
private wordOffsetsBuffer;
|
||||
private matchCountReadBuffer;
|
||||
private matchIReadBuffer;
|
||||
private matchJReadBuffer;
|
||||
private wordCount;
|
||||
private ciphertextBufferSize;
|
||||
private static readonly ZERO_DATA;
|
||||
private shaderCode;
|
||||
init(): Promise<boolean>;
|
||||
/**
|
||||
* Upload the word list to GPU buffers.
|
||||
* Words are packed into a byte buffer, with offsets stored separately.
|
||||
*/
|
||||
uploadWords(words: string[]): void;
|
||||
/**
|
||||
* Run a batch of word pair checks on the GPU.
|
||||
* @param targetChannelHash - Target channel hash byte
|
||||
* @param batchOffset - Starting pair index (i * wordCount + j)
|
||||
* @param batchSize - Number of pairs to check
|
||||
* @param ciphertextHex - Ciphertext for MAC verification
|
||||
* @param targetMacHex - Target MAC
|
||||
* @returns Array of matching pair indices as [i, j] tuples
|
||||
*/
|
||||
runBatch(targetChannelHash: number, batchOffset: number, batchSize: number, ciphertextHex: string, targetMacHex: string): Promise<Array<[number, number]>>;
|
||||
getWordCount(): number;
|
||||
destroy(): void;
|
||||
}
|
||||
//# sourceMappingURL=gpu-wordpairs.d.ts.map
|
||||
1
package/dist/gpu-wordpairs.d.ts.map
vendored
Executable file
1
package/dist/gpu-wordpairs.d.ts.map
vendored
Executable file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"gpu-wordpairs.d.ts","sourceRoot":"","sources":["../src/gpu-wordpairs.ts"],"names":[],"mappings":"AAEA,qBAAa,YAAY;IACvB,OAAO,CAAC,MAAM,CAA0B;IACxC,OAAO,CAAC,QAAQ,CAAmC;IACnD,OAAO,CAAC,eAAe,CAAmC;IAG1D,OAAO,CAAC,YAAY,CAA0B;IAC9C,OAAO,CAAC,gBAAgB,CAA0B;IAClD,OAAO,CAAC,YAAY,CAA0B;IAC9C,OAAO,CAAC,YAAY,CAA0B;IAC9C,OAAO,CAAC,gBAAgB,CAA0B;IAClD,OAAO,CAAC,cAAc,CAA0B;IAChD,OAAO,CAAC,iBAAiB,CAA0B;IAGnD,OAAO,CAAC,oBAAoB,CAA0B;IACtD,OAAO,CAAC,gBAAgB,CAA0B;IAClD,OAAO,CAAC,gBAAgB,CAA0B;IAGlD,OAAO,CAAC,SAAS,CAAa;IAC9B,OAAO,CAAC,oBAAoB,CAAa;IAEzC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAwB;IAGzD,OAAO,CAAC,UAAU,CAuTlB;IAEM,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC;IAmF9B;;;OAGG;IACH,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI;IA2DlC;;;;;;;;OAQG;IACG,QAAQ,CACZ,iBAAiB,EAAE,MAAM,EACzB,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,aAAa,EAAE,MAAM,EACrB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IA8HnC,YAAY,IAAI,MAAM;IAItB,OAAO,IAAI,IAAI;CAmBhB"}
|
||||
600
package/dist/gpu-wordpairs.js
vendored
Executable file
600
package/dist/gpu-wordpairs.js
vendored
Executable file
@@ -0,0 +1,600 @@
|
||||
// WebGPU-accelerated word pair cracking for MeshCore packets
|
||||
export class GpuWordPairs {
|
||||
constructor() {
|
||||
this.device = null;
|
||||
this.pipeline = null;
|
||||
this.bindGroupLayout = null;
|
||||
// Buffers
|
||||
this.paramsBuffer = null;
|
||||
this.matchCountBuffer = null;
|
||||
this.matchIBuffer = null; // i indices for matches
|
||||
this.matchJBuffer = null; // j indices for matches
|
||||
this.ciphertextBuffer = null;
|
||||
this.wordDataBuffer = null; // Packed word bytes
|
||||
this.wordOffsetsBuffer = null; // (offset, length) for each word
|
||||
// Staging buffers
|
||||
this.matchCountReadBuffer = null;
|
||||
this.matchIReadBuffer = null;
|
||||
this.matchJReadBuffer = null;
|
||||
// Cached state
|
||||
this.wordCount = 0;
|
||||
this.ciphertextBufferSize = 0;
|
||||
// Shader for word pair checking - optimized based on OpenCL reference implementation
|
||||
this.shaderCode = `
|
||||
// SHA256 round constants
|
||||
const K: array<u32, 64> = array<u32, 64>(
|
||||
0x428a2f98u, 0x71374491u, 0xb5c0fbcfu, 0xe9b5dba5u, 0x3956c25bu, 0x59f111f1u, 0x923f82a4u, 0xab1c5ed5u,
|
||||
0xd807aa98u, 0x12835b01u, 0x243185beu, 0x550c7dc3u, 0x72be5d74u, 0x80deb1feu, 0x9bdc06a7u, 0xc19bf174u,
|
||||
0xe49b69c1u, 0xefbe4786u, 0x0fc19dc6u, 0x240ca1ccu, 0x2de92c6fu, 0x4a7484aau, 0x5cb0a9dcu, 0x76f988dau,
|
||||
0x983e5152u, 0xa831c66du, 0xb00327c8u, 0xbf597fc7u, 0xc6e00bf3u, 0xd5a79147u, 0x06ca6351u, 0x14292967u,
|
||||
0x27b70a85u, 0x2e1b2138u, 0x4d2c6dfcu, 0x53380d13u, 0x650a7354u, 0x766a0abbu, 0x81c2c92eu, 0x92722c85u,
|
||||
0xa2bfe8a1u, 0xa81a664bu, 0xc24b8b70u, 0xc76c51a3u, 0xd192e819u, 0xd6990624u, 0xf40e3585u, 0x106aa070u,
|
||||
0x19a4c116u, 0x1e376c08u, 0x2748774cu, 0x34b0bcb5u, 0x391c0cb3u, 0x4ed8aa4au, 0x5b9cca4fu, 0x682e6ff3u,
|
||||
0x748f82eeu, 0x78a5636fu, 0x84c87814u, 0x8cc70208u, 0x90befffau, 0xa4506cebu, 0xbef9a3f7u, 0xc67178f2u
|
||||
);
|
||||
|
||||
// Pre-computed HMAC ipad/opad XOR states for common key padding (0x36/0x5c repeated)
|
||||
const IPAD_XOR: u32 = 0x36363636u;
|
||||
const OPAD_XOR: u32 = 0x5c5c5c5cu;
|
||||
|
||||
struct Params {
|
||||
target_channel_hash: u32,
|
||||
word_count: u32,
|
||||
i_start: u32, // Starting i index (row) - computed on CPU from 64-bit offset
|
||||
j_start: u32, // Starting j index (col) - computed on CPU from 64-bit offset
|
||||
batch_size: u32,
|
||||
target_mac: u32,
|
||||
ciphertext_words: u32,
|
||||
ciphertext_len_bits: u32,
|
||||
max_combined_len: u32,
|
||||
_padding: u32,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<storage, read_write> match_count: atomic<u32>;
|
||||
@group(0) @binding(2) var<storage, read_write> match_i: array<u32>; // Separate array for i indices
|
||||
@group(0) @binding(3) var<storage, read_write> match_j: array<u32>; // Separate array for j indices
|
||||
@group(0) @binding(4) var<storage, read> ciphertext: array<u32>;
|
||||
@group(0) @binding(5) var<storage, read> word_data: array<u32>;
|
||||
@group(0) @binding(6) var<storage, read> word_offsets: array<u32>;
|
||||
|
||||
// Inline bit rotation for better performance
|
||||
fn rotr(x: u32, n: u32) -> u32 {
|
||||
return (x >> n) | (x << (32u - n));
|
||||
}
|
||||
|
||||
// SHA256 compression function - processes one 64-byte block
|
||||
// Takes mutable state h and message block msg
|
||||
fn sha256_compress(h: ptr<function, array<u32, 8>>, msg: ptr<function, array<u32, 16>>) {
|
||||
var w: array<u32, 64>;
|
||||
|
||||
// Load message into first 16 words
|
||||
w[0] = (*msg)[0]; w[1] = (*msg)[1]; w[2] = (*msg)[2]; w[3] = (*msg)[3];
|
||||
w[4] = (*msg)[4]; w[5] = (*msg)[5]; w[6] = (*msg)[6]; w[7] = (*msg)[7];
|
||||
w[8] = (*msg)[8]; w[9] = (*msg)[9]; w[10] = (*msg)[10]; w[11] = (*msg)[11];
|
||||
w[12] = (*msg)[12]; w[13] = (*msg)[13]; w[14] = (*msg)[14]; w[15] = (*msg)[15];
|
||||
|
||||
// Extend message schedule
|
||||
for (var i = 16u; i < 64u; i++) {
|
||||
let s0 = rotr(w[i-15u], 7u) ^ rotr(w[i-15u], 18u) ^ (w[i-15u] >> 3u);
|
||||
let s1 = rotr(w[i-2u], 17u) ^ rotr(w[i-2u], 19u) ^ (w[i-2u] >> 10u);
|
||||
w[i] = w[i-16u] + s0 + w[i-7u] + s1;
|
||||
}
|
||||
|
||||
var a = (*h)[0]; var b = (*h)[1]; var c = (*h)[2]; var d = (*h)[3];
|
||||
var e = (*h)[4]; var f = (*h)[5]; var g = (*h)[6]; var hv = (*h)[7];
|
||||
|
||||
// Main compression loop
|
||||
for (var i = 0u; i < 64u; i++) {
|
||||
let S1 = rotr(e, 6u) ^ rotr(e, 11u) ^ rotr(e, 25u);
|
||||
let ch = (e & f) ^ (~e & g);
|
||||
let t1 = hv + S1 + ch + K[i] + w[i];
|
||||
let S0 = rotr(a, 2u) ^ rotr(a, 13u) ^ rotr(a, 22u);
|
||||
let maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
let t2 = S0 + maj;
|
||||
hv = g; g = f; f = e; e = d + t1;
|
||||
d = c; c = b; b = a; a = t1 + t2;
|
||||
}
|
||||
|
||||
(*h)[0] += a; (*h)[1] += b; (*h)[2] += c; (*h)[3] += d;
|
||||
(*h)[4] += e; (*h)[5] += f; (*h)[6] += g; (*h)[7] += hv;
|
||||
}
|
||||
|
||||
// Initialize SHA256 state
|
||||
fn sha256_init() -> array<u32, 8> {
|
||||
return array<u32, 8>(
|
||||
0x6a09e667u, 0xbb67ae85u, 0x3c6ef372u, 0xa54ff53au,
|
||||
0x510e527fu, 0x9b05688cu, 0x1f83d9abu, 0x5be0cd19u
|
||||
);
|
||||
}
|
||||
|
||||
// Compute channel hash from 16-byte key
|
||||
fn compute_channel_hash(key: array<u32, 4>) -> u32 {
|
||||
var h = sha256_init();
|
||||
var msg: array<u32, 16>;
|
||||
msg[0] = key[0]; msg[1] = key[1]; msg[2] = key[2]; msg[3] = key[3];
|
||||
msg[4] = 0x80000000u;
|
||||
msg[5] = 0u; msg[6] = 0u; msg[7] = 0u; msg[8] = 0u; msg[9] = 0u;
|
||||
msg[10] = 0u; msg[11] = 0u; msg[12] = 0u; msg[13] = 0u; msg[14] = 0u;
|
||||
msg[15] = 128u;
|
||||
sha256_compress(&h, &msg);
|
||||
return h[0] >> 24u;
|
||||
}
|
||||
|
||||
// HMAC-SHA256 with precomputed ipad/opad states (optimization from OpenCL version)
|
||||
// Returns first 2 bytes of HMAC as u32 (in high 16 bits)
|
||||
fn hmac_sha256_optimized(key: array<u32, 4>) -> u32 {
|
||||
// Precompute ipad state: SHA256 state after processing (key XOR ipad)
|
||||
var h_ipad = sha256_init();
|
||||
var ipad_block: array<u32, 16>;
|
||||
ipad_block[0] = key[0] ^ IPAD_XOR;
|
||||
ipad_block[1] = key[1] ^ IPAD_XOR;
|
||||
ipad_block[2] = key[2] ^ IPAD_XOR;
|
||||
ipad_block[3] = key[3] ^ IPAD_XOR;
|
||||
ipad_block[4] = IPAD_XOR; ipad_block[5] = IPAD_XOR; ipad_block[6] = IPAD_XOR; ipad_block[7] = IPAD_XOR;
|
||||
ipad_block[8] = IPAD_XOR; ipad_block[9] = IPAD_XOR; ipad_block[10] = IPAD_XOR; ipad_block[11] = IPAD_XOR;
|
||||
ipad_block[12] = IPAD_XOR; ipad_block[13] = IPAD_XOR; ipad_block[14] = IPAD_XOR; ipad_block[15] = IPAD_XOR;
|
||||
sha256_compress(&h_ipad, &ipad_block);
|
||||
|
||||
// Process ciphertext with ipad state
|
||||
var h = h_ipad;
|
||||
let ct_words = params.ciphertext_words;
|
||||
var word_idx = 0u;
|
||||
|
||||
// Process full blocks
|
||||
while (word_idx + 16u <= ct_words) {
|
||||
var block: array<u32, 16>;
|
||||
block[0] = ciphertext[word_idx]; block[1] = ciphertext[word_idx+1u];
|
||||
block[2] = ciphertext[word_idx+2u]; block[3] = ciphertext[word_idx+3u];
|
||||
block[4] = ciphertext[word_idx+4u]; block[5] = ciphertext[word_idx+5u];
|
||||
block[6] = ciphertext[word_idx+6u]; block[7] = ciphertext[word_idx+7u];
|
||||
block[8] = ciphertext[word_idx+8u]; block[9] = ciphertext[word_idx+9u];
|
||||
block[10] = ciphertext[word_idx+10u]; block[11] = ciphertext[word_idx+11u];
|
||||
block[12] = ciphertext[word_idx+12u]; block[13] = ciphertext[word_idx+13u];
|
||||
block[14] = ciphertext[word_idx+14u]; block[15] = ciphertext[word_idx+15u];
|
||||
sha256_compress(&h, &block);
|
||||
word_idx += 16u;
|
||||
}
|
||||
|
||||
// Final block with remaining ciphertext + padding
|
||||
var final_block: array<u32, 16>;
|
||||
let remaining = ct_words - word_idx;
|
||||
for (var i = 0u; i < 16u; i++) {
|
||||
if (i < remaining) {
|
||||
final_block[i] = ciphertext[word_idx + i];
|
||||
} else if (i == remaining) {
|
||||
final_block[i] = 0x80000000u;
|
||||
} else {
|
||||
final_block[i] = 0u;
|
||||
}
|
||||
}
|
||||
|
||||
let total_bits = 512u + params.ciphertext_len_bits;
|
||||
if (remaining < 14u) {
|
||||
final_block[14] = 0u;
|
||||
final_block[15] = total_bits;
|
||||
sha256_compress(&h, &final_block);
|
||||
} else {
|
||||
sha256_compress(&h, &final_block);
|
||||
var len_block: array<u32, 16>;
|
||||
for (var i = 0u; i < 14u; i++) { len_block[i] = 0u; }
|
||||
len_block[14] = 0u;
|
||||
len_block[15] = total_bits;
|
||||
sha256_compress(&h, &len_block);
|
||||
}
|
||||
|
||||
// Inner hash complete, now outer hash
|
||||
// Precompute opad state
|
||||
var h_opad = sha256_init();
|
||||
var opad_block: array<u32, 16>;
|
||||
opad_block[0] = key[0] ^ OPAD_XOR;
|
||||
opad_block[1] = key[1] ^ OPAD_XOR;
|
||||
opad_block[2] = key[2] ^ OPAD_XOR;
|
||||
opad_block[3] = key[3] ^ OPAD_XOR;
|
||||
opad_block[4] = OPAD_XOR; opad_block[5] = OPAD_XOR; opad_block[6] = OPAD_XOR; opad_block[7] = OPAD_XOR;
|
||||
opad_block[8] = OPAD_XOR; opad_block[9] = OPAD_XOR; opad_block[10] = OPAD_XOR; opad_block[11] = OPAD_XOR;
|
||||
opad_block[12] = OPAD_XOR; opad_block[13] = OPAD_XOR; opad_block[14] = OPAD_XOR; opad_block[15] = OPAD_XOR;
|
||||
sha256_compress(&h_opad, &opad_block);
|
||||
|
||||
// Final HMAC block: inner_hash + padding
|
||||
var hash_block: array<u32, 16>;
|
||||
hash_block[0] = h[0]; hash_block[1] = h[1]; hash_block[2] = h[2]; hash_block[3] = h[3];
|
||||
hash_block[4] = h[4]; hash_block[5] = h[5]; hash_block[6] = h[6]; hash_block[7] = h[7];
|
||||
hash_block[8] = 0x80000000u;
|
||||
hash_block[9] = 0u; hash_block[10] = 0u; hash_block[11] = 0u;
|
||||
hash_block[12] = 0u; hash_block[13] = 0u; hash_block[14] = 0u;
|
||||
hash_block[15] = 512u + 256u;
|
||||
sha256_compress(&h_opad, &hash_block);
|
||||
|
||||
return h_opad[0] & 0xFFFF0000u;
|
||||
}
|
||||
|
||||
// Read a byte from packed word data (big-endian within u32)
|
||||
fn read_byte(byte_offset: u32) -> u32 {
|
||||
let word_idx = byte_offset >> 2u;
|
||||
let byte_in_word = byte_offset & 3u;
|
||||
let word = word_data[word_idx];
|
||||
return (word >> ((3u - byte_in_word) << 3u)) & 0xFFu;
|
||||
}
|
||||
|
||||
// Process a single word pair
|
||||
fn process_word_pair(word1_idx: u32, word2_idx: u32) -> bool {
|
||||
let offset_len1 = word_offsets[word1_idx];
|
||||
let offset1 = offset_len1 >> 8u; // 24 bits for offset (up to 16M bytes)
|
||||
let len1 = offset_len1 & 0xFFu; // 8 bits for length (up to 255 chars)
|
||||
|
||||
let offset_len2 = word_offsets[word2_idx];
|
||||
let offset2 = offset_len2 >> 8u;
|
||||
let len2 = offset_len2 & 0xFFu;
|
||||
|
||||
let combined_len = len1 + len2;
|
||||
if (combined_len > params.max_combined_len) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for consecutive dashes at join point
|
||||
if (len1 > 0u && len2 > 0u) {
|
||||
let last1 = read_byte(offset1 + len1 - 1u);
|
||||
let first2 = read_byte(offset2);
|
||||
if (last1 == 0x2du && first2 == 0x2du) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Build message: "#" + word1 + word2
|
||||
var msg: array<u32, 16>;
|
||||
for (var i = 0u; i < 16u; i++) { msg[i] = 0u; }
|
||||
|
||||
let total_len = 1u + combined_len;
|
||||
var byte_pos = 0u;
|
||||
var current_word = 0x23000000u; // '#' at position 0
|
||||
byte_pos = 1u;
|
||||
|
||||
// Copy word1
|
||||
for (var i = 0u; i < len1; i++) {
|
||||
let c = read_byte(offset1 + i);
|
||||
let shift = (3u - (byte_pos & 3u)) << 3u;
|
||||
current_word |= c << shift;
|
||||
byte_pos++;
|
||||
if ((byte_pos & 3u) == 0u) {
|
||||
msg[(byte_pos >> 2u) - 1u] = current_word;
|
||||
current_word = 0u;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy word2
|
||||
for (var i = 0u; i < len2; i++) {
|
||||
let c = read_byte(offset2 + i);
|
||||
let shift = (3u - (byte_pos & 3u)) << 3u;
|
||||
current_word |= c << shift;
|
||||
byte_pos++;
|
||||
if ((byte_pos & 3u) == 0u) {
|
||||
msg[(byte_pos >> 2u) - 1u] = current_word;
|
||||
current_word = 0u;
|
||||
}
|
||||
}
|
||||
|
||||
// Add 0x80 padding
|
||||
let shift = (3u - (byte_pos & 3u)) << 3u;
|
||||
current_word |= 0x80u << shift;
|
||||
msg[(byte_pos) >> 2u] = current_word;
|
||||
msg[15] = total_len << 3u;
|
||||
|
||||
// Compute key = SHA256("#" + word1 + word2)
|
||||
var h = sha256_init();
|
||||
sha256_compress(&h, &msg);
|
||||
|
||||
let key = array<u32, 4>(h[0], h[1], h[2], h[3]);
|
||||
|
||||
// Check channel hash first (fast rejection)
|
||||
if (compute_channel_hash(key) != params.target_channel_hash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify MAC (expensive, only if channel hash matches)
|
||||
return hmac_sha256_optimized(key) == params.target_mac;
|
||||
}
|
||||
|
||||
// Process multiple pairs per thread for better throughput
|
||||
const PAIRS_PER_THREAD: u32 = 32u;
|
||||
|
||||
@compute @workgroup_size(256)
|
||||
fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
|
||||
let base_idx = global_id.x * PAIRS_PER_THREAD;
|
||||
let word_count = params.word_count;
|
||||
let batch_size = params.batch_size;
|
||||
|
||||
// i_start and j_start are computed on CPU from 64-bit batch_offset
|
||||
// This avoids needing 64-bit math in WGSL
|
||||
let i_start = params.i_start;
|
||||
let j_start = params.j_start;
|
||||
|
||||
for (var p = 0u; p < PAIRS_PER_THREAD; p++) {
|
||||
let offset = base_idx + p;
|
||||
if (offset >= batch_size) { return; }
|
||||
|
||||
// Compute actual (i, j) from starting position + offset
|
||||
// offset = local_i * word_count + local_j where local_j < word_count
|
||||
let total_j = j_start + offset;
|
||||
let extra_i = total_j / word_count;
|
||||
let i = i_start + extra_i;
|
||||
let j = total_j % word_count;
|
||||
|
||||
if (i >= word_count) { return; }
|
||||
|
||||
if (process_word_pair(i, j)) {
|
||||
let idx = atomicAdd(&match_count, 1u);
|
||||
if (idx < 1024u) {
|
||||
match_i[idx] = i;
|
||||
match_j[idx] = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
async init() {
|
||||
if (!navigator.gpu) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const adapter = await navigator.gpu.requestAdapter();
|
||||
if (!adapter) {
|
||||
return false;
|
||||
}
|
||||
this.device = await adapter.requestDevice();
|
||||
this.bindGroupLayout = this.device.createBindGroupLayout({
|
||||
entries: [
|
||||
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
|
||||
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
|
||||
{ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
|
||||
{ binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
|
||||
{ binding: 4, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
|
||||
{ binding: 5, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
|
||||
{ binding: 6, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
|
||||
],
|
||||
});
|
||||
this.paramsBuffer = this.device.createBuffer({
|
||||
size: 40, // 10 x u32 for params struct with 64-bit offset
|
||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
this.matchCountBuffer = this.device.createBuffer({
|
||||
size: 4,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
this.matchIBuffer = this.device.createBuffer({
|
||||
size: 1024 * 4,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
|
||||
});
|
||||
this.matchJBuffer = this.device.createBuffer({
|
||||
size: 1024 * 4,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
|
||||
});
|
||||
this.matchCountReadBuffer = this.device.createBuffer({
|
||||
size: 4,
|
||||
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
this.matchIReadBuffer = this.device.createBuffer({
|
||||
size: 1024 * 4,
|
||||
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
this.matchJReadBuffer = this.device.createBuffer({
|
||||
size: 1024 * 4,
|
||||
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
const shaderModule = this.device.createShaderModule({
|
||||
code: this.shaderCode,
|
||||
});
|
||||
const pipelineLayout = this.device.createPipelineLayout({
|
||||
bindGroupLayouts: [this.bindGroupLayout],
|
||||
});
|
||||
this.pipeline = this.device.createComputePipeline({
|
||||
layout: pipelineLayout,
|
||||
compute: {
|
||||
module: shaderModule,
|
||||
entryPoint: 'main',
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
catch (e) {
|
||||
console.error('WebGPU word pairs initialization failed:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Upload the word list to GPU buffers.
|
||||
* Words are packed into a byte buffer, with offsets stored separately.
|
||||
*/
|
||||
uploadWords(words) {
|
||||
if (!this.device) {
|
||||
throw new Error('GPU not initialized');
|
||||
}
|
||||
this.wordCount = words.length;
|
||||
// Calculate total byte size needed
|
||||
let totalBytes = 0;
|
||||
for (const word of words) {
|
||||
totalBytes += word.length;
|
||||
}
|
||||
// Pack words into byte array (big-endian word order for GPU)
|
||||
const wordData = new Uint8Array(Math.ceil(totalBytes / 4) * 4);
|
||||
const wordOffsets = new Uint32Array(words.length);
|
||||
let byteOffset = 0;
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const word = words[i];
|
||||
// Pack: 24 bits for offset (up to 16M), 8 bits for length (up to 255)
|
||||
wordOffsets[i] = (byteOffset << 8) | word.length;
|
||||
for (let j = 0; j < word.length; j++) {
|
||||
wordData[byteOffset++] = word.charCodeAt(j);
|
||||
}
|
||||
}
|
||||
// Convert to big-endian u32 for GPU
|
||||
const wordDataU32 = new Uint32Array(Math.ceil(totalBytes / 4));
|
||||
for (let i = 0; i < wordDataU32.length; i++) {
|
||||
wordDataU32[i] =
|
||||
(wordData[i * 4] << 24) |
|
||||
(wordData[i * 4 + 1] << 16) |
|
||||
(wordData[i * 4 + 2] << 8) |
|
||||
wordData[i * 4 + 3];
|
||||
}
|
||||
// Create/recreate buffers
|
||||
if (this.wordDataBuffer) {
|
||||
this.wordDataBuffer.destroy();
|
||||
}
|
||||
if (this.wordOffsetsBuffer) {
|
||||
this.wordOffsetsBuffer.destroy();
|
||||
}
|
||||
this.wordDataBuffer = this.device.createBuffer({
|
||||
size: Math.max(wordDataU32.byteLength, 4),
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
this.device.queue.writeBuffer(this.wordDataBuffer, 0, wordDataU32);
|
||||
this.wordOffsetsBuffer = this.device.createBuffer({
|
||||
size: wordOffsets.byteLength,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
this.device.queue.writeBuffer(this.wordOffsetsBuffer, 0, wordOffsets);
|
||||
}
|
||||
/**
|
||||
* Run a batch of word pair checks on the GPU.
|
||||
* @param targetChannelHash - Target channel hash byte
|
||||
* @param batchOffset - Starting pair index (i * wordCount + j)
|
||||
* @param batchSize - Number of pairs to check
|
||||
* @param ciphertextHex - Ciphertext for MAC verification
|
||||
* @param targetMacHex - Target MAC
|
||||
* @returns Array of matching pair indices as [i, j] tuples
|
||||
*/
|
||||
async runBatch(targetChannelHash, batchOffset, batchSize, ciphertextHex, targetMacHex) {
|
||||
if (!this.device ||
|
||||
!this.pipeline ||
|
||||
!this.bindGroupLayout ||
|
||||
!this.paramsBuffer ||
|
||||
!this.matchCountBuffer ||
|
||||
!this.matchIBuffer ||
|
||||
!this.matchJBuffer ||
|
||||
!this.matchCountReadBuffer ||
|
||||
!this.matchIReadBuffer ||
|
||||
!this.matchJReadBuffer ||
|
||||
!this.wordDataBuffer ||
|
||||
!this.wordOffsetsBuffer) {
|
||||
throw new Error('GPU not initialized or words not uploaded');
|
||||
}
|
||||
// Parse ciphertext
|
||||
const ciphertextBytes = new Uint8Array(ciphertextHex.length / 2);
|
||||
for (let i = 0; i < ciphertextBytes.length; i++) {
|
||||
ciphertextBytes[i] = parseInt(ciphertextHex.substr(i * 2, 2), 16);
|
||||
}
|
||||
const ciphertextLenBits = ciphertextBytes.length * 8;
|
||||
const paddedLen = Math.ceil(ciphertextBytes.length / 4) * 4;
|
||||
const padded = new Uint8Array(paddedLen);
|
||||
padded.set(ciphertextBytes);
|
||||
const ciphertextWords = new Uint32Array(paddedLen / 4);
|
||||
for (let i = 0; i < ciphertextWords.length; i++) {
|
||||
ciphertextWords[i] =
|
||||
(padded[i * 4] << 24) |
|
||||
(padded[i * 4 + 1] << 16) |
|
||||
(padded[i * 4 + 2] << 8) |
|
||||
padded[i * 4 + 3];
|
||||
}
|
||||
// Parse target MAC
|
||||
const macByte0 = parseInt(targetMacHex.substr(0, 2), 16);
|
||||
const macByte1 = parseInt(targetMacHex.substr(2, 2), 16);
|
||||
const targetMac = (macByte0 << 24) | (macByte1 << 16);
|
||||
// Resize ciphertext buffer if needed
|
||||
const requiredSize = Math.max(ciphertextWords.length * 4, 4);
|
||||
if (!this.ciphertextBuffer || this.ciphertextBufferSize < requiredSize) {
|
||||
if (this.ciphertextBuffer) {
|
||||
this.ciphertextBuffer.destroy();
|
||||
}
|
||||
this.ciphertextBuffer = this.device.createBuffer({
|
||||
size: requiredSize,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
this.ciphertextBufferSize = requiredSize;
|
||||
}
|
||||
// Compute i_start and j_start from 64-bit batchOffset on CPU
|
||||
// This avoids needing 64-bit math in WGSL shader
|
||||
const iStart = Math.floor(batchOffset / this.wordCount);
|
||||
const jStart = batchOffset % this.wordCount;
|
||||
const paramsData = new Uint32Array([
|
||||
targetChannelHash,
|
||||
this.wordCount,
|
||||
iStart,
|
||||
jStart,
|
||||
batchSize,
|
||||
targetMac,
|
||||
ciphertextWords.length,
|
||||
ciphertextLenBits,
|
||||
30, // max combined length
|
||||
0, // padding
|
||||
]);
|
||||
this.device.queue.writeBuffer(this.paramsBuffer, 0, paramsData);
|
||||
this.device.queue.writeBuffer(this.ciphertextBuffer, 0, ciphertextWords);
|
||||
this.device.queue.writeBuffer(this.matchCountBuffer, 0, GpuWordPairs.ZERO_DATA);
|
||||
// Create bind group
|
||||
const bindGroup = this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.paramsBuffer } },
|
||||
{ binding: 1, resource: { buffer: this.matchCountBuffer } },
|
||||
{ binding: 2, resource: { buffer: this.matchIBuffer } },
|
||||
{ binding: 3, resource: { buffer: this.matchJBuffer } },
|
||||
{ binding: 4, resource: { buffer: this.ciphertextBuffer } },
|
||||
{ binding: 5, resource: { buffer: this.wordDataBuffer } },
|
||||
{ binding: 6, resource: { buffer: this.wordOffsetsBuffer } },
|
||||
],
|
||||
});
|
||||
// Dispatch
|
||||
const commandEncoder = this.device.createCommandEncoder();
|
||||
const passEncoder = commandEncoder.beginComputePass();
|
||||
passEncoder.setPipeline(this.pipeline);
|
||||
passEncoder.setBindGroup(0, bindGroup);
|
||||
const PAIRS_PER_THREAD = 32; // Must match shader constant
|
||||
passEncoder.dispatchWorkgroups(Math.ceil(batchSize / (256 * PAIRS_PER_THREAD)));
|
||||
passEncoder.end();
|
||||
commandEncoder.copyBufferToBuffer(this.matchCountBuffer, 0, this.matchCountReadBuffer, 0, 4);
|
||||
commandEncoder.copyBufferToBuffer(this.matchIBuffer, 0, this.matchIReadBuffer, 0, 1024 * 4);
|
||||
commandEncoder.copyBufferToBuffer(this.matchJBuffer, 0, this.matchJReadBuffer, 0, 1024 * 4);
|
||||
this.device.queue.submit([commandEncoder.finish()]);
|
||||
// Read results
|
||||
await this.matchCountReadBuffer.mapAsync(GPUMapMode.READ);
|
||||
const matchCount = new Uint32Array(this.matchCountReadBuffer.getMappedRange())[0];
|
||||
this.matchCountReadBuffer.unmap();
|
||||
const matches = [];
|
||||
if (matchCount > 0) {
|
||||
await this.matchIReadBuffer.mapAsync(GPUMapMode.READ);
|
||||
await this.matchJReadBuffer.mapAsync(GPUMapMode.READ);
|
||||
const iIndices = new Uint32Array(this.matchIReadBuffer.getMappedRange());
|
||||
const jIndices = new Uint32Array(this.matchJReadBuffer.getMappedRange());
|
||||
for (let k = 0; k < Math.min(matchCount, 1024); k++) {
|
||||
matches.push([iIndices[k], jIndices[k]]);
|
||||
}
|
||||
this.matchIReadBuffer.unmap();
|
||||
this.matchJReadBuffer.unmap();
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
getWordCount() {
|
||||
return this.wordCount;
|
||||
}
|
||||
destroy() {
|
||||
this.paramsBuffer?.destroy();
|
||||
this.matchCountBuffer?.destroy();
|
||||
this.matchIBuffer?.destroy();
|
||||
this.matchJBuffer?.destroy();
|
||||
this.ciphertextBuffer?.destroy();
|
||||
this.wordDataBuffer?.destroy();
|
||||
this.wordOffsetsBuffer?.destroy();
|
||||
this.matchCountReadBuffer?.destroy();
|
||||
this.matchIReadBuffer?.destroy();
|
||||
this.matchJReadBuffer?.destroy();
|
||||
if (this.device) {
|
||||
this.device.destroy();
|
||||
this.device = null;
|
||||
}
|
||||
this.pipeline = null;
|
||||
this.bindGroupLayout = null;
|
||||
}
|
||||
}
|
||||
GpuWordPairs.ZERO_DATA = new Uint32Array([0]);
|
||||
//# sourceMappingURL=gpu-wordpairs.js.map
|
||||
1
package/dist/gpu-wordpairs.js.map
vendored
Executable file
1
package/dist/gpu-wordpairs.js.map
vendored
Executable file
File diff suppressed because one or more lines are too long
33
package/dist/index.d.ts
vendored
Executable file
33
package/dist/index.d.ts
vendored
Executable file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* MeshCore Cracker - Standalone library for cracking MeshCore GroupText packets
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { GroupTextCracker } from 'meshcore-cracker';
|
||||
*
|
||||
* const cracker = new GroupTextCracker();
|
||||
*
|
||||
* // Optional: load wordlist for dictionary attack
|
||||
* await cracker.loadWordlist('/words.txt');
|
||||
*
|
||||
* const result = await cracker.crack(packetHex, {
|
||||
* maxLength: 6,
|
||||
* useSenderFilter: true,
|
||||
* useUtf8Filter: true,
|
||||
* }, (progress) => {
|
||||
* console.log(`${progress.percent.toFixed(1)}% - ETA: ${progress.etaSeconds}s`);
|
||||
* });
|
||||
*
|
||||
* if (result.found) {
|
||||
* console.log(`Room: #${result.roomName}`);
|
||||
* console.log(`Message: ${result.decryptedMessage}`);
|
||||
* }
|
||||
*
|
||||
* cracker.destroy();
|
||||
* ```
|
||||
*/
|
||||
export { GroupTextCracker } from './cracker.js';
|
||||
export type { CrackOptions, CrackResult, ProgressReport, ProgressCallback, DecodedPacket, } from './types.js';
|
||||
export { deriveKeyFromRoomName, getChannelHash, verifyMac, isTimestampValid, isValidUtf8, hasColon, indexToRoomName, roomNameToIndex, countNamesForLength, indexSpaceForLength, PUBLIC_ROOM_NAME, PUBLIC_KEY, DEFAULT_VALID_SECONDS, } from './core.js';
|
||||
export { isWebGpuSupported } from './gpu-bruteforce.js';
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
1
package/dist/index.d.ts.map
vendored
Executable file
1
package/dist/index.d.ts.map
vendored
Executable file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAGH,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGhD,YAAY,EACV,YAAY,EACZ,WAAW,EACX,cAAc,EACd,gBAAgB,EAChB,aAAa,GACd,MAAM,YAAY,CAAC;AAGpB,OAAO,EACL,qBAAqB,EACrB,cAAc,EACd,SAAS,EACT,gBAAgB,EAChB,WAAW,EACX,QAAQ,EACR,eAAe,EACf,eAAe,EACf,mBAAmB,EACnB,mBAAmB,EACnB,gBAAgB,EAChB,UAAU,EACV,qBAAqB,GACtB,MAAM,WAAW,CAAC;AAEnB,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC"}
|
||||
34
package/dist/index.js
vendored
Executable file
34
package/dist/index.js
vendored
Executable file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* MeshCore Cracker - Standalone library for cracking MeshCore GroupText packets
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { GroupTextCracker } from 'meshcore-cracker';
|
||||
*
|
||||
* const cracker = new GroupTextCracker();
|
||||
*
|
||||
* // Optional: load wordlist for dictionary attack
|
||||
* await cracker.loadWordlist('/words.txt');
|
||||
*
|
||||
* const result = await cracker.crack(packetHex, {
|
||||
* maxLength: 6,
|
||||
* useSenderFilter: true,
|
||||
* useUtf8Filter: true,
|
||||
* }, (progress) => {
|
||||
* console.log(`${progress.percent.toFixed(1)}% - ETA: ${progress.etaSeconds}s`);
|
||||
* });
|
||||
*
|
||||
* if (result.found) {
|
||||
* console.log(`Room: #${result.roomName}`);
|
||||
* console.log(`Message: ${result.decryptedMessage}`);
|
||||
* }
|
||||
*
|
||||
* cracker.destroy();
|
||||
* ```
|
||||
*/
|
||||
// Main cracker class
|
||||
export { GroupTextCracker } from './cracker.js';
|
||||
// Utility exports for advanced usage
|
||||
export { deriveKeyFromRoomName, getChannelHash, verifyMac, isTimestampValid, isValidUtf8, hasColon, indexToRoomName, roomNameToIndex, countNamesForLength, indexSpaceForLength, PUBLIC_ROOM_NAME, PUBLIC_KEY, DEFAULT_VALID_SECONDS, } from './core.js';
|
||||
export { isWebGpuSupported } from './gpu-bruteforce.js';
|
||||
//# sourceMappingURL=index.js.map
|
||||
1
package/dist/index.js.map
vendored
Executable file
1
package/dist/index.js.map
vendored
Executable file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,qBAAqB;AACrB,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAWhD,qCAAqC;AACrC,OAAO,EACL,qBAAqB,EACrB,cAAc,EACd,SAAS,EACT,gBAAgB,EAChB,WAAW,EACX,QAAQ,EACR,eAAe,EACf,eAAe,EACf,mBAAmB,EACnB,mBAAmB,EACnB,gBAAgB,EAChB,UAAU,EACV,qBAAqB,GACtB,MAAM,WAAW,CAAC;AAEnB,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC"}
|
||||
164
package/dist/types.d.ts
vendored
Executable file
164
package/dist/types.d.ts
vendored
Executable file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Options for configuring the cracking process.
|
||||
*/
|
||||
export interface CrackOptions {
|
||||
/**
|
||||
* Maximum room name length to search (default: 8).
|
||||
* Longer names exponentially increase search time.
|
||||
*/
|
||||
maxLength?: number;
|
||||
/**
|
||||
* Minimum room name length to search (default: 1).
|
||||
* Use this to skip shorter room names if you know the target is longer.
|
||||
*/
|
||||
startingLength?: number;
|
||||
/**
|
||||
* Use dictionary attack before brute force (default: true).
|
||||
* When enabled and a wordlist is loaded, tries dictionary words first.
|
||||
* Set to false to skip dictionary attack even if a wordlist is loaded.
|
||||
*/
|
||||
useDictionary?: boolean;
|
||||
/**
|
||||
* Filter results by timestamp validity (default: true).
|
||||
* When enabled, rejects results where the decrypted timestamp
|
||||
* is outside the validity window.
|
||||
*/
|
||||
useTimestampFilter?: boolean;
|
||||
/**
|
||||
* Timestamp validity window in seconds (default: 2592000 = 30 days).
|
||||
* Only used when useTimestampFilter is enabled.
|
||||
* Timestamps older than this many seconds from now are rejected.
|
||||
*/
|
||||
validSeconds?: number;
|
||||
/**
|
||||
* Filter results by UTF-8 validity (default: true).
|
||||
* When enabled, rejects results containing invalid UTF-8 sequences.
|
||||
*/
|
||||
useUtf8Filter?: boolean;
|
||||
/**
|
||||
* Filter results by sender presence (default: true).
|
||||
* When enabled, only accepts results where the decrypted message
|
||||
* has a valid sender field, which is expected in valid MeshCore messages.
|
||||
*
|
||||
* Technically, this checks for ": " (colon-space) within the first 50
|
||||
* characters of the decrypted text, where the part before the colon
|
||||
* doesn't contain special characters like brackets.
|
||||
*
|
||||
* When a sender is found, the decrypted message includes the full
|
||||
* "sender: message" format.
|
||||
*/
|
||||
useSenderFilter?: boolean;
|
||||
/**
|
||||
* Resume cracking from a specific position.
|
||||
* Useful for resuming interrupted searches.
|
||||
* The interpretation depends on startFromType.
|
||||
*/
|
||||
startFrom?: string;
|
||||
/**
|
||||
* How to interpret the startFrom value (default: 'bruteforce').
|
||||
* - 'dictionary': startFrom is a dictionary word; resume dictionary attack from that word, then continue to word pairs and brute force
|
||||
* - 'dictionary-pair': startFrom is "word1+word2"; resume two-word combination attack from that pair, then continue to brute force
|
||||
* - 'bruteforce': startFrom is a brute-force position; skip dictionary/pairs and resume brute force from that position
|
||||
*/
|
||||
startFromType?: 'dictionary' | 'dictionary-pair' | 'bruteforce';
|
||||
/**
|
||||
* Force CPU-based cracking instead of WebGPU (default: false).
|
||||
* Much slower but works in environments without WebGPU support.
|
||||
* Also useful for testing.
|
||||
*/
|
||||
forceCpu?: boolean;
|
||||
/**
|
||||
* EXPERIMENTAL: Try two-word combinations from the wordlist (default: false).
|
||||
* After the dictionary attack, tries every pair of words concatenated together
|
||||
* (e.g., "hello" + "world" = "helloworld") where the combined length is <= 30.
|
||||
* This can significantly increase search time depending on wordlist size.
|
||||
* Only used when useDictionary is true and a wordlist is loaded.
|
||||
*/
|
||||
useTwoWordCombinations?: boolean;
|
||||
/**
|
||||
* EXPERIMENTAL - Target GPU dispatch time in milliseconds (default: 1000).
|
||||
*
|
||||
* Higher values improve throughput by reducing dispatch overhead, but:
|
||||
* - Reduce responsiveness of progress updates and abort()
|
||||
* - May cause browser watchdog timeouts or "device lost" errors
|
||||
* - May cause system UI stuttering during long dispatches
|
||||
*
|
||||
* Values up to ~10000ms may work on modern GPUs but stability varies
|
||||
* by browser, OS, and hardware. Test thoroughly before using in production.
|
||||
* Only applies when using GPU (not forceCpu).
|
||||
*/
|
||||
gpuDispatchMs?: number;
|
||||
}
|
||||
/**
|
||||
* Progress information reported during cracking.
|
||||
*/
|
||||
export interface ProgressReport {
|
||||
/** Total candidates checked so far */
|
||||
checked: number;
|
||||
/** Total candidates to check */
|
||||
total: number;
|
||||
/** Progress percentage (0-100) */
|
||||
percent: number;
|
||||
/** Current cracking rate in keys/second */
|
||||
rateKeysPerSec: number;
|
||||
/** Estimated time remaining in seconds */
|
||||
etaSeconds: number;
|
||||
/** Time elapsed since start in seconds */
|
||||
elapsedSeconds: number;
|
||||
/** Current room name length being tested */
|
||||
currentLength: number;
|
||||
/** Current room name position being tested */
|
||||
currentPosition: string;
|
||||
/** Current phase of cracking */
|
||||
phase: 'public-key' | 'wordlist' | 'wordlist-pairs' | 'bruteforce';
|
||||
}
|
||||
/**
|
||||
* Callback function for progress updates.
|
||||
* Called approximately 5 times per second during cracking.
|
||||
*/
|
||||
export type ProgressCallback = (report: ProgressReport) => void;
|
||||
/**
|
||||
* Result of a cracking operation.
|
||||
*/
|
||||
export interface CrackResult {
|
||||
/** Whether a matching room name was found */
|
||||
found: boolean;
|
||||
/** The room name (without '#' prefix) if found */
|
||||
roomName?: string;
|
||||
/** The derived encryption key (hex) if found */
|
||||
key?: string;
|
||||
/** The decrypted message content if found */
|
||||
decryptedMessage?: string;
|
||||
/** Whether the operation was aborted */
|
||||
aborted?: boolean;
|
||||
/**
|
||||
* Position to resume from to continue searching.
|
||||
* Always provided on success, abort, or not-found (not on error).
|
||||
* Pass this as `startFrom` with the corresponding `startFromType` to skip
|
||||
* past this result and continue searching for additional matches.
|
||||
*/
|
||||
resumeFrom?: string;
|
||||
/**
|
||||
* Type of resume position. Use as `startFromType` when resuming.
|
||||
* - 'dictionary': resumeFrom is a dictionary word
|
||||
* - 'dictionary-pair': resumeFrom is "word1+word2" (two-word combination)
|
||||
* - 'bruteforce': resumeFrom is a brute-force position
|
||||
*/
|
||||
resumeType?: 'dictionary' | 'dictionary-pair' | 'bruteforce';
|
||||
/** Error message if an error occurred */
|
||||
error?: string;
|
||||
}
|
||||
/**
|
||||
* Decoded packet information extracted from a MeshCore GroupText packet.
|
||||
*/
|
||||
export interface DecodedPacket {
|
||||
/** Channel hash (1 byte, hex) */
|
||||
channelHash: string;
|
||||
/** Encrypted ciphertext (hex) */
|
||||
ciphertext: string;
|
||||
/** MAC for verification (2 bytes, hex) */
|
||||
cipherMac: string;
|
||||
/** Whether this is a GroupText packet */
|
||||
isGroupText: boolean;
|
||||
}
|
||||
//# sourceMappingURL=types.d.ts.map
|
||||
1
package/dist/types.d.ts.map
vendored
Executable file
1
package/dist/types.d.ts.map
vendored
Executable file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IAExB;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAE7B;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IAExB;;;;;;;;;;;OAWG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAE1B;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,YAAY,GAAG,iBAAiB,GAAG,YAAY,CAAC;IAEhE;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IAEnB;;;;;;OAMG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAC;IAEjC;;;;;;;;;;;OAWG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,sCAAsC;IACtC,OAAO,EAAE,MAAM,CAAC;IAEhB,gCAAgC;IAChC,KAAK,EAAE,MAAM,CAAC;IAEd,kCAAkC;IAClC,OAAO,EAAE,MAAM,CAAC;IAEhB,2CAA2C;IAC3C,cAAc,EAAE,MAAM,CAAC;IAEvB,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IAEnB,0CAA0C;IAC1C,cAAc,EAAE,MAAM,CAAC;IAEvB,4CAA4C;IAC5C,aAAa,EAAE,MAAM,CAAC;IAEtB,8CAA8C;IAC9C,eAAe,EAAE,MAAM,CAAC;IAExB,gCAAgC;IAChC,KAAK,EAAE,YAAY,GAAG,UAAU,GAAG,gBAAgB,GAAG,YAAY,CAAC;CACpE;AAED;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,MAAM,EAAE,cAAc,KAAK,IAAI,CAAC;AAEhE;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,6CAA6C;IAC7C,KAAK,EAAE,OAAO,CAAC;IAEf,kDAAkD;IAClD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB,gDAAgD;IAChD,GAAG,CAAC,EAAE,MAAM,CAAC;IAEb,6CAA6C;IAC7C,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAE1B,wCAAwC;IACxC,OAAO,CAAC,EAAE,OAAO,CAAC;IAElB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,YAAY,GAAG,iBAAiB,GAAG,YAAY,CAAC;IAE7D,yCAAyC;IACzC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,iCAAiC;IACjC,WAAW,EAAE,MAAM,CAAC;IAEpB,iCAAiC;IACjC,UAAU,EAAE,MAAM,CAAC;IAEnB,0CAA0C;IAC1C,SAAS,EAAE,MAAM,CAAC;IAElB,yCAAyC;IACzC,WAAW,EAAE,OAAO,CAAC;CACtB"}
|
||||
2
package/dist/types.js
vendored
Executable file
2
package/dist/types.js
vendored
Executable file
@@ -0,0 +1,2 @@
|
||||
export {};
|
||||
//# sourceMappingURL=types.js.map
|
||||
1
package/dist/types.js.map
vendored
Executable file
1
package/dist/types.js.map
vendored
Executable file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
||||
12
package/dist/wordlist.d.ts
vendored
Executable file
12
package/dist/wordlist.d.ts
vendored
Executable file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* English wordlist for dictionary attacks
|
||||
* Auto-generated from words.txt - do not edit manually
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { ENGLISH_WORDLIST } from 'meshcore-hashtag-cracker/wordlist';
|
||||
* cracker.setWordlist(ENGLISH_WORDLIST);
|
||||
* ```
|
||||
*/
|
||||
export declare const ENGLISH_WORDLIST: string[];
|
||||
//# sourceMappingURL=wordlist.d.ts.map
|
||||
1
package/dist/wordlist.d.ts.map
vendored
Executable file
1
package/dist/wordlist.d.ts.map
vendored
Executable file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"wordlist.d.ts","sourceRoot":"","sources":["../src/wordlist.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,eAAO,MAAM,gBAAgB,EAAE,MAAM,EAAwlstL,CAAC"}
|
||||
12
package/dist/wordlist.js
vendored
Executable file
12
package/dist/wordlist.js
vendored
Executable file
File diff suppressed because one or more lines are too long
1
package/dist/wordlist.js.map
vendored
Executable file
1
package/dist/wordlist.js.map
vendored
Executable file
File diff suppressed because one or more lines are too long
63
package/package.json
Executable file
63
package/package.json
Executable file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "meshcore-hashtag-cracker",
|
||||
"version": "1.11.0",
|
||||
"description": "MeshCore hashtag GroupText packet brute-forcer with WebGPU acceleration and dictionary attacks",
|
||||
"author": "Jack Kingsman",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./wordlist": {
|
||||
"import": "./dist/wordlist.js",
|
||||
"types": "./dist/wordlist.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"browser",
|
||||
"README.md",
|
||||
"LICENSE.md"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/jkingsman/meshcore-hashtag-cracker.git"
|
||||
},
|
||||
"homepage": "https://github.com/jkingsman/meshcore-hashtag-cracker#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/jkingsman/meshcore-hashtag-cracker/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build:wordlist && tsc && npm run build:browser",
|
||||
"build:wordlist": "node scripts/build-wordlist.js",
|
||||
"build:browser": "node scripts/build-browser.js",
|
||||
"watch": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"prepublishOnly": "npm run build && npm test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@michaelhart/meshcore-decoder": "npm:meshcore-decoder-multibyte-patch@0.2.7",
|
||||
"crypto-js": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@webgpu/types": "^0.1.68",
|
||||
"esbuild": "^0.27.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"keywords": [
|
||||
"meshcore",
|
||||
"cracker",
|
||||
"brute-force",
|
||||
"webgpu"
|
||||
],
|
||||
"directories": {
|
||||
"test": "test"
|
||||
}
|
||||
}
|
||||
@@ -2211,8 +2211,9 @@ app.get('/api/channels/:hash/messages', (req, res) => {
|
||||
|
||||
const sender = decoded.sender || (decoded.text ? decoded.text.split(': ')[0] : null) || pkt.observer_name || pkt.observer_id || 'Unknown';
|
||||
const text = decoded.text || decoded.encryptedData || '';
|
||||
const ts = decoded.sender_timestamp || pkt.timestamp;
|
||||
const dedupeKey = `${sender}:${ts}`;
|
||||
// Use server observation timestamp for dedup — sender_timestamp is unreliable (device clocks are wildly inaccurate)
|
||||
const ts = pkt.timestamp;
|
||||
const dedupeKey = `${sender}:${pkt.hash}`;
|
||||
|
||||
if (msgMap.has(dedupeKey)) {
|
||||
const existing = msgMap.get(dedupeKey);
|
||||
|
||||
Reference in New Issue
Block a user