diff --git a/cmd/server/channel_keys.go b/cmd/server/channel_keys.go new file mode 100644 index 00000000..21ec9718 --- /dev/null +++ b/cmd/server/channel_keys.go @@ -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 +} diff --git a/cmd/server/channel_keys_test.go b/cmd/server/channel_keys_test.go new file mode 100644 index 00000000..af3e491d --- /dev/null +++ b/cmd/server/channel_keys_test.go @@ -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") + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 6c45973c..ab7079f5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) diff --git a/cmd/server/routes.go b/cmd/server/routes.go index f194bfbd..9b37c85a 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -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 { diff --git a/public/channels.js b/public/channels.js index 432ec633..317e944c 100644 --- a/public/channels.js +++ b/public/channels.js @@ -327,6 +327,13 @@
+