mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-06 12:51:42 +00:00
c233c14156
## Summary
Adds `corescope-decrypt` — a standalone CLI tool that decrypts and
exports MeshCore hashtag channel messages from a CoreScope SQLite
database.
### What it does
MeshCore hashtag channels use symmetric encryption with keys derived
from the channel name. The CoreScope ingestor stores **all** GRP_TXT
packets, even those it can't decrypt. This tool enables retroactive
decryption — decrypt historical messages for any channel whose name you
learn after the fact.
### Architecture
- **`internal/channel/`** — Shared crypto package extracted from
ingestor logic:
- `DeriveKey()` — `SHA-256("#name")[:16]`
- `ChannelHash()` — 1-byte packet filter (`SHA-256(key)[0]`)
- `Decrypt()` — HMAC-SHA256 MAC verify + AES-128-ECB
- `ParsePlaintext()` — timestamp + flags + "sender: message" parsing
- **`cmd/decrypt/`** — CLI binary with three output formats:
- `--format json` — Full metadata (observers, path, raw hex)
- `--format html` — Self-contained interactive viewer with search/sort
- `--format irc` (or `log`) — Plain-text IRC-style log, greppable
### Usage
```bash
# JSON export
corescope-decrypt --channel "#wardriving" --db meshcore.db
# Interactive HTML viewer
corescope-decrypt --channel wardriving --db meshcore.db --format html --output wardriving.html
# Greppable log
corescope-decrypt --channel "#wardriving" --db meshcore.db --format irc | grep "KE6QR"
# From Docker
docker exec corescope-prod /app/corescope-decrypt --channel "#wardriving" --db /app/data/meshcore.db
```
### Build & deployment
- Statically linked (`CGO_ENABLED=0`) — zero dependencies
- Added to Dockerfile (available at `/app/corescope-decrypt` in
container)
- CI: builds and tests in go-test job
- CI: attaches linux/amd64 and linux/arm64 binaries to GitHub Releases
on tags
### Testing
- `internal/channel/` — 9 tests: key derivation, encrypt/decrypt
round-trip, MAC rejection, wrong-channel rejection, plaintext parsing
- `cmd/decrypt/` — 7 tests: payload extraction, channel hash
consistency, all 3 output formats, JSON parseability, fixture DB
integration
- Verified against real fixture DB: successfully decrypts 17
`#wardriving` messages
### Limitations
- Hashtag channels only (name-derived keys). Custom PSK channels not
supported.
- No DM decryption (asymmetric, per-peer keys).
- Read-only database access.
Fixes #723
---------
Co-authored-by: you <you@example.com>
99 lines
2.9 KiB
Go
99 lines
2.9 KiB
Go
// Package channel provides MeshCore hashtag channel key derivation,
|
|
// decryption (HMAC-SHA256 MAC + AES-128-ECB), and plaintext parsing.
|
|
package channel
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"strings"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// DeriveKey derives an AES-128 key from a channel name (e.g. "#wardriving").
|
|
// Returns 16 bytes: SHA-256(channelName)[:16].
|
|
func DeriveKey(channelName string) []byte {
|
|
h := sha256.Sum256([]byte(channelName))
|
|
return h[:16]
|
|
}
|
|
|
|
// ChannelHash returns the 1-byte channel hash used as the first byte of GRP_TXT payloads.
|
|
// It is the first byte of SHA-256 of the 16-byte key.
|
|
func ChannelHash(key []byte) byte {
|
|
h := sha256.Sum256(key)
|
|
return h[0]
|
|
}
|
|
|
|
// Decrypt verifies the 2-byte HMAC-SHA256 MAC and performs AES-128-ECB decryption.
|
|
// mac must be exactly 2 bytes. ciphertext must be a multiple of 16 bytes.
|
|
// Returns the plaintext and true if MAC verification succeeded, or nil and false otherwise.
|
|
func Decrypt(key []byte, mac []byte, ciphertext []byte) ([]byte, bool) {
|
|
if len(key) != 16 || len(mac) != 2 || len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 {
|
|
return nil, false
|
|
}
|
|
|
|
// 32-byte channel secret: 16-byte key + 16 zero bytes
|
|
channelSecret := make([]byte, 32)
|
|
copy(channelSecret, key)
|
|
|
|
// Verify HMAC-SHA256 (first 2 bytes must match)
|
|
h := hmac.New(sha256.New, channelSecret)
|
|
h.Write(ciphertext)
|
|
calculatedMac := h.Sum(nil)
|
|
if calculatedMac[0] != mac[0] || calculatedMac[1] != mac[1] {
|
|
return nil, false
|
|
}
|
|
|
|
// AES-128-ECB decrypt
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
plaintext := make([]byte, len(ciphertext))
|
|
for i := 0; i < len(ciphertext); i += aes.BlockSize {
|
|
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
|
|
}
|
|
|
|
return plaintext, true
|
|
}
|
|
|
|
// ParsePlaintext parses decrypted plaintext into timestamp, sender, and message.
|
|
// Format: timestamp(4 LE) + flags(1) + "sender: message\0..."
|
|
func ParsePlaintext(plaintext []byte) (timestamp uint32, sender string, message string, err error) {
|
|
if len(plaintext) < 5 {
|
|
return 0, "", "", fmt.Errorf("plaintext too short (%d bytes)", len(plaintext))
|
|
}
|
|
|
|
timestamp = binary.LittleEndian.Uint32(plaintext[0:4])
|
|
text := string(plaintext[5:])
|
|
if idx := strings.IndexByte(text, 0); idx >= 0 {
|
|
text = text[:idx]
|
|
}
|
|
|
|
if !utf8.ValidString(text) || countNonPrintable(text) > 2 {
|
|
return 0, "", "", fmt.Errorf("decrypted text contains non-printable characters")
|
|
}
|
|
|
|
// Parse "sender: message" format
|
|
if colonIdx := strings.Index(text, ": "); colonIdx > 0 && colonIdx < 50 {
|
|
potentialSender := text[:colonIdx]
|
|
if !strings.ContainsAny(potentialSender, ":[]") {
|
|
return timestamp, potentialSender, text[colonIdx+2:], nil
|
|
}
|
|
}
|
|
|
|
return timestamp, "", text, nil
|
|
}
|
|
|
|
func countNonPrintable(s string) int {
|
|
count := 0
|
|
for _, r := range s {
|
|
if r < 32 && r != '\n' && r != '\r' && r != '\t' {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|