mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-01 20:09:19 +00:00
feat: add hashtag channels from web UI (#725 M2)
- POST /api/channels/keys endpoint: accepts channel name, derives key
via SHA256('#name')[:16], adds to runtime key manager
- GET /api/channels/keys: lists user-added channel keys
- DELETE /api/channels/keys/{name}: removes a user-added key
- Persistence: user_channel_keys SQLite table, loaded on startup
- Retroactive decryption: background goroutine scans undecrypted
GRP_TXT packets and decrypts with new key
- Frontend: add channel input in channels sidebar with feedback
- PSK support: optional hex key parameter for non-hashtag channels
- Tests: key derivation, CRUD, API handlers, persistence, validation
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// UserChannelKey represents a user-added channel key stored in SQLite.
|
||||
type UserChannelKey struct {
|
||||
Name string `json:"name"`
|
||||
KeyHex string `json:"key"`
|
||||
Source string `json:"source"` // "hashtag" or "psk"
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// ChannelKeyManager manages runtime channel decryption keys.
|
||||
// Keys come from two sources: config (loaded by ingestor) and user-added (via API).
|
||||
type ChannelKeyManager struct {
|
||||
mu sync.RWMutex
|
||||
keys map[string]string // name → hex key
|
||||
}
|
||||
|
||||
// NewChannelKeyManager creates a new empty key manager.
|
||||
func NewChannelKeyManager() *ChannelKeyManager {
|
||||
return &ChannelKeyManager{keys: make(map[string]string)}
|
||||
}
|
||||
|
||||
// AddKey adds or updates a channel key.
|
||||
func (m *ChannelKeyManager) AddKey(name, keyHex string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.keys[name] = keyHex
|
||||
}
|
||||
|
||||
// GetKeys returns a snapshot of all keys.
|
||||
func (m *ChannelKeyManager) GetKeys() map[string]string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
cp := make(map[string]string, len(m.keys))
|
||||
for k, v := range m.keys {
|
||||
cp[k] = v
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
// RemoveKey removes a channel key by name. Returns true if it existed.
|
||||
func (m *ChannelKeyManager) RemoveKey(name string) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
_, existed := m.keys[name]
|
||||
delete(m.keys, name)
|
||||
return existed
|
||||
}
|
||||
|
||||
// deriveHashtagKey derives an AES-128 key from a hashtag channel name.
|
||||
// SHA-256(name)[:16] — name must include the # prefix.
|
||||
func deriveHashtagKey(channelName string) string {
|
||||
h := sha256.Sum256([]byte(channelName))
|
||||
return hex.EncodeToString(h[:16])
|
||||
}
|
||||
|
||||
// ensureUserChannelKeysTable creates the user_channel_keys table if it doesn't exist.
|
||||
func ensureUserChannelKeysTable(dbPath string) error {
|
||||
rw, err := openRW(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open RW for user_channel_keys: %w", err)
|
||||
}
|
||||
defer rw.Close()
|
||||
|
||||
_, err = rw.Exec(`CREATE TABLE IF NOT EXISTS user_channel_keys (
|
||||
name TEXT PRIMARY KEY,
|
||||
key_hex TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'hashtag',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`)
|
||||
return err
|
||||
}
|
||||
|
||||
// loadUserChannelKeys loads all user-added channel keys from SQLite.
|
||||
func loadUserChannelKeys(db *DB) ([]UserChannelKey, error) {
|
||||
rows, err := db.conn.Query(`SELECT name, key_hex, source, created_at FROM user_channel_keys ORDER BY created_at`)
|
||||
if err != nil {
|
||||
// Table might not exist yet — not an error
|
||||
if strings.Contains(err.Error(), "no such table") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var keys []UserChannelKey
|
||||
for rows.Next() {
|
||||
var k UserChannelKey
|
||||
if err := rows.Scan(&k.Name, &k.KeyHex, &k.Source, &k.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// saveUserChannelKey persists a user-added channel key to SQLite.
|
||||
func saveUserChannelKey(dbPath string, key UserChannelKey) error {
|
||||
rw, err := openRW(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rw.Close()
|
||||
|
||||
_, err = rw.Exec(
|
||||
`INSERT OR REPLACE INTO user_channel_keys (name, key_hex, source) VALUES (?, ?, ?)`,
|
||||
key.Name, key.KeyHex, key.Source,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// deleteUserChannelKey removes a user-added channel key from SQLite.
|
||||
func deleteUserChannelKey(dbPath string, name string) error {
|
||||
rw, err := openRW(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rw.Close()
|
||||
|
||||
_, err = rw.Exec(`DELETE FROM user_channel_keys WHERE name = ?`, name)
|
||||
return err
|
||||
}
|
||||
|
||||
// channelDecryptResult holds the result of decrypting a GRP_TXT message.
|
||||
type channelDecryptResult struct {
|
||||
Timestamp uint32
|
||||
Flags byte
|
||||
Sender string
|
||||
Message string
|
||||
}
|
||||
|
||||
// decryptChannelMessage decrypts a GRP_TXT payload using a channel key.
|
||||
// Implements MeshCore channel decryption: HMAC-SHA256 MAC verification + AES-128-ECB.
|
||||
func decryptChannelMessage(ciphertextHex, macHex, channelKeyHex string) (*channelDecryptResult, error) {
|
||||
channelKey, err := hex.DecodeString(channelKeyHex)
|
||||
if err != nil || len(channelKey) != 16 {
|
||||
return nil, fmt.Errorf("invalid channel key")
|
||||
}
|
||||
|
||||
macBytes, err := hex.DecodeString(macHex)
|
||||
if err != nil || len(macBytes) != 2 {
|
||||
return nil, fmt.Errorf("invalid MAC")
|
||||
}
|
||||
|
||||
ciphertext, err := hex.DecodeString(ciphertextHex)
|
||||
if err != nil || len(ciphertext) == 0 {
|
||||
return nil, fmt.Errorf("invalid ciphertext")
|
||||
}
|
||||
|
||||
// 32-byte channel secret: 16-byte key + 16 zero bytes
|
||||
channelSecret := make([]byte, 32)
|
||||
copy(channelSecret, channelKey)
|
||||
|
||||
// Verify HMAC-SHA256 (first 2 bytes must match provided MAC)
|
||||
h := hmac.New(sha256.New, channelSecret)
|
||||
h.Write(ciphertext)
|
||||
calculatedMac := h.Sum(nil)
|
||||
if calculatedMac[0] != macBytes[0] || calculatedMac[1] != macBytes[1] {
|
||||
return nil, fmt.Errorf("MAC verification failed")
|
||||
}
|
||||
|
||||
// AES-128-ECB decrypt (block-by-block, no padding)
|
||||
if len(ciphertext)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("ciphertext not aligned to AES block size")
|
||||
}
|
||||
block, err := aes.NewCipher(channelKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AES cipher: %w", err)
|
||||
}
|
||||
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])
|
||||
}
|
||||
|
||||
// Parse: timestamp(4 LE) + flags(1) + message(UTF-8, null-terminated)
|
||||
if len(plaintext) < 5 {
|
||||
return nil, fmt.Errorf("decrypted content too short")
|
||||
}
|
||||
timestamp := binary.LittleEndian.Uint32(plaintext[0:4])
|
||||
flags := plaintext[4]
|
||||
messageText := string(plaintext[5:])
|
||||
if idx := strings.IndexByte(messageText, 0); idx >= 0 {
|
||||
messageText = messageText[:idx]
|
||||
}
|
||||
|
||||
// Validate decrypted text is printable UTF-8 (not binary garbage)
|
||||
if !utf8.ValidString(messageText) || countNonPrintable(messageText) > 2 {
|
||||
return nil, fmt.Errorf("decrypted text contains non-printable characters")
|
||||
}
|
||||
|
||||
result := &channelDecryptResult{Timestamp: timestamp, Flags: flags}
|
||||
|
||||
// Parse "sender: message" format
|
||||
colonIdx := strings.Index(messageText, ": ")
|
||||
if colonIdx > 0 && colonIdx < 50 {
|
||||
potentialSender := messageText[:colonIdx]
|
||||
if !strings.ContainsAny(potentialSender, ":[]") {
|
||||
result.Sender = potentialSender
|
||||
result.Message = messageText[colonIdx+2:]
|
||||
} else {
|
||||
result.Message = messageText
|
||||
}
|
||||
} else {
|
||||
result.Message = messageText
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// retroactiveDecrypt scans undecrypted GRP_TXT packets and attempts decryption
|
||||
// with the given channel name and key. Returns the number of packets decrypted.
|
||||
func retroactiveDecrypt(dbPath string, readDB *DB, channelName, keyHex string) (int, error) {
|
||||
// Query undecrypted GRP_TXT packets (payload_type=5 for GRP_TXT)
|
||||
rows, err := readDB.conn.Query(`
|
||||
SELECT id, decoded_json FROM transmissions
|
||||
WHERE payload_type = 5
|
||||
AND (decoded_json IS NULL
|
||||
OR decoded_json NOT LIKE '%"decryptionStatus":"decrypted"%')
|
||||
ORDER BY first_seen DESC
|
||||
LIMIT 10000
|
||||
`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("query undecrypted packets: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type candidate struct {
|
||||
id int64
|
||||
decodedJSON sql.NullString
|
||||
}
|
||||
var candidates []candidate
|
||||
for rows.Next() {
|
||||
var c candidate
|
||||
if err := rows.Scan(&c.id, &c.decodedJSON); err != nil {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, c)
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Open RW connection for updates
|
||||
rw, err := openRW(dbPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open RW for retroactive decrypt: %w", err)
|
||||
}
|
||||
defer rw.Close()
|
||||
|
||||
decrypted := 0
|
||||
for _, c := range candidates {
|
||||
if !c.decodedJSON.Valid || c.decodedJSON.String == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var decoded map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(c.decodedJSON.String), &decoded); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the encrypted data and MAC from the decoded JSON
|
||||
encData, _ := decoded["encryptedData"].(string)
|
||||
mac, _ := decoded["mac"].(string)
|
||||
if encData == "" || mac == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if already decrypted
|
||||
if status, _ := decoded["decryptionStatus"].(string); status == "decrypted" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Attempt decryption
|
||||
result, err := decryptChannelMessage(encData, mac, keyHex)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build updated decoded JSON
|
||||
text := result.Message
|
||||
if result.Sender != "" && result.Message != "" {
|
||||
text = result.Sender + ": " + result.Message
|
||||
}
|
||||
decoded["type"] = "CHAN"
|
||||
decoded["channel"] = channelName
|
||||
decoded["decryptionStatus"] = "decrypted"
|
||||
decoded["sender"] = result.Sender
|
||||
decoded["text"] = text
|
||||
decoded["sender_timestamp"] = result.Timestamp
|
||||
|
||||
updatedJSON, err := json.Marshal(decoded)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = rw.Exec(`UPDATE transmissions SET decoded_json = ? WHERE id = ?`, string(updatedJSON), c.id)
|
||||
if err != nil {
|
||||
log.Printf("[channels] retroactive decrypt update failed for id=%d: %v", c.id, err)
|
||||
continue
|
||||
}
|
||||
decrypted++
|
||||
}
|
||||
|
||||
return decrypted, nil
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func TestDeriveHashtagKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want string
|
||||
}{
|
||||
{"#General", "649af2cab73ed5a890890a5485a0c004"},
|
||||
{"#test", "9cd8fcf22a47333b591d96a2b848b73f"},
|
||||
{"#MeshCore", "dcf73f393fa217f6b28fcec6ffc411ad"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := deriveHashtagKey(tt.name)
|
||||
if got != tt.want {
|
||||
t.Errorf("deriveHashtagKey(%q) = %q, want %q", tt.name, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelKeyManager(t *testing.T) {
|
||||
m := NewChannelKeyManager()
|
||||
|
||||
// Add keys
|
||||
m.AddKey("#test", "abc123")
|
||||
m.AddKey("#other", "def456")
|
||||
|
||||
keys := m.GetKeys()
|
||||
if len(keys) != 2 {
|
||||
t.Fatalf("expected 2 keys, got %d", len(keys))
|
||||
}
|
||||
if keys["#test"] != "abc123" {
|
||||
t.Errorf("expected #test key abc123, got %s", keys["#test"])
|
||||
}
|
||||
|
||||
// Remove key
|
||||
existed := m.RemoveKey("#test")
|
||||
if !existed {
|
||||
t.Error("expected RemoveKey to return true")
|
||||
}
|
||||
existed = m.RemoveKey("#nonexistent")
|
||||
if existed {
|
||||
t.Error("expected RemoveKey to return false for nonexistent key")
|
||||
}
|
||||
|
||||
keys = m.GetKeys()
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("expected 1 key after removal, got %d", len(keys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserChannelKeysPersistence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create the DB file with minimum schema
|
||||
createTestDB(t, dbPath)
|
||||
|
||||
// Ensure table
|
||||
if err := ensureUserChannelKeysTable(dbPath); err != nil {
|
||||
t.Fatalf("ensureUserChannelKeysTable: %v", err)
|
||||
}
|
||||
|
||||
// Save a key
|
||||
key := UserChannelKey{Name: "#test", KeyHex: "abc123", Source: "hashtag"}
|
||||
if err := saveUserChannelKey(dbPath, key); err != nil {
|
||||
t.Fatalf("saveUserChannelKey: %v", err)
|
||||
}
|
||||
|
||||
// Load keys via read-only connection
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
keys, err := loadUserChannelKeys(db)
|
||||
if err != nil {
|
||||
t.Fatalf("loadUserChannelKeys: %v", err)
|
||||
}
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("expected 1 key, got %d", len(keys))
|
||||
}
|
||||
if keys[0].Name != "#test" || keys[0].KeyHex != "abc123" || keys[0].Source != "hashtag" {
|
||||
t.Errorf("unexpected key: %+v", keys[0])
|
||||
}
|
||||
|
||||
// Delete key
|
||||
if err := deleteUserChannelKey(dbPath, "#test"); err != nil {
|
||||
t.Fatalf("deleteUserChannelKey: %v", err)
|
||||
}
|
||||
|
||||
keys, err = loadUserChannelKeys(db)
|
||||
if err != nil {
|
||||
t.Fatalf("loadUserChannelKeys after delete: %v", err)
|
||||
}
|
||||
if len(keys) != 0 {
|
||||
t.Errorf("expected 0 keys after delete, got %d", len(keys))
|
||||
}
|
||||
}
|
||||
|
||||
func createTestDB(t *testing.T, dbPath string) {
|
||||
t.Helper()
|
||||
rw, err := openRW(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("openRW: %v", err)
|
||||
}
|
||||
defer rw.Close()
|
||||
|
||||
// Minimal schema for the server's OpenDB to work
|
||||
_, err = rw.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS transmissions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
raw_hex TEXT NOT NULL,
|
||||
hash TEXT,
|
||||
first_seen TEXT,
|
||||
route_type INTEGER,
|
||||
payload_type INTEGER,
|
||||
payload_version INTEGER,
|
||||
decoded_json TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS observations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
transmission_id INTEGER,
|
||||
observer_idx INTEGER,
|
||||
timestamp TEXT,
|
||||
snr REAL,
|
||||
rssi REAL,
|
||||
hops INTEGER,
|
||||
path_json TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
public_key TEXT PRIMARY KEY,
|
||||
short_name TEXT,
|
||||
long_name TEXT,
|
||||
role TEXT,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
last_seen TEXT,
|
||||
advert_count INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS observers (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT,
|
||||
iata TEXT
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create schema: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAddChannelKeyAPI(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
createTestDB(t, dbPath)
|
||||
if err := ensureUserChannelKeysTable(dbPath); err != nil {
|
||||
t.Fatalf("ensureUserChannelKeysTable: %v", err)
|
||||
}
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
srv := NewServer(db, &Config{}, nil)
|
||||
|
||||
// Test adding a hashtag channel
|
||||
body := `{"name":"#TestChannel"}`
|
||||
req := httptest.NewRequest("POST", "/api/channels/keys", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleAddChannelKey(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["name"] != "#TestChannel" {
|
||||
t.Errorf("expected name #TestChannel, got %v", resp["name"])
|
||||
}
|
||||
if resp["source"] != "hashtag" {
|
||||
t.Errorf("expected source hashtag, got %v", resp["source"])
|
||||
}
|
||||
expectedKey := deriveHashtagKey("#TestChannel")
|
||||
if resp["key"] != expectedKey {
|
||||
t.Errorf("expected key %s, got %v", expectedKey, resp["key"])
|
||||
}
|
||||
|
||||
// Verify it's in the runtime key manager
|
||||
keys := srv.channelKeys.GetKeys()
|
||||
if keys["#TestChannel"] != expectedKey {
|
||||
t.Errorf("key not in runtime manager")
|
||||
}
|
||||
|
||||
// Verify persistence
|
||||
loaded, err := loadUserChannelKeys(db)
|
||||
if err != nil {
|
||||
t.Fatalf("loadUserChannelKeys: %v", err)
|
||||
}
|
||||
if len(loaded) != 1 || loaded[0].Name != "#TestChannel" {
|
||||
t.Errorf("expected persisted key, got %+v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAddChannelKeyAutoPrefix(t *testing.T) {
|
||||
srv := NewServer(nil, &Config{}, nil)
|
||||
|
||||
body := `{"name":"NoHash"}`
|
||||
req := httptest.NewRequest("POST", "/api/channels/keys", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleAddChannelKey(w, req)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["name"] != "#NoHash" {
|
||||
t.Errorf("expected auto-prefixed name #NoHash, got %v", resp["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAddChannelKeyValidation(t *testing.T) {
|
||||
srv := NewServer(nil, &Config{}, nil)
|
||||
|
||||
// Empty name
|
||||
body := `{"name":""}`
|
||||
req := httptest.NewRequest("POST", "/api/channels/keys", bytes.NewBufferString(body))
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleAddChannelKey(w, req)
|
||||
if w.Code != 400 {
|
||||
t.Errorf("expected 400 for empty name, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Invalid key length
|
||||
body = `{"name":"#test","key":"abc"}`
|
||||
req = httptest.NewRequest("POST", "/api/channels/keys", bytes.NewBufferString(body))
|
||||
w = httptest.NewRecorder()
|
||||
srv.handleAddChannelKey(w, req)
|
||||
if w.Code != 400 {
|
||||
t.Errorf("expected 400 for short key, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleListChannelKeys(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
createTestDB(t, dbPath)
|
||||
ensureUserChannelKeysTable(dbPath)
|
||||
saveUserChannelKey(dbPath, UserChannelKey{Name: "#a", KeyHex: "aaaa", Source: "hashtag"})
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
srv := NewServer(db, &Config{}, nil)
|
||||
req := httptest.NewRequest("GET", "/api/channels/keys", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleListChannelKeys(w, req)
|
||||
|
||||
var resp struct {
|
||||
Keys []UserChannelKey `json:"keys"`
|
||||
}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if len(resp.Keys) != 1 || resp.Keys[0].Name != "#a" {
|
||||
t.Errorf("unexpected keys response: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDeleteChannelKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
createTestDB(t, dbPath)
|
||||
ensureUserChannelKeysTable(dbPath)
|
||||
saveUserChannelKey(dbPath, UserChannelKey{Name: "#del", KeyHex: "bbbb", Source: "hashtag"})
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
srv := NewServer(db, &Config{}, nil)
|
||||
srv.channelKeys.AddKey("#del", "bbbb")
|
||||
|
||||
// Use mux to inject path vars
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/api/channels/keys/{name}", srv.handleDeleteChannelKey).Methods("DELETE")
|
||||
|
||||
req := httptest.NewRequest("DELETE", "/api/channels/keys/del", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify removed from runtime
|
||||
keys := srv.channelKeys.GetKeys()
|
||||
if _, exists := keys["#del"]; exists {
|
||||
t.Error("key should have been removed from runtime manager")
|
||||
}
|
||||
|
||||
// Verify removed from DB
|
||||
loaded, _ := loadUserChannelKeys(db)
|
||||
if len(loaded) != 0 {
|
||||
t.Errorf("expected 0 keys after delete, got %d", len(loaded))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptChannelMessageServer(t *testing.T) {
|
||||
// Verify key derivation matches ingestor
|
||||
key := deriveHashtagKey("#General")
|
||||
if key != "649af2cab73ed5a890890a5485a0c004" {
|
||||
t.Fatalf("unexpected #General key: %s", key)
|
||||
}
|
||||
// Verify the function handles invalid inputs gracefully
|
||||
_, err := decryptChannelMessage("", "aabb", key)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty ciphertext")
|
||||
}
|
||||
_, err = decryptChannelMessage("aabbccdd", "", key)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty MAC")
|
||||
}
|
||||
_, err = decryptChannelMessage("aabbccdd", "aabb", "short")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid key")
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the test fixture DB path resolver works
|
||||
func TestFixtureDBExists(t *testing.T) {
|
||||
// This just verifies our test helper creates a usable DB
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
createTestDB(t, dbPath)
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
t.Fatal("test DB was not created")
|
||||
}
|
||||
}
|
||||
@@ -228,6 +228,23 @@ func main() {
|
||||
// HTTP server
|
||||
srv := NewServer(database, cfg, hub)
|
||||
srv.store = store
|
||||
|
||||
// Initialize user channel keys table and load persisted keys
|
||||
if err := ensureUserChannelKeysTable(dbPath); err != nil {
|
||||
log.Printf("[channels] warning: could not create user_channel_keys table: %v", err)
|
||||
}
|
||||
if database != nil {
|
||||
userKeys, err := loadUserChannelKeys(database)
|
||||
if err != nil {
|
||||
log.Printf("[channels] warning: could not load user channel keys: %v", err)
|
||||
} else if len(userKeys) > 0 {
|
||||
for _, k := range userKeys {
|
||||
srv.channelKeys.AddKey(k.Name, k.KeyHex)
|
||||
}
|
||||
log.Printf("[channels] loaded %d user channel keys", len(userKeys))
|
||||
}
|
||||
}
|
||||
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
|
||||
+132
-8
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -46,6 +47,9 @@ type Server struct {
|
||||
|
||||
// Router reference for OpenAPI spec generation
|
||||
router *mux.Router
|
||||
|
||||
// Channel key manager for runtime decryption keys
|
||||
channelKeys *ChannelKeyManager
|
||||
}
|
||||
|
||||
// PerfStats tracks request performance.
|
||||
@@ -75,14 +79,15 @@ func NewPerfStats() *PerfStats {
|
||||
|
||||
func NewServer(db *DB, cfg *Config, hub *Hub) *Server {
|
||||
return &Server{
|
||||
db: db,
|
||||
cfg: cfg,
|
||||
hub: hub,
|
||||
startedAt: time.Now(),
|
||||
perfStats: NewPerfStats(),
|
||||
version: resolveVersion(),
|
||||
commit: resolveCommit(),
|
||||
buildTime: resolveBuildTime(),
|
||||
db: db,
|
||||
cfg: cfg,
|
||||
hub: hub,
|
||||
startedAt: time.Now(),
|
||||
perfStats: NewPerfStats(),
|
||||
version: resolveVersion(),
|
||||
commit: resolveCommit(),
|
||||
buildTime: resolveBuildTime(),
|
||||
channelKeys: NewChannelKeyManager(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +165,9 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
|
||||
// Other endpoints
|
||||
r.HandleFunc("/api/resolve-hops", s.handleResolveHops).Methods("GET")
|
||||
r.HandleFunc("/api/channels/keys", s.handleAddChannelKey).Methods("POST")
|
||||
r.HandleFunc("/api/channels/keys", s.handleListChannelKeys).Methods("GET")
|
||||
r.HandleFunc("/api/channels/keys/{name}", s.handleDeleteChannelKey).Methods("DELETE")
|
||||
r.HandleFunc("/api/channels/{hash}/messages", s.handleChannelMessages).Methods("GET")
|
||||
r.HandleFunc("/api/channels", s.handleChannels).Methods("GET")
|
||||
r.HandleFunc("/api/observers/metrics/summary", s.handleMetricsSummary).Methods("GET")
|
||||
@@ -1697,6 +1705,122 @@ func (s *Server) handleChannelMessages(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, ChannelMessagesResponse{Messages: []map[string]interface{}{}, Total: 0})
|
||||
}
|
||||
|
||||
// handleAddChannelKey adds a new channel key (hashtag-derived or PSK).
|
||||
func (s *Server) handleAddChannelKey(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key,omitempty"` // optional hex PSK
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, 400, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
writeError(w, 400, "name is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure hashtag prefix
|
||||
if !strings.HasPrefix(name, "#") {
|
||||
name = "#" + name
|
||||
}
|
||||
|
||||
var keyHex string
|
||||
source := "hashtag"
|
||||
if req.Key != "" {
|
||||
// Validate hex key (must be 32 hex chars = 16 bytes)
|
||||
if len(req.Key) != 32 {
|
||||
writeError(w, 400, "key must be 32 hex characters (16 bytes)")
|
||||
return
|
||||
}
|
||||
if _, err := hex.DecodeString(req.Key); err != nil {
|
||||
writeError(w, 400, "key must be valid hex")
|
||||
return
|
||||
}
|
||||
keyHex = req.Key
|
||||
source = "psk"
|
||||
} else {
|
||||
keyHex = deriveHashtagKey(name)
|
||||
}
|
||||
|
||||
// Add to runtime key manager
|
||||
s.channelKeys.AddKey(name, keyHex)
|
||||
|
||||
// Persist to SQLite
|
||||
if s.db != nil {
|
||||
if err := saveUserChannelKey(s.db.path, UserChannelKey{
|
||||
Name: name,
|
||||
KeyHex: keyHex,
|
||||
Source: source,
|
||||
}); err != nil {
|
||||
log.Printf("[channels] failed to persist key for %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Retroactive decryption in background
|
||||
if s.db != nil {
|
||||
go func() {
|
||||
count, err := retroactiveDecrypt(s.db.path, s.db, name, keyHex)
|
||||
if err != nil {
|
||||
log.Printf("[channels] retroactive decrypt error: %v", err)
|
||||
} else if count > 0 {
|
||||
log.Printf("[channels] retroactively decrypted %d packets for %s", count, name)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"name": name,
|
||||
"key": keyHex,
|
||||
"source": source,
|
||||
"message": fmt.Sprintf("Added %s — decrypting matching messages...", name),
|
||||
})
|
||||
}
|
||||
|
||||
// handleListChannelKeys returns all user-added channel keys.
|
||||
func (s *Server) handleListChannelKeys(w http.ResponseWriter, r *http.Request) {
|
||||
if s.db == nil {
|
||||
writeJSON(w, map[string]interface{}{"keys": []UserChannelKey{}})
|
||||
return
|
||||
}
|
||||
keys, err := loadUserChannelKeys(s.db)
|
||||
if err != nil {
|
||||
writeError(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if keys == nil {
|
||||
keys = []UserChannelKey{}
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"keys": keys})
|
||||
}
|
||||
|
||||
// handleDeleteChannelKey removes a user-added channel key.
|
||||
func (s *Server) handleDeleteChannelKey(w http.ResponseWriter, r *http.Request) {
|
||||
name := mux.Vars(r)["name"]
|
||||
if name == "" {
|
||||
writeError(w, 400, "name is required")
|
||||
return
|
||||
}
|
||||
|
||||
// URL-decode: the name may contain # which is URL-encoded
|
||||
if !strings.HasPrefix(name, "#") {
|
||||
name = "#" + name
|
||||
}
|
||||
|
||||
s.channelKeys.RemoveKey(name)
|
||||
|
||||
if s.db != nil {
|
||||
if err := deleteUserChannelKey(s.db.path, name); err != nil {
|
||||
log.Printf("[channels] failed to delete key for %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "name": name})
|
||||
}
|
||||
|
||||
func (s *Server) handleObservers(w http.ResponseWriter, r *http.Request) {
|
||||
observers, err := s.db.GetObservers()
|
||||
if err != nil {
|
||||
|
||||
@@ -327,6 +327,13 @@
|
||||
<div class="ch-sidebar-header">
|
||||
<div class="ch-sidebar-title"><span class="ch-icon">💬</span> Channels</div>
|
||||
</div>
|
||||
<div class="ch-add-channel" id="chAddChannel">
|
||||
<form id="chAddForm" class="ch-add-form" autocomplete="off">
|
||||
<input type="text" id="chAddInput" class="ch-add-input" placeholder="Enter #channelname" aria-label="Add channel" />
|
||||
<button type="submit" class="ch-add-btn" title="Add channel">+</button>
|
||||
</form>
|
||||
<div id="chAddFeedback" class="ch-add-feedback" style="display:none"></div>
|
||||
</div>
|
||||
<div id="chRegionFilter" class="region-filter-container" style="padding:0 8px"></div>
|
||||
<div class="ch-channel-list" id="chList" role="listbox" aria-label="Channels">
|
||||
<div class="ch-loading">Loading channels…</div>
|
||||
@@ -355,6 +362,47 @@
|
||||
});
|
||||
|
||||
loadObserverRegions();
|
||||
|
||||
// Add channel form handler
|
||||
(function () {
|
||||
var form = document.getElementById('chAddForm');
|
||||
var input = document.getElementById('chAddInput');
|
||||
var feedback = document.getElementById('chAddFeedback');
|
||||
if (!form) return;
|
||||
form.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
var val = (input.value || '').trim();
|
||||
if (!val) return;
|
||||
// Auto-add # prefix if missing
|
||||
if (val.charAt(0) !== '#') val = '#' + val;
|
||||
feedback.style.display = 'block';
|
||||
feedback.textContent = 'Adding ' + val + '…';
|
||||
feedback.className = 'ch-add-feedback';
|
||||
try {
|
||||
var resp = await fetch('/api/channels/keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: val })
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
feedback.textContent = data.error || 'Failed to add channel';
|
||||
feedback.className = 'ch-add-feedback ch-add-error';
|
||||
return;
|
||||
}
|
||||
feedback.textContent = data.message || ('Added ' + val);
|
||||
feedback.className = 'ch-add-feedback ch-add-success';
|
||||
input.value = '';
|
||||
// Refresh channel list after a short delay (retroactive decryption is async)
|
||||
setTimeout(function () { loadChannels(); }, 1500);
|
||||
setTimeout(function () { feedback.style.display = 'none'; }, 4000);
|
||||
} catch (err) {
|
||||
feedback.textContent = 'Network error';
|
||||
feedback.className = 'ch-add-feedback ch-add-error';
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
loadChannels().then(async function () {
|
||||
if (routeParam) await selectChannel(routeParam);
|
||||
if (_pendingNode && _pendingNode.length < 200) await showNodeDetail(_pendingNode);
|
||||
|
||||
@@ -460,6 +460,22 @@ fieldset.mc-section legend.mc-label { padding: 0; }
|
||||
.ch-sidebar-header {
|
||||
padding: 14px 16px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.ch-add-channel { padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||
.ch-add-form { display: flex; gap: 4px; }
|
||||
.ch-add-input {
|
||||
flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px;
|
||||
font-size: 13px; background: var(--input-bg, var(--bg)); color: var(--text); font-family: var(--font);
|
||||
}
|
||||
.ch-add-input::placeholder { color: var(--text-secondary, #999); }
|
||||
.ch-add-btn {
|
||||
padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--accent, #4a9eff); color: #fff; font-size: 16px; font-weight: 700;
|
||||
cursor: pointer; line-height: 1;
|
||||
}
|
||||
.ch-add-btn:hover { opacity: 0.85; }
|
||||
.ch-add-feedback { font-size: 12px; padding: 4px 0 0; color: var(--text-secondary, #999); }
|
||||
.ch-add-feedback.ch-add-success { color: var(--success, #4caf50); }
|
||||
.ch-add-feedback.ch-add-error { color: var(--error, #f44336); }
|
||||
.ch-sidebar-title {
|
||||
display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 700; margin-bottom: 8px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user