package main import ( "crypto/aes" "crypto/cipher" "crypto/ecdh" "crypto/rand" "crypto/sha256" "golang.org/x/crypto/hkdf" "embed" "encoding/base64" "encoding/hex" "encoding/json" "flag" "fmt" "io" "io/fs" "os" "os/exec" "os/signal" "path/filepath" "runtime" "strings" "syscall" "time" "golang.org/x/net/websocket" ) //go:embed bin/nym-client-linux-amd64 var nymBin embed.FS func main() { keyFile := flag.String("key", "", "path to X25519 private key file (hex, 32 bytes); generated if missing") inboxID := flag.String("id", "nymdrop-inbox", "nym-client identity id") wsPort := flag.Int("ws-port", 1977, "nym-client websocket port") outDir := flag.String("out", "", "directory to save submissions (default: ~/nymdrop-inbox)") flag.Parse() homeDir, _ := os.UserHomeDir() if *outDir == "" { *outDir = filepath.Join(homeDir, "nymdrop-inbox") } if err := os.MkdirAll(*outDir, 0700); err != nil { fatalf("mkdir outDir: %v", err) } privKey := loadOrGenKey(*keyFile, homeDir) nymBinPath := extractNymClient() defer os.RemoveAll(filepath.Dir(nymBinPath)) configDir := filepath.Join(homeDir, ".nymdrop-reader") initNymClient(nymBinPath, *inboxID, configDir) nymCmd := startNymClient(nymBinPath, *inboxID, configDir, *wsPort) defer nymCmd.Process.Kill() wsURL := fmt.Sprintf("ws://127.0.0.1:%d", *wsPort) ws := dialWS(wsURL) defer ws.Close() // Print own Nym address so operator knows where to point sources. printSelfAddress(ws) fmt.Printf("nymdrop-reader listening — submissions saved to %s\n", *outDir) fmt.Println("Press Ctrl+C to quit.") go receiveLoop(ws, privKey, *outDir) sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) <-sig fmt.Println("\nShutting down.") } // ── key management ─────────────────────────────────────────────────────────── func loadOrGenKey(keyFile, homeDir string) *ecdh.PrivateKey { if keyFile == "" { keyFile = filepath.Join(homeDir, ".nymdrop-reader", "privkey.hex") } data, err := os.ReadFile(keyFile) if err == nil { raw, err := hex.DecodeString(strings.TrimSpace(string(data))) if err != nil { fatalf("privkey decode: %v", err) } priv, err := ecdh.X25519().NewPrivateKey(raw) if err != nil { fatalf("privkey load: %v", err) } fmt.Printf("Loaded private key from %s\n", keyFile) pubHex := hex.EncodeToString(priv.PublicKey().Bytes()) fmt.Printf("Public key (deploy in nymdrop-server): %s\n", pubHex) return priv } // Generate new key. rawPriv := make([]byte, 32) if _, err := rand.Read(rawPriv); err != nil { fatalf("keygen rand: %v", err) } priv, err := ecdh.X25519().NewPrivateKey(rawPriv) if err != nil { fatalf("keygen: %v", err) } if err := os.MkdirAll(filepath.Dir(keyFile), 0700); err != nil { fatalf("mkdir keydir: %v", err) } if err := os.WriteFile(keyFile, []byte(hex.EncodeToString(rawPriv)+"\n"), 0600); err != nil { fatalf("write privkey: %v", err) } pubHex := hex.EncodeToString(priv.PublicKey().Bytes()) fmt.Printf("Generated new private key → %s\n", keyFile) fmt.Printf("Public key (deploy in nymdrop-server): %s\n\n", pubHex) fmt.Println(" Set NYMDROP_PUBKEY_PLACEHOLDER to the public key above in static/crypto.js") fmt.Println(" and rebuild the server before accepting submissions.") return priv } // ── nym-client lifecycle ───────────────────────────────────────────────────── func extractNymClient() string { tmpDir, err := os.MkdirTemp("", "nymdrop-reader-*") if err != nil { fatalf("mktemp: %v", err) } binName := "nym-client" if runtime.GOOS == "windows" { binName += ".exe" } binPath := filepath.Join(tmpDir, binName) srcName := "bin/nym-client-linux-amd64" data, err := fs.ReadFile(nymBin, srcName) if err != nil { fatalf("embedded nym-client not found: %v", err) } if err := os.WriteFile(binPath, data, 0700); err != nil { fatalf("extract nym-client: %v", err) } return binPath } func initNymClient(binPath, id, configDir string) { // --home removed: nym-client v1.1.76+ uses ~/.nym/ fixed path cmd := exec.Command(binPath, "init", "--id", id) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr _ = cmd.Run() } func startNymClient(binPath, id, configDir string, wsPort int) *exec.Cmd { cmd := exec.Command(binPath, "run", "--id", id, "--port", fmt.Sprintf("%d", wsPort), ) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { fatalf("nym-client start: %v", err) } return cmd } // dialWS waits for the nym-client WebSocket to be ready, then returns the // open connection. Reusing the probe connection avoids the duplicate-connection // panic in nym-client (which rejects a second WS on the same port). func dialWS(wsURL string) *websocket.Conn { fmt.Print("Connecting to Nym mixnet") for i := 0; i < 120; i++ { ws, err := websocket.Dial(wsURL, "", "http://localhost/") if err == nil { fmt.Println(" ready.") return ws } fmt.Print(".") time.Sleep(500 * time.Millisecond) } fatalf("nym-client websocket did not start in time") return nil } // ── websocket message types ─────────────────────────────────────────────────── type wsReceived struct { Type string `json:"type"` Message json.RawMessage `json:"message"` SenderTag string `json:"senderTag"` } type wsSelfAddress struct { Type string `json:"type"` Address string `json:"address"` } func printSelfAddress(ws *websocket.Conn) { req := map[string]string{"type": "selfAddress"} if err := websocket.JSON.Send(ws, req); err != nil { fmt.Fprintf(os.Stderr, "selfAddress send: %v\n", err) return } var resp wsSelfAddress if err := websocket.JSON.Receive(ws, &resp); err != nil { fmt.Fprintf(os.Stderr, "selfAddress recv: %v\n", err) return } fmt.Printf("Nym inbox address: %s\n\n", resp.Address) } // ── receive loop ────────────────────────────────────────────────────────────── func receiveLoop(ws *websocket.Conn, privKey *ecdh.PrivateKey, outDir string) { debug := os.Getenv("NYMDROP_DEBUG") != "" for { // Read the raw frame regardless of opcode. websocket.Message.Receive // copies the payload of both text and binary frames into the []byte, // whereas websocket.JSON.Receive silently drops anything that is not a // well-formed JSON text frame — which is exactly how earlier received // messages were being lost. var frame []byte if err := websocket.Message.Receive(ws, &frame); err != nil { if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") { return } fmt.Fprintf(os.Stderr, "ws recv: %v\n", err) time.Sleep(500 * time.Millisecond) continue } if len(frame) == 0 { continue } if debug { preview := frame if len(preview) > 200 { preview = preview[:200] } fmt.Fprintf(os.Stderr, "[debug] frame: %d bytes, lead=0x%02x: %q\n", len(frame), frame[0], preview) } dataB64, ok := extractMessageB64(frame) if !ok { continue } plaintext, err := decryptMessage(dataB64, privKey) if err != nil { fmt.Fprintf(os.Stderr, "decrypt failed: %v\n", err) continue } saveSubmission(outDir, plaintext) } } // extractMessageB64 pulls the base64 payload string out of a nym-client // "received" frame. It tolerates the two encodings nym-client uses across // versions: text/JSON (the common case) and the native binary protocol. func extractMessageB64(frame []byte) (string, bool) { // Text/JSON frame: {"type":"received","message":"","senderTag":...} if frame[0] == '{' { var msg wsReceived if err := json.Unmarshal(frame, &msg); err != nil || msg.Type != "received" { return "", false } // message is normally a JSON string; tolerate the legacy // {"data":""} object shape just in case. var s string if err := json.Unmarshal(msg.Message, &s); err == nil { return s, true } var obj struct { Data string `json:"data"` } if err := json.Unmarshal(msg.Message, &obj); err == nil && obj.Data != "" { return obj.Data, true } return "", false } // Binary frame (nym native binary protocol): // 0x01 (Received tag) | senderTag flag(1) | [senderTag 16 bytes] | message // The message bytes are exactly what the sender transmitted — for nymdrop // that is the base64 ASCII string produced by the server. if frame[0] == 0x01 { buf := frame[1:] if len(buf) < 1 { return "", false } hasTag := buf[0] buf = buf[1:] if hasTag == 1 { if len(buf) < 16 { return "", false } buf = buf[16:] } if len(buf) == 0 { return "", false } return string(buf), true } return "", false } // ── decryption ──────────────────────────────────────────────────────────────── // decryptMessage decrypts a base64-encoded packet from the relay. // // Wire format from relay: // nonce_hex(32 ASCII chars) + ":" + ephemeral_x25519_pub(32) + iv(12) + ciphertext+tag // // The nonce prefix is stripped; it exists only to make identical submissions // look different on the Nym wire. It is not used in decryption. func decryptMessage(dataB64 string, privKey *ecdh.PrivateKey) (string, error) { raw, err := base64.StdEncoding.DecodeString(dataB64) if err != nil { return "", fmt.Errorf("base64: %w", err) } // Strip relay nonce prefix: 32 hex chars + ':' const noncePrefix = 32 + 1 // "deadbeef...:" = 33 bytes if len(raw) < noncePrefix+32+12+1 { return "", fmt.Errorf("packet too short (%d bytes)", len(raw)) } raw = raw[noncePrefix:] // Extract ephemeral public key (32 bytes X25519) ephPubBytes := raw[:32] iv := raw[32:44] ciphertext := raw[44:] ephPub, err := ecdh.X25519().NewPublicKey(ephPubBytes) if err != nil { return "", fmt.Errorf("ephemeral pubkey: %w", err) } // ECDH sharedSecret, err := privKey.ECDH(ephPub) if err != nil { return "", fmt.Errorf("ecdh: %w", err) } defer zeroBytes(sharedSecret) // HKDF-SHA-256 aesKey := make([]byte, 32) hkdfR := hkdf.New(sha256.New, sharedSecret, make([]byte, 32), []byte("nymdrop-v1")) if _, err := io.ReadFull(hkdfR, aesKey); err != nil { return "", fmt.Errorf("hkdf: %w", err) } defer zeroBytes(aesKey) // AES-GCM-256 decrypt block, err := aes.NewCipher(aesKey) if err != nil { return "", fmt.Errorf("aes: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return "", fmt.Errorf("gcm: %w", err) } plaintext, err := gcm.Open(nil, iv, ciphertext, nil) if err != nil { return "", fmt.Errorf("gcm open: %w", err) } return string(plaintext), nil } // ── storage ─────────────────────────────────────────────────────────────────── func saveSubmission(outDir, plaintext string) { ts := time.Now().UTC().Format("20060102-150405") path := filepath.Join(outDir, ts+".txt") // Avoid collision on rapid submissions. for i := 1; ; i++ { if _, err := os.Stat(path); os.IsNotExist(err) { break } path = filepath.Join(outDir, fmt.Sprintf("%s-%d.txt", ts, i)) } // Separate embedded files from text body. body := plaintext fileSection := "" if idx := strings.Index(plaintext, "\n---FILE:"); idx != -1 { body = plaintext[:idx] fileSection = plaintext[idx+1:] } if err := os.WriteFile(path, []byte(body), 0600); err != nil { fmt.Fprintf(os.Stderr, "save submission: %v\n", err) return } fmt.Printf("\n[%s] New submission → %s\n", ts, path) preview := body if len(preview) > 200 { preview = preview[:200] + "..." } fmt.Printf(" %s\n", strings.ReplaceAll(strings.TrimSpace(preview), "\n", "\n ")) // Save attached files. if fileSection != "" { saveAttachments(outDir, ts, fileSection) } } func saveAttachments(outDir, ts, fileSection string) { lines := strings.Split(fileSection, "\n") var currentName string var currentData strings.Builder flush := func() { if currentName == "" { return } raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(currentData.String())) if err != nil { fmt.Fprintf(os.Stderr, " attachment decode %q: %v\n", currentName, err) return } safeName := filepath.Base(filepath.Clean(currentName)) attPath := filepath.Join(outDir, ts+"-"+safeName) if err := os.WriteFile(attPath, raw, 0600); err != nil { fmt.Fprintf(os.Stderr, " save attachment %q: %v\n", currentName, err) return } fmt.Printf(" attachment → %s (%d bytes)\n", attPath, len(raw)) currentName = "" currentData.Reset() } for _, line := range lines { if strings.HasPrefix(line, "---FILE:") && strings.HasSuffix(line, "---") { flush() currentName = strings.TrimSuffix(strings.TrimPrefix(line, "---FILE:"), "---") } else if currentName != "" { currentData.WriteString(line) } } flush() } // ── helpers ─────────────────────────────────────────────────────────────────── func zeroBytes(b []byte) { for i := range b { b[i] = 0 } } func fatalf(format string, args ...any) { fmt.Fprintf(os.Stderr, "nymdrop-reader: "+format+"\n", args...) os.Exit(1) }