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:
you
2026-03-21 21:53:38 +00:00
parent 3c12690ccb
commit eca0c9bd61
46 changed files with 4707 additions and 2 deletions

106
RELEASE-NOTES-DRAFT.md Normal file
View 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
View 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 | — |

Binary file not shown.

108
package/LICENSE.md Executable file
View 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
View File

@@ -0,0 +1,152 @@
![NPM Version](https://img.shields.io/npm/v/meshcore-hashtag-cracker)
# 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

653
package/browser/testbed.html Executable file
View 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
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

13
package/dist/cpu-bruteforce.d.ts vendored Executable file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

42
package/dist/dictionary-index.d.ts vendored Executable file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

38
package/dist/gpu-wordpairs.d.ts vendored Executable file
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

33
package/dist/index.d.ts vendored Executable file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=types.js.map

1
package/dist/types.js.map vendored Executable file
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

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
View 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"
}
}

View File

@@ -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);