// corescope-decrypt decrypts and exports hashtag channel messages from a CoreScope SQLite database. // // Usage: // // corescope-decrypt --channel "#wardriving" --db meshcore.db [--format json|html] [--output file] package main import ( "database/sql" "encoding/hex" "encoding/json" "flag" "fmt" "html" "log" "os" "sort" "strings" "time" "github.com/meshcore-analyzer/channel" _ "modernc.org/sqlite" ) // Version info (set via ldflags). var version = "dev" // ChannelMessage is a single decrypted channel message with metadata. type ChannelMessage struct { Hash string `json:"hash"` Timestamp string `json:"timestamp"` Sender string `json:"sender"` Message string `json:"message"` Channel string `json:"channel"` RawHex string `json:"raw_hex"` Path []string `json:"path"` Observers []Observer `json:"observers"` } // Observer is a single observation of the transmission. type Observer struct { Name string `json:"name"` SNR float64 `json:"snr"` RSSI float64 `json:"rssi"` Timestamp string `json:"timestamp"` } func main() { channelName := flag.String("channel", "", "Channel name (e.g. \"#wardriving\")") dbPath := flag.String("db", "", "Path to CoreScope SQLite database") format := flag.String("format", "json", "Output format: json, html, irc (or log)") output := flag.String("output", "", "Output file (default: stdout)") showVersion := flag.Bool("version", false, "Print version and exit") flag.Usage = func() { fmt.Fprintf(os.Stderr, `corescope-decrypt — Decrypt and export MeshCore hashtag channel messages USAGE corescope-decrypt --channel NAME --db PATH [--format FORMAT] [--output FILE] FLAGS --channel NAME Channel name to decrypt (e.g. "#wardriving", "wardriving") The "#" prefix is added automatically if missing. --db PATH Path to a CoreScope SQLite database file (read-only access). --format FORMAT Output format (default: json): json — Machine-readable JSON array with full metadata html — Self-contained HTML viewer with search and sorting irc — Plain-text IRC-style log, one line per message log — Alias for irc --output FILE Write output to FILE instead of stdout. --version Print version and exit. EXAMPLES # Export #wardriving messages as JSON corescope-decrypt --channel "#wardriving" --db /app/data/meshcore.db # Generate an interactive HTML viewer corescope-decrypt --channel wardriving --db meshcore.db --format html --output wardriving.html # Greppable IRC log corescope-decrypt --channel "#MeshCore" --db meshcore.db --format irc --output meshcore.log grep "KE6QR" meshcore.log # From the Docker container docker exec corescope-prod /app/corescope-decrypt --channel "#wardriving" --db /app/data/meshcore.db RETROACTIVE DECRYPTION MeshCore hashtag channels use symmetric encryption — the key is derived from the channel name. The CoreScope ingestor stores ALL GRP_TXT packets in the database, even those it cannot decrypt at ingest time. This tool lets you retroactively decrypt messages for any channel whose name you know, even if the ingestor was never configured with that channel's key. This means you can recover historical messages by simply knowing the channel name. LIMITATIONS - Only hashtag channels (shared-secret, name-derived key) are supported. - Direct messages (TXT_MSG) use per-peer encryption and cannot be decrypted. - Custom PSK channels (non-hashtag) require the raw key, not a channel name. `) } flag.Parse() if *showVersion { fmt.Println("corescope-decrypt", version) os.Exit(0) } if *channelName == "" || *dbPath == "" { flag.Usage() os.Exit(1) } // Normalize channel name ch := *channelName if !strings.HasPrefix(ch, "#") { ch = "#" + ch } key := channel.DeriveKey(ch) chHash := channel.ChannelHash(key) db, err := sql.Open("sqlite", *dbPath+"?mode=ro") if err != nil { log.Fatalf("Failed to open database: %v", err) } defer db.Close() // Query all GRP_TXT packets rows, err := db.Query(`SELECT id, hash, raw_hex, first_seen FROM transmissions WHERE payload_type = 5`) if err != nil { log.Fatalf("Query failed: %v", err) } defer rows.Close() var messages []ChannelMessage decrypted, total := 0, 0 for rows.Next() { var id int var txHash, rawHex, firstSeen string if err := rows.Scan(&id, &txHash, &rawHex, &firstSeen); err != nil { log.Printf("Scan error: %v", err) continue } total++ payload, err := extractGRPPayload(rawHex) if err != nil { continue } if len(payload) < 3 { continue } // Check channel hash byte if payload[0] != chHash { continue } mac := payload[1:3] ciphertext := payload[3:] if len(ciphertext) < 5 || len(ciphertext)%16 != 0 { // Pad ciphertext to block boundary for decryption attempt if len(ciphertext) < 16 { continue } // Truncate to block boundary ciphertext = ciphertext[:len(ciphertext)/16*16] } plaintext, ok := channel.Decrypt(key, mac, ciphertext) if !ok { continue } ts, sender, msg, err := channel.ParsePlaintext(plaintext) if err != nil { continue } decrypted++ // Convert MeshCore timestamp timestamp := time.Unix(int64(ts), 0).UTC().Format(time.RFC3339) // Get path from decoded_json path := getPathFromDB(db, id) // Get observers observers := getObservers(db, id) messages = append(messages, ChannelMessage{ Hash: txHash, Timestamp: timestamp, Sender: sender, Message: msg, Channel: ch, RawHex: rawHex, Path: path, Observers: observers, }) } // Sort by timestamp sort.Slice(messages, func(i, j int) bool { return messages[i].Timestamp < messages[j].Timestamp }) log.Printf("Scanned %d GRP_TXT packets, decrypted %d for channel %s", total, decrypted, ch) // Generate output var out []byte switch *format { case "json": out, err = json.MarshalIndent(messages, "", " ") if err != nil { log.Fatalf("JSON marshal: %v", err) } out = append(out, '\n') case "html": out = renderHTML(messages, ch) case "irc", "log": out = renderIRC(messages) default: log.Fatalf("Unknown format: %s (use json, html, irc, or log)", *format) } if *output != "" { if err := os.WriteFile(*output, out, 0644); err != nil { log.Fatalf("Write file: %v", err) } log.Printf("Written to %s", *output) } else { os.Stdout.Write(out) } } // extractGRPPayload parses a raw hex packet and returns the GRP_TXT payload bytes. func extractGRPPayload(rawHex string) ([]byte, error) { buf, err := hex.DecodeString(strings.TrimSpace(rawHex)) if err != nil || len(buf) < 2 { return nil, fmt.Errorf("invalid hex") } // Header byte header := buf[0] payloadType := int((header >> 2) & 0x0F) if payloadType != 5 { // GRP_TXT return nil, fmt.Errorf("not GRP_TXT") } routeType := int(header & 0x03) offset := 1 // Transport codes (2 codes × 2 bytes) come BEFORE path for transport routes if routeType == 0 || routeType == 3 { offset += 4 } // Path byte if offset >= len(buf) { return nil, fmt.Errorf("too short for path") } pathByte := buf[offset] offset++ hashSize := int(pathByte>>6) + 1 hashCount := int(pathByte & 0x3F) offset += hashSize * hashCount if offset >= len(buf) { return nil, fmt.Errorf("too short for payload") } return buf[offset:], nil } func getPathFromDB(db *sql.DB, txID int) []string { var decodedJSON sql.NullString err := db.QueryRow(`SELECT decoded_json FROM transmissions WHERE id = ?`, txID).Scan(&decodedJSON) if err != nil || !decodedJSON.Valid { return nil } var decoded struct { Path struct { Hops []string `json:"hops"` } `json:"path"` } if json.Unmarshal([]byte(decodedJSON.String), &decoded) == nil { return decoded.Path.Hops } return nil } func getObservers(db *sql.DB, txID int) []Observer { rows, err := db.Query(` SELECT o.name, obs.snr, obs.rssi, obs.timestamp FROM observations obs LEFT JOIN observers o ON o.id = CAST(obs.observer_idx AS TEXT) WHERE obs.transmission_id = ? ORDER BY obs.timestamp `, txID) if err != nil { return nil } defer rows.Close() var observers []Observer for rows.Next() { var name sql.NullString var snr, rssi sql.NullFloat64 var ts int64 if err := rows.Scan(&name, &snr, &rssi, &ts); err != nil { continue } obs := Observer{ Timestamp: time.Unix(ts, 0).UTC().Format(time.RFC3339), } if name.Valid { obs.Name = name.String } if snr.Valid { obs.SNR = snr.Float64 } if rssi.Valid { obs.RSSI = rssi.Float64 } observers = append(observers, obs) } return observers } func renderIRC(messages []ChannelMessage) []byte { var b strings.Builder for _, m := range messages { sender := m.Sender if sender == "" { sender = "???" } // Parse RFC3339 timestamp into a compact format t, err := time.Parse(time.RFC3339, m.Timestamp) if err != nil { b.WriteString(fmt.Sprintf("[%s] <%s> %s\n", m.Timestamp, sender, m.Message)) continue } b.WriteString(fmt.Sprintf("[%s] <%s> %s\n", t.Format("2006-01-02 15:04:05"), sender, m.Message)) } return []byte(b.String()) } func renderHTML(messages []ChannelMessage, channelName string) []byte { jsonData, _ := json.Marshal(messages) var b strings.Builder b.WriteString(`
| Timestamp | Sender | Message | Observers |
|---|