From aa459e3b84cc4c5d908f3781c9253848c18640c3 Mon Sep 17 00:00:00 2001 From: Gab Virebent Date: Sat, 20 Jun 2026 01:07:45 +0000 Subject: Initial public release: Nym-native anonymous submission system End-to-end verified pipeline (browser -> HTTP relay -> Nym mixnet -> reader). Client-side X25519+HKDF+AES-GCM-256, no-log blind relay, AGPL-3.0. --- cmd/nymdrop-reader/main.go | 460 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 460 insertions(+) create mode 100644 cmd/nymdrop-reader/main.go (limited to 'cmd/nymdrop-reader/main.go') diff --git a/cmd/nymdrop-reader/main.go b/cmd/nymdrop-reader/main.go new file mode 100644 index 0000000..248972e --- /dev/null +++ b/cmd/nymdrop-reader/main.go @@ -0,0 +1,460 @@ +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) +} -- cgit v1.2.3