summaryrefslogtreecommitdiffstats
path: root/cmd/nymdrop-reader/main.go
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/nymdrop-reader/main.go')
-rw-r--r--cmd/nymdrop-reader/main.go476
1 files changed, 46 insertions, 430 deletions
diff --git a/cmd/nymdrop-reader/main.go b/cmd/nymdrop-reader/main.go
index 8e235d9..4582768 100644
--- a/cmd/nymdrop-reader/main.go
+++ b/cmd/nymdrop-reader/main.go
@@ -1,464 +1,80 @@
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"
- "nymdrop/internal/nym"
"os"
- "os/exec"
"os/signal"
"path/filepath"
- "runtime"
"strings"
"syscall"
- "time"
- "github.com/awnumar/memguard"
- "golang.org/x/net/websocket"
+ "nymdrop/internal/reader"
)
-//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)")
+ wsPort := flag.Int("ws-port", 1977, "nym-client websocket port")
+ outDir := flag.String("out", "", "directory to save submissions (default: ~/nymdrop-inbox, or <data-dir>/nymdrop-inbox)")
+ dataDir := flag.String("data-dir", "", "self-contained data directory (key, nym-client state, inbox all live here instead of $HOME — for portable/USB use, no trace left on the host)")
+ guiMode := flag.Bool("gui", false, "launch the graphical reader instead of the headless/CLI mode")
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.")
- // Safety net: wipes any locked buffer still alive (e.g. one in flight in
- // receiveLoop when the signal arrived) on top of the deferred Destroy
- // calls that cover the normal path.
- memguard.Purge()
-}
-
-// ── 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)
- }
- // raw is wiped as soon as it's copied into locked, non-swappable memory;
- // ecdh.NewPrivateKey takes its own copy, so the guarded buffer can be
- // destroyed right after construction.
- keyBuf := memguard.NewBufferFromBytes(raw)
- priv, err := ecdh.X25519().NewPrivateKey(keyBuf.Bytes())
- keyBuf.Destroy()
- 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)
- }
- keyBuf := memguard.NewBufferFromBytes(rawPriv) // wipes rawPriv on copy
- priv, err := ecdh.X25519().NewPrivateKey(keyBuf.Bytes())
- if err != nil {
- keyBuf.Destroy()
- fatalf("keygen: %v", err)
- }
- if err := os.MkdirAll(filepath.Dir(keyFile), 0700); err != nil {
- keyBuf.Destroy()
- fatalf("mkdir keydir: %v", err)
- }
- if err := os.WriteFile(keyFile, []byte(hex.EncodeToString(keyBuf.Bytes())+"\n"), 0600); err != nil {
- keyBuf.Destroy()
- fatalf("write privkey: %v", err)
- }
- keyBuf.Destroy()
- 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)
+ cfg := reader.Config{
+ KeyFile: *keyFile,
+ InboxID: *inboxID,
+ WSPort: *wsPort,
+ OutDir: *outDir,
+ DataDir: *dataDir,
+ Debug: os.Getenv("NYMDROP_DEBUG") != "",
}
- 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"`
-}
-
-// printSelfAddress queries the inbox address using nym-client's binary
-// protocol rather than JSON. This is deliberate, not cosmetic: nym-client
-// picks text-vs-binary for every later push ("received" events) based on
-// the format of the *last* request it saw on this connection (see
-// clients/native/src/websocket/handler.rs, ReceivedResponseType). Submission
-// payloads are raw binary crypto material with no base64 wrapping (see
-// Client.Send in internal/nym) — if this connection stayed in JSON/text
-// mode, nym-client would run String::from_utf8_lossy over that binary data
-// before handing it to us, silently corrupting it. Querying the self
-// address in binary form here locks the connection into binary mode for
-// the lifetime of receiveLoop below.
-func printSelfAddress(ws *websocket.Conn) {
- if err := websocket.Message.Send(ws, nym.EncodeSelfAddressRequest()); err != nil {
- fmt.Fprintf(os.Stderr, "selfAddress send: %v\n", err)
- return
- }
- var frame []byte
- if err := websocket.Message.Receive(ws, &frame); err != nil {
- fmt.Fprintf(os.Stderr, "selfAddress recv: %v\n", err)
+ if *guiMode {
+ runGUI(cfg)
return
}
- addr, err := nym.DecodeSelfAddressResponse(frame)
- if err != nil {
- fmt.Fprintf(os.Stderr, "selfAddress decode: %v\n", err)
- return
- }
- fmt.Printf("Nym inbox address: %s\n\n", addr)
-}
-
-// ── 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)
- }
-
- raw, ok := extractMessage(frame)
- if !ok {
- continue
- }
-
- plaintext, err := decryptMessage(raw, privKey)
- if err != nil {
- fmt.Fprintf(os.Stderr, "decrypt failed: %v\n", err)
- continue
- }
-
- saveSubmission(outDir, plaintext)
- }
-}
-
-// extractMessage pulls the raw submission payload out of a nym-client
-// "received" frame, tolerating both encodings nym-client can use: binary
-// (what this connection actually runs in, see printSelfAddress) and
-// text/JSON (kept as a defensive fallback only — should never fire here).
-func extractMessage(frame []byte) ([]byte, bool) {
- // Binary frame (nym native binary protocol), the expected/only path:
- // 0x01 (Received tag) | has_sender_tag(1) | [sender_tag 16B] | msg_len(8, BE) | msg
- // nym.DecodeReceived returns the message bytes exactly as the sender
- // transmitted them — for nymdrop that is the raw, unencoded packet
- // (ephemeral pubkey || iv || AES-GCM ciphertext), see internal/nym.Client.Send.
- if frame[0] == nym.RespReceived {
- return nym.DecodeReceived(frame)
- }
-
- // Text/JSON frame: {"type":"received","message":"<base64>","senderTag":...}
- // Only reachable if this connection somehow reverted to text mode; the
- // message field is base64 in that protocol (JSON strings can't carry
- // arbitrary binary), so it must be decoded here to match the binary path.
- if frame[0] == '{' {
- var msg wsReceived
- if err := json.Unmarshal(frame, &msg); err != nil || msg.Type != "received" {
- return nil, false
- }
- var s string
- if err := json.Unmarshal(msg.Message, &s); err != nil {
- return nil, false
- }
- raw, err := base64.StdEncoding.DecodeString(s)
- if err != nil {
- return nil, false
- }
- return raw, true
- }
-
- return nil, false
+ runCLI(cfg)
}
-// ── decryption ────────────────────────────────────────────────────────────────
-
-// decryptMessage decrypts a raw 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(raw []byte, privKey *ecdh.PrivateKey) (string, error) {
- // 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)
+func runCLI(cfg reader.Config) {
+ hooks := reader.Hooks{
+ Log: func(format string, args ...any) {
+ fmt.Printf(format+"\n", args...)
+ },
+ SelfAddress: func(addr string) {
+ fmt.Printf("Nym inbox address: %s\n\n", addr)
+ },
+ Submission: func(path, preview string) {
+ ts := strings.TrimSuffix(filepath.Base(path), ".txt")
+ fmt.Printf("\n[%s] New submission -> %s\n", ts, path)
+ fmt.Printf(" %s\n", strings.ReplaceAll(preview, "\n", "\n "))
+ },
+ Attachment: func(path string, size int) {
+ fmt.Printf(" attachment -> %s (%d bytes)\n", path, size)
+ },
+ Error: func(err error) {
+ fmt.Fprintln(os.Stderr, err)
+ },
+ }
+
+ in, err := reader.Start(cfg, hooks)
if err != nil {
- return "", fmt.Errorf("ecdh: %w", err)
+ fatalf("%v", err)
}
- // Locked, non-swappable memory for the two secrets derived per submission;
- // Destroy zeroes and unlocks instead of relying on a plain overwrite loop.
- sharedBuf := memguard.NewBufferFromBytes(sharedSecret)
- defer sharedBuf.Destroy()
+ defer in.Close()
- // HKDF-SHA-256
- aesKeyRaw := make([]byte, 32)
- hkdfR := hkdf.New(sha256.New, sharedBuf.Bytes(), make([]byte, 32), []byte("nymdrop-v1"))
- if _, err := io.ReadFull(hkdfR, aesKeyRaw); err != nil {
- return "", fmt.Errorf("hkdf: %w", err)
- }
- aesBuf := memguard.NewBufferFromBytes(aesKeyRaw)
- defer aesBuf.Destroy()
-
- // AES-GCM-256 decrypt
- block, err := aes.NewCipher(aesBuf.Bytes())
- 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
+ fmt.Printf("nymdrop-reader listening -- submissions saved to %s\n", in.OutDir())
+ fmt.Println("Press Ctrl+C to quit.")
- 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()
- }
+ go in.ReceiveLoop()
- 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()
+ sig := make(chan os.Signal, 1)
+ signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
+ <-sig
+ fmt.Println("\nShutting down.")
}
-// ── helpers ───────────────────────────────────────────────────────────────────
-
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "nymdrop-reader: "+format+"\n", args...)
os.Exit(1)