Files
meshcore-analyzer/package/browser/testbed.html
you eca0c9bd61 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.
2026-03-21 21:53:38 +00:00

654 lines
23 KiB
HTML
Executable File

<!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>