import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { GroupTextCracker, type ProgressReport } from 'meshcore-hashtag-cracker'; import NoSleep from 'nosleep.js'; import type { RawPacket, Channel } from '../types'; import { api } from '../api'; import { toast } from './ui/sonner'; import { cn } from '@/lib/utils'; /** * Extract the payload from a raw packet hex string, skipping header and path. * Returns the payload as a hex string, or null if malformed. */ function extractPayload(packetHex: string): string | null { if (packetHex.length < 4) return null; // Need at least 2 bytes try { const header = parseInt(packetHex.slice(0, 2), 16); const routeType = header & 0x03; let offset = 2; // 1 byte = 2 hex chars // Skip transport codes if present (TRANSPORT_FLOOD=0, TRANSPORT_DIRECT=3) if (routeType === 0x00 || routeType === 0x03) { if (packetHex.length < offset + 8) return null; // Need 4 more bytes offset += 8; // 4 bytes = 8 hex chars } // Get path length if (packetHex.length < offset + 2) return null; const pathLength = parseInt(packetHex.slice(offset, offset + 2), 16); offset += 2; // Skip path data const pathBytes = pathLength * 2; // hex chars if (packetHex.length < offset + pathBytes) return null; offset += pathBytes; // Rest is payload return packetHex.slice(offset); } catch { return null; } } interface CrackedRoom { roomName: string; key: string; packetId: number; message: string; crackedAt: number; } interface QueueItem { packet: RawPacket; attempts: number; lastAttemptLength: number; status: 'pending' | 'cracking' | 'cracked' | 'failed'; } interface CrackerPanelProps { packets: RawPacket[]; channels: Channel[]; onChannelCreate: (name: string, key: string) => Promise; onRunningChange?: (running: boolean) => void; visible?: boolean; } export function CrackerPanel({ packets, channels, onChannelCreate, onRunningChange, visible = false, }: CrackerPanelProps) { const [isRunning, setIsRunning] = useState(false); const [maxLength, setMaxLength] = useState(6); const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false); const [decryptHistorical, setDecryptHistorical] = useState(true); const [turboMode, setTurboMode] = useState(false); const [progress, setProgress] = useState(null); const [queue, setQueue] = useState>(new Map()); const [crackedRooms, setCrackedRooms] = useState([]); const [wordlistLoaded, setWordlistLoaded] = useState(false); const [gpuAvailable, setGpuAvailable] = useState(null); const [undecryptedPacketCount, setUndecryptedPacketCount] = useState(null); const [skippedDuplicates, setSkippedDuplicates] = useState(0); const crackerRef = useRef(null); const noSleepRef = useRef(null); const isRunningRef = useRef(false); const abortedRef = useRef(false); const isProcessingRef = useRef(false); const queueRef = useRef>(new Map()); const retryFailedRef = useRef(false); const maxLengthRef = useRef(6); const decryptHistoricalRef = useRef(true); const turboModeRef = useRef(false); const undecryptedIdsRef = useRef>(new Set()); const seenPayloadsRef = useRef>(new Set()); // Initialize cracker and NoSleep useEffect(() => { const cracker = new GroupTextCracker(); crackerRef.current = cracker; setGpuAvailable(cracker.isGpuAvailable()); const noSleep = new NoSleep(); noSleepRef.current = noSleep; return () => { cracker.destroy(); crackerRef.current = null; noSleep.disable(); noSleepRef.current = null; }; }, []); // Load wordlist dynamically when panel becomes visible for the first time useEffect(() => { if (!visible || wordlistLoaded) return; import('meshcore-hashtag-cracker/wordlist') .then(({ ENGLISH_WORDLIST }) => { if (crackerRef.current) { crackerRef.current.setWordlist(ENGLISH_WORDLIST); setWordlistLoaded(true); } }) .catch((err) => { console.error('Failed to load wordlist:', err); toast.error('Failed to load wordlist', { description: 'Cracking will not be available', }); }); }, [visible, wordlistLoaded]); // Fetch undecrypted packet count useEffect(() => { const fetchCount = () => { api .getUndecryptedPacketCount() .then(({ count }) => setUndecryptedPacketCount(count)) .catch(() => setUndecryptedPacketCount(null)); }; fetchCount(); // Refresh periodically const interval = setInterval(fetchCount, 30000); return () => clearInterval(interval); }, []); // Get existing channel keys for filtering (memoized to avoid recreating on every render) const existingChannelKeys = useMemo( () => new Set(channels.map((c) => c.key.toUpperCase())), [channels] ); // Filter packets to only undecrypted GROUP_TEXT const undecryptedGroupText = packets.filter( (p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted ); // Update queue when packets change (deduplicated by payload) // Note: We intentionally depend on .length only to avoid re-running on every array identity change useEffect(() => { let newSkipped = 0; setQueue((prev) => { const newQueue = new Map(prev); let changed = false; for (const packet of undecryptedGroupText) { if (!newQueue.has(packet.id)) { // Extract payload and check for duplicates const payload = extractPayload(packet.data); if (payload && seenPayloadsRef.current.has(payload)) { // Skip - we already have a packet with this payload queued newSkipped++; continue; } // Track this payload as seen if (payload) { seenPayloadsRef.current.add(payload); } newQueue.set(packet.id, { packet, attempts: 0, lastAttemptLength: 0, status: 'pending', }); changed = true; } } if (changed) { queueRef.current = newQueue; return newQueue; } return prev; }); if (newSkipped > 0) { setSkippedDuplicates((prev) => prev + newSkipped); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [undecryptedGroupText.length]); // Keep refs in sync with state useEffect(() => { queueRef.current = queue; }, [queue]); useEffect(() => { retryFailedRef.current = retryFailedAtNextLength; }, [retryFailedAtNextLength]); useEffect(() => { maxLengthRef.current = maxLength; }, [maxLength]); useEffect(() => { decryptHistoricalRef.current = decryptHistorical; }, [decryptHistorical]); useEffect(() => { turboModeRef.current = turboMode; }, [turboMode]); // Keep undecrypted IDs ref in sync - used to skip packets already decrypted by other means useEffect(() => { undecryptedIdsRef.current = new Set(undecryptedGroupText.map((p) => p.id)); }, [undecryptedGroupText]); // Notify parent of running state changes useEffect(() => { onRunningChange?.(isRunning); }, [isRunning, onRunningChange]); // Stats (cracking count is implicit - if progress is shown, we're cracking one) const pendingCount = Array.from(queue.values()).filter((q) => q.status === 'pending').length; const crackedCount = Array.from(queue.values()).filter((q) => q.status === 'cracked').length; const failedCount = Array.from(queue.values()).filter((q) => q.status === 'failed').length; // Process next packet in queue const processNext = useCallback(async () => { // Prevent concurrent processing if (isProcessingRef.current) return; if (!crackerRef.current || !isRunningRef.current) return; const currentQueue = queueRef.current; // Find next pending packet let nextItem: QueueItem | null = null; let nextId: number | null = null; for (const [id, item] of currentQueue.entries()) { if (item.status === 'pending') { nextItem = item; nextId = id; break; } } // If no pending and retry option is enabled, pick the failed one with lowest lastAttemptLength if (!nextItem && retryFailedRef.current) { const failedItems = Array.from(currentQueue.entries()).filter( ([, item]) => item.status === 'failed' && item.lastAttemptLength < 10 // Hard cap at length 10 ); if (failedItems.length > 0) { // Sort by lastAttemptLength ascending and pick the first (lowest) failedItems.sort((a, b) => a[1].lastAttemptLength - b[1].lastAttemptLength); [nextId, nextItem] = failedItems[0]; } } if (!nextItem || nextId === null) { // Nothing to process right now, but keep running and check again later if (isRunningRef.current) { setTimeout(() => processNext(), 1000); } return; } // Check if this packet is still undecrypted - it may have been decrypted // by historical decrypt when we cracked another packet from the same channel if (!undecryptedIdsRef.current.has(nextId)) { // Already decrypted by other means, remove from queue and continue setQueue((prev) => { const updated = new Map(prev); updated.delete(nextId); return updated; }); if (isRunningRef.current) { setTimeout(() => processNext(), 10); } return; } // Lock processing isProcessingRef.current = true; const currentMaxLength = maxLengthRef.current; const isRetry = nextItem.lastAttemptLength > 0; const targetLength = isRetry ? nextItem.lastAttemptLength + 1 : currentMaxLength; try { const result = await crackerRef.current.crack( nextItem.packet.data, { maxLength: targetLength, useSenderFilter: true, useTimestampFilter: true, useUtf8Filter: true, ...(turboModeRef.current && { gpuDispatchMs: 10000 }), // For retries, skip dictionary and shorter lengths - we already checked those ...(isRetry && { useDictionary: false, startingLength: targetLength }), }, (prog) => { setProgress(prog); } ); if (abortedRef.current) { abortedRef.current = false; isProcessingRef.current = false; setProgress(null); return; } if (result.found && result.roomName && result.key) { // Success! setQueue((prev) => { const updated = new Map(prev); const item = updated.get(nextId!); if (item) { updated.set(nextId!, { ...item, status: 'cracked', attempts: item.attempts + 1, lastAttemptLength: targetLength, }); } return updated; }); const newRoom: CrackedRoom = { roomName: result.roomName, key: result.key, packetId: nextId!, message: result.decryptedMessage || '', crackedAt: Date.now(), }; setCrackedRooms((prev) => [...prev, newRoom]); // Auto-add channel if not already exists const keyUpper = result.key.toUpperCase(); if (!existingChannelKeys.has(keyUpper)) { try { const channelName = '#' + result.roomName; await onChannelCreate(channelName, result.key); // Optionally decrypt any other historical packets with this newly discovered key // This prevents wasting cracking cycles on packets from the same channel if (decryptHistoricalRef.current) { await api.decryptHistoricalPackets({ key_type: 'channel', channel_name: channelName, }); } } catch (err) { console.error('Failed to create channel or decrypt historical:', err); toast.error('Failed to save cracked channel', { description: err instanceof Error ? err.message : 'Channel discovered but could not be saved', }); } } } else { // Failed setQueue((prev) => { const updated = new Map(prev); const item = updated.get(nextId!); if (item) { updated.set(nextId!, { ...item, status: 'failed', attempts: item.attempts + 1, lastAttemptLength: targetLength, }); } return updated; }); } } catch (err) { console.error('Cracking error:', err); setQueue((prev) => { const updated = new Map(prev); const item = updated.get(nextId!); if (item) { updated.set(nextId!, { ...item, status: 'failed', attempts: item.attempts + 1, lastAttemptLength: targetLength, }); } return updated; }); } // Unlock processing isProcessingRef.current = false; setProgress(null); // Continue processing if still running if (isRunningRef.current) { setTimeout(() => processNext(), 100); } }, [existingChannelKeys, onChannelCreate]); // Start/stop handlers const handleStart = () => { if (!gpuAvailable) { toast.error('WebGPU not available', { description: 'Cracking requires Chrome 113+ or Edge 113+ with WebGPU support.', }); return; } setIsRunning(true); isRunningRef.current = true; abortedRef.current = false; noSleepRef.current?.enable(); processNext(); }; const handleStop = () => { setIsRunning(false); isRunningRef.current = false; abortedRef.current = true; crackerRef.current?.abort(); noSleepRef.current?.disable(); }; return (
setMaxLength(Math.min(10, Math.max(1, parseInt(e.target.value) || 6)))} className="w-14 px-2 py-1 text-sm bg-muted border border-border rounded" />
{decryptHistorical && ( {undecryptedPacketCount !== null && undecryptedPacketCount > 0 ? `(${undecryptedPacketCount.toLocaleString()} packets; messages will stream in as decrypted)` : '(messages will stream in as decrypted)'} )}
{/* Status */}
Pending: {pendingCount} Cracked: {crackedCount} Failed: {failedCount} {skippedDuplicates > 0 && ( Skipped (dup):{' '} {skippedDuplicates} )}
{/* Progress */} {progress && (
{progress.phase === 'wordlist' ? 'Dictionary' : progress.phase === 'bruteforce' ? 'Bruteforce' : 'Public Key'} {progress.phase === 'bruteforce' && ` - Length ${progress.currentLength}`}:{' '} {progress.currentPosition} {progress.rateKeysPerSec >= 1e9 ? `${(progress.rateKeysPerSec / 1e9).toFixed(2)} Gkeys/s` : `${(progress.rateKeysPerSec / 1e6).toFixed(1)} Mkeys/s`}{' '} • ETA:{' '} {progress.etaSeconds < 60 ? `${Math.round(progress.etaSeconds)}s` : `${Math.round(progress.etaSeconds / 60)}m`}
)} {/* GPU status */} {gpuAvailable === false && (
WebGPU not available. Cracking requires Chrome 113+ or Edge 113+.
)} {!wordlistLoaded && gpuAvailable !== false && (
Loading wordlist...
)} {/* Cracked rooms list */} {crackedRooms.length > 0 && (
Cracked Rooms:
{crackedRooms.map((room, i) => (
#{room.roomName} "{room.message.slice(0, 50)} {room.message.length > 50 ? '...' : ''}"
))}
)}

For unknown-keyed GroupText packets, this will attempt to dictionary attack, then brute force payloads as they arrive, testing room names up to the specified length to discover active rooms on the local mesh (GroupText packets may not be hashtag messages; we have no way of knowing but try as if they are). Retry failed at n+1 will let the cracker return to the failed queue and pick up messages it couldn't crack, attempting them at one longer length. Decrypt historical will run an async job on any room name it finds to see if any historically captured packets will decrypt with that key. Turbo mode will push your GPU to the max (target dispatch time of 10s) and may allow accelerated cracking and/or system instability.

); }