diff options
| author | Gab Virebent <gabriel1@virebent.art> | 2026-07-12 19:48:39 +0200 |
|---|---|---|
| committer | Gab Virebent <gabriel1@virebent.art> | 2026-07-12 19:48:39 +0200 |
| commit | 41d3abd30cb30be8a8e95c8396da5bb84ab72881 (patch) | |
| tree | 4061a284058c1ce60a423757b9370a329a96cfc1 | |
| parent | bec3734676faf3868ea225e0316e77ad5a7e4421 (diff) | |
| download | nymdrop-41d3abd30cb30be8a8e95c8396da5bb84ab72881.tar.gz nymdrop-41d3abd30cb30be8a8e95c8396da5bb84ab72881.tar.xz nymdrop-41d3abd30cb30be8a8e95c8396da5bb84ab72881.zip | |
Add Fyne GUI and portable --data-dir mode to nymdrop-reader
Extract reader logic (key management, embedded nym-client lifecycle,
receive loop, decrypt, save) into internal/reader as a Hooks-driven
package, shared by both the existing headless CLI and a new --gui mode
built with Fyne. Add --data-dir to make a reader instance fully
self-contained: privkey, inbox, and the embedded nym-client's own state
(normally fixed to $HOME/.nym) all live under the given directory via a
HOME override on the subprocess, enabling zero-trace USB operation.
CLI default behavior is unchanged (same paths, same protocol) so the
pietro deployment is unaffected.
| -rw-r--r-- | cmd/nymdrop-reader/gui.go | 135 | ||||
| -rw-r--r-- | cmd/nymdrop-reader/main.go | 476 | ||||
| -rw-r--r-- | go.mod | 33 | ||||
| -rw-r--r-- | go.sum | 67 | ||||
| -rw-r--r-- | internal/reader/reader.go | 573 | ||||
| -rw-r--r-- | internal/reader/reader_test.go (renamed from cmd/nymdrop-reader/memguard_verify_test.go) | 2 | ||||
| -rwxr-xr-x | scripts/fetch-nym-binaries.sh | 2 |
7 files changed, 856 insertions, 432 deletions
diff --git a/cmd/nymdrop-reader/gui.go b/cmd/nymdrop-reader/gui.go new file mode 100644 index 0000000..d8e5383 --- /dev/null +++ b/cmd/nymdrop-reader/gui.go @@ -0,0 +1,135 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" + + "nymdrop/internal/reader" +) + +// submissionEntry is one row in the submissions list: enough to render the +// row (label) and to load the full body on demand (path) without keeping +// every decrypted body in memory at once. +type submissionEntry struct { + label string + path string +} + +// runGUI is the non-technical front-end for nymdrop-reader: a journalist +// running this from a USB stick should never need a terminal. It drives the +// same reader.Instance the CLI uses, through the same Hooks contract, so +// there is exactly one implementation of the receive/decrypt/save logic. +func runGUI(cfg reader.Config) { + a := app.NewWithID("art.virebent.nymdrop-reader") + w := a.NewWindow("NymDrop Reader") + w.Resize(fyne.NewSize(900, 560)) + + statusLabel := widget.NewLabel("Starting...") + statusLabel.Wrapping = fyne.TextWrapWord + + addrEntry := widget.NewEntry() + addrEntry.Disable() + addrEntry.SetPlaceHolder("Nym inbox address will appear here once connected") + + outDirLabel := widget.NewLabel("") + + var submissions []submissionEntry + + content := widget.NewMultiLineEntry() + content.Wrapping = fyne.TextWrapWord + content.Disable() + + list := widget.NewList( + func() int { return len(submissions) }, + func() fyne.CanvasObject { return widget.NewLabel("template") }, + func(id widget.ListItemID, obj fyne.CanvasObject) { + obj.(*widget.Label).SetText(submissions[id].label) + }, + ) + list.OnSelected = func(id widget.ListItemID) { + if id < 0 || id >= len(submissions) { + return + } + data, err := os.ReadFile(submissions[id].path) + if err != nil { + content.SetText(fmt.Sprintf("error reading %s: %v", submissions[id].path, err)) + return + } + content.SetText(string(data)) + } + + hooks := reader.Hooks{ + Log: func(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + fyne.Do(func() { statusLabel.SetText(msg) }) + }, + SelfAddress: func(addr string) { + fyne.Do(func() { + addrEntry.SetText(addr) + statusLabel.SetText("Connected -- waiting for submissions.") + }) + }, + Submission: func(path, preview string) { + ts := strings.TrimSuffix(filepath.Base(path), ".txt") + oneLine := strings.ReplaceAll(preview, "\n", " ") + if len(oneLine) > 80 { + oneLine = oneLine[:80] + "..." + } + entry := submissionEntry{label: ts + " " + oneLine, path: path} + fyne.Do(func() { + // Newest first: a journalist checking the box wants the + // latest tip on top, not scrolled to the bottom. + submissions = append([]submissionEntry{entry}, submissions...) + list.Refresh() + statusLabel.SetText(fmt.Sprintf("%d submission(s) received.", len(submissions))) + }) + }, + Attachment: func(path string, size int) { + fyne.Do(func() { + statusLabel.SetText(fmt.Sprintf("attachment saved: %s (%d bytes)", path, size)) + }) + }, + Error: func(err error) { + fyne.Do(func() { statusLabel.SetText("error: " + err.Error()) }) + }, + } + + in, err := reader.Start(cfg, hooks) + if err != nil { + statusLabel.SetText("failed to start: " + err.Error()) + } else { + outDirLabel.SetText("Submissions saved to: " + in.OutDir()) + go in.ReceiveLoop() + } + + top := container.NewVBox( + widget.NewLabelWithStyle("NymDrop Reader", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + statusLabel, + widget.NewForm(widget.NewFormItem("Nym inbox address", addrEntry)), + outDirLabel, + ) + + split := container.NewHSplit( + container.NewBorder(widget.NewLabel("Submissions"), nil, nil, nil, list), + container.NewBorder(widget.NewLabel("Content"), nil, nil, nil, content), + ) + split.Offset = 0.35 + + w.SetContent(container.NewBorder(top, nil, nil, nil, split)) + + w.SetCloseIntercept(func() { + if in != nil { + in.Close() + } + w.Close() + }) + + w.ShowAndRun() +} 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) @@ -9,6 +9,39 @@ require ( ) require ( + fyne.io/fyne/v2 v2.8.0 // indirect + fyne.io/systray v1.12.2 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/anthonynsimon/bild v0.14.0 // indirect github.com/awnumar/memcall v0.4.0 // indirect + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fredbi/uri v1.1.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect + github.com/fyne-io/glfw-js v0.4.0 // indirect + github.com/fyne-io/image v0.1.1 // indirect + github.com/fyne-io/oksvg v0.2.0 // indirect + github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 // indirect + github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a // indirect + github.com/go-text/render v0.2.1 // indirect + github.com/go-text/typesetting v0.3.4 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/hack-pad/go-indexeddb v0.3.2 // indirect + github.com/hack-pad/safejs v0.1.0 // indirect + github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect + github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rymdport/portal v0.4.2 // indirect + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/yuin/goldmark v1.8.2 // indirect + golang.org/x/image v0.24.0 // indirect golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) @@ -1,10 +1,77 @@ +fyne.io/fyne/v2 v2.8.0 h1:KNUdIk1eKsXSPy/wU6MdiR1hppAPvyzbjPbtJ8h6EUQ= +fyne.io/fyne/v2 v2.8.0/go.mod h1:tLJK7CVtUBOnMiSDR+J88t/quiGuEhwGs09tIVM1RXg= +fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= +fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/anthonynsimon/bild v0.14.0 h1:IFRkmKdNdqmexXHfEU7rPlAmdUZ8BDZEGtGHDnGWync= +github.com/anthonynsimon/bild v0.14.0/go.mod h1:hcvEAyBjTW69qkKJTfpcDQ83sSZHxwOunsseDfeQhUs= github.com/awnumar/memcall v0.4.0 h1:B7hgZYdfH6Ot1Goaz8jGne/7i8xD4taZie/PNSFZ29g= github.com/awnumar/memcall v0.4.0/go.mod h1:8xOx1YbfyuCg3Fy6TO8DK0kZUua3V42/goA5Ru47E8w= github.com/awnumar/memguard v0.23.0 h1:sJ3a1/SWlcuKIQ7MV+R9p0Pvo9CWsMbGZvcZQtmc68A= github.com/awnumar/memguard v0.23.0/go.mod h1:olVofBrsPdITtJ2HgxQKrEYEMyIBAIciVG4wNnZhW9M= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko= +github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8= +github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= +github.com/fyne-io/glfw-js v0.4.0 h1:I9hREBeFyI10cNIqbMKYb1PRidyPDgwob8o2la9SfQo= +github.com/fyne-io/glfw-js v0.4.0/go.mod h1:SDchsFZh4n7nVuBoiowOhOgIBdz+qUQVeC1w9fe2yVU= +github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= +github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= +github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8= +github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= +github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHI= +github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= +github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a h1:HWK0MBggT/T6YH7VffE10xBIhqeTq8JzIUPJXrRy87g= +github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a/go.mod h1:T5Dn0JwIJOX1euPZ/iT4tq6nFYtmukjcYa7937HuYK8= +github.com/go-text/render v0.2.1 h1:qwHhxqGUjjg4L0XyJWj7M7bpY75NZM+kBpv2Yfw5mcg= +github.com/go-text/render v0.2.1/go.mod h1:HCCAq8MUlm/WRcXshBb4K/n+IkjeXQ1c2Ba+yICSm0A= +github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU= +github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= +github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= +github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= +github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= +github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= +github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= +github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU= +github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= +golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/reader/reader.go b/internal/reader/reader.go new file mode 100644 index 0000000..c0ac853 --- /dev/null +++ b/internal/reader/reader.go @@ -0,0 +1,573 @@ +// Package reader holds the nymdrop-reader engine: it runs an embedded +// nym-client, receives submissions over its WebSocket, decrypts them, and +// writes them to disk. It is deliberately transport/UI-agnostic — both the +// headless CLI (cmd/nymdrop-reader) and the Fyne GUI drive it through Hooks +// instead of writing to stdout directly, so the two front-ends can share one +// implementation instead of drifting apart. +package reader + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/rand" + "crypto/sha256" + "embed" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "golang.org/x/crypto/hkdf" + + "github.com/awnumar/memguard" + "golang.org/x/net/websocket" + + "nymdrop/internal/nym" +) + +//go:embed bin/nym-client-linux-amd64 +var nymBin embed.FS + +// Config holds everything needed to start a reader instance. Empty fields +// fall back to the historical defaults (~/.nymdrop-reader, ~/nymdrop-inbox) +// so existing deployments (e.g. the pietro systemd service) keep behaving +// identically when DataDir is left unset. +type Config struct { + KeyFile string // path to X25519 private key file (hex); generated if missing + InboxID string // nym-client identity id + WSPort int // nym-client websocket port + OutDir string // directory to save decrypted submissions + // DataDir, if non-empty, makes the whole instance self-contained under + // this directory: the embedded nym-client's own config/state (normally + // fixed to ~/.nym by nym-client itself) is redirected here by setting + // HOME for the child process, and KeyFile/OutDir default inside it too. + // This is what makes a USB-portable, zero-trace-on-host reader possible + // without any cooperation from nym-client itself (verified: nym-client + // reads $HOME like any other Rust `dirs`-crate consumer, nothing more + // specific is exposed on its CLI). + DataDir string + Debug bool +} + +// Hooks lets a front-end observe reader activity without depending on how +// the engine is implemented. Any field left nil is a no-op. +type Hooks struct { + Log func(format string, args ...any) + SelfAddress func(addr string) + Submission func(path, preview string) + Attachment func(path string, size int) + Error func(err error) +} + +func (h *Hooks) logf(format string, args ...any) { + if h.Log != nil { + h.Log(format, args...) + } +} + +func (h *Hooks) errorf(err error) { + if h.Error != nil { + h.Error(err) + } +} + +// Instance is a running reader: an embedded nym-client subprocess plus the +// WebSocket connection to it. +type Instance struct { + cfg Config + hooks Hooks + privKey *ecdh.PrivateKey + nymCmd *exec.Cmd + nymBinDir string + ws *websocket.Conn + SelfAddr string +} + +// resolveDefaults fills empty Config fields the same way the original +// nymdrop-reader main() did, anchoring everything under DataDir when set. +func resolveDefaults(cfg Config) (Config, error) { + if cfg.InboxID == "" { + cfg.InboxID = "nymdrop-inbox" + } + if cfg.WSPort == 0 { + cfg.WSPort = 1977 + } + + base := cfg.DataDir + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return cfg, fmt.Errorf("home dir: %w", err) + } + base = home + } + + if cfg.KeyFile == "" { + cfg.KeyFile = filepath.Join(base, ".nymdrop-reader", "privkey.hex") + } + if cfg.OutDir == "" { + cfg.OutDir = filepath.Join(base, "nymdrop-inbox") + } + return cfg, nil +} + +// Start loads/generates the decryption key, extracts and launches the +// embedded nym-client, and connects to its WebSocket. It returns once the +// connection is ready and the self-address has been queried; call +// ReceiveLoop afterwards (in its own goroutine if driving a GUI) and Close +// when done. +func Start(cfg Config, hooks Hooks) (*Instance, error) { + cfg, err := resolveDefaults(cfg) + if err != nil { + return nil, err + } + if err := os.MkdirAll(cfg.OutDir, 0700); err != nil { + return nil, fmt.Errorf("mkdir outDir: %w", err) + } + + privKey, err := loadOrGenKey(cfg.KeyFile, &hooks) + if err != nil { + return nil, err + } + + nymBinPath, nymBinDir, err := extractNymClient() + if err != nil { + return nil, err + } + + // nym-client's own config directory is hardcoded to $HOME/.nym (no CLI + // flag for it) — redirecting HOME for the child process is the only way + // to keep a portable run from touching the operator's real home. See + // Config.DataDir doc comment. Left empty (cfg.DataDir=="") this is a + // no-op: the child inherits the real environment, identical to before. + nymHome := cfg.DataDir + + if err := initNymClient(nymBinPath, cfg.InboxID, nymHome); err != nil { + os.RemoveAll(nymBinDir) + return nil, err + } + + nymCmd, err := startNymClient(nymBinPath, cfg.InboxID, cfg.WSPort, nymHome) + if err != nil { + os.RemoveAll(nymBinDir) + return nil, err + } + + wsURL := fmt.Sprintf("ws://127.0.0.1:%d", cfg.WSPort) + hooks.logf("Connecting to Nym mixnet") + ws, err := dialWS(wsURL, &hooks) + if err != nil { + nymCmd.Process.Kill() + os.RemoveAll(nymBinDir) + return nil, err + } + + in := &Instance{ + cfg: cfg, + hooks: hooks, + privKey: privKey, + nymCmd: nymCmd, + nymBinDir: nymBinDir, + ws: ws, + } + + in.SelfAddr = in.querySelfAddress() + if in.SelfAddr != "" && hooks.SelfAddress != nil { + hooks.SelfAddress(in.SelfAddr) + } + + return in, nil +} + +// OutDir returns the directory submissions are written to. +func (in *Instance) OutDir() string { return in.cfg.OutDir } + +// Close tears down the nym-client subprocess, its WebSocket, and its +// extracted binary's temp directory. +func (in *Instance) Close() { + if in.ws != nil { + in.ws.Close() + } + if in.nymCmd != nil && in.nymCmd.Process != nil { + in.nymCmd.Process.Kill() + } + if in.nymBinDir != "" { + os.RemoveAll(in.nymBinDir) + } + memguard.Purge() +} + +// ── key management ─────────────────────────────────────────────────────────── + +func loadOrGenKey(keyFile string, hooks *Hooks) (*ecdh.PrivateKey, error) { + data, err := os.ReadFile(keyFile) + if err == nil { + raw, err := hex.DecodeString(strings.TrimSpace(string(data))) + if err != nil { + return nil, fmt.Errorf("privkey decode: %w", err) + } + keyBuf := memguard.NewBufferFromBytes(raw) + priv, err := ecdh.X25519().NewPrivateKey(keyBuf.Bytes()) + keyBuf.Destroy() + if err != nil { + return nil, fmt.Errorf("privkey load: %w", err) + } + hooks.logf("Loaded private key from %s", keyFile) + hooks.logf("Public key (deploy in nymdrop-server): %s", hex.EncodeToString(priv.PublicKey().Bytes())) + return priv, nil + } + + rawPriv := make([]byte, 32) + if _, err := rand.Read(rawPriv); err != nil { + return nil, fmt.Errorf("keygen rand: %w", err) + } + keyBuf := memguard.NewBufferFromBytes(rawPriv) + priv, err := ecdh.X25519().NewPrivateKey(keyBuf.Bytes()) + if err != nil { + keyBuf.Destroy() + return nil, fmt.Errorf("keygen: %w", err) + } + if err := os.MkdirAll(filepath.Dir(keyFile), 0700); err != nil { + keyBuf.Destroy() + return nil, fmt.Errorf("mkdir keydir: %w", err) + } + if err := os.WriteFile(keyFile, []byte(hex.EncodeToString(keyBuf.Bytes())+"\n"), 0600); err != nil { + keyBuf.Destroy() + return nil, fmt.Errorf("write privkey: %w", err) + } + keyBuf.Destroy() + hooks.logf("Generated new private key -> %s", keyFile) + hooks.logf("Public key (deploy in nymdrop-server): %s", hex.EncodeToString(priv.PublicKey().Bytes())) + hooks.logf(" Set NYMDROP_PUBKEY_PLACEHOLDER to the public key above in static/crypto.js") + hooks.logf(" and rebuild the server before accepting submissions.") + return priv, nil +} + +// ── nym-client lifecycle ───────────────────────────────────────────────────── + +func extractNymClient() (binPath, tmpDir string, err error) { + tmpDir, err = os.MkdirTemp("", "nymdrop-reader-*") + if err != nil { + return "", "", fmt.Errorf("mktemp: %w", err) + } + binName := "nym-client" + if runtime.GOOS == "windows" { + binName += ".exe" + } + binPath = filepath.Join(tmpDir, binName) + + data, err := fs.ReadFile(nymBin, "bin/nym-client-linux-amd64") + if err != nil { + os.RemoveAll(tmpDir) + return "", "", fmt.Errorf("embedded nym-client not found: %w", err) + } + if err := os.WriteFile(binPath, data, 0700); err != nil { + os.RemoveAll(tmpDir) + return "", "", fmt.Errorf("extract nym-client: %w", err) + } + return binPath, tmpDir, nil +} + +// childEnv returns the environment for the embedded nym-client subprocess, +// overriding HOME (and USERPROFILE, for a future Windows build of +// nym-client) to nymHome when it is non-empty. Verified against the real +// binary: nym-client happily initialises its ~/.nym tree under a redirected +// HOME with no other flag needed. +func childEnv(nymHome string) []string { + if nymHome == "" { + return nil // inherit parent's environment unchanged + } + env := os.Environ() + filtered := env[:0] + for _, kv := range env { + if strings.HasPrefix(kv, "HOME=") || strings.HasPrefix(kv, "USERPROFILE=") { + continue + } + filtered = append(filtered, kv) + } + filtered = append(filtered, "HOME="+nymHome, "USERPROFILE="+nymHome) + return filtered +} + +func initNymClient(binPath, id, nymHome string) error { + cmd := exec.Command(binPath, "init", "--id", id) + cmd.Env = childEnv(nymHome) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + _ = cmd.Run() // best-effort, same as before: a pre-existing id is fine + return nil +} + +func startNymClient(binPath, id string, wsPort int, nymHome string) (*exec.Cmd, error) { + cmd := exec.Command(binPath, "run", + "--id", id, + "--port", fmt.Sprintf("%d", wsPort), + ) + cmd.Env = childEnv(nymHome) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("nym-client start: %w", err) + } + return cmd, nil +} + +func dialWS(wsURL string, hooks *Hooks) (*websocket.Conn, error) { + for i := 0; i < 120; i++ { + ws, err := websocket.Dial(wsURL, "", "http://localhost/") + if err == nil { + hooks.logf("Nym mixnet connection ready.") + return ws, nil + } + time.Sleep(500 * time.Millisecond) + } + return nil, fmt.Errorf("nym-client websocket did not start in time") +} + +// ── websocket message types ─────────────────────────────────────────────────── + +type wsReceived struct { + Type string `json:"type"` + Message json.RawMessage `json:"message"` + SenderTag string `json:"senderTag"` +} + +// querySelfAddress 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 (in *Instance) querySelfAddress() string { + if err := websocket.Message.Send(in.ws, nym.EncodeSelfAddressRequest()); err != nil { + in.hooks.errorf(fmt.Errorf("selfAddress send: %w", err)) + return "" + } + var frame []byte + if err := websocket.Message.Receive(in.ws, &frame); err != nil { + in.hooks.errorf(fmt.Errorf("selfAddress recv: %w", err)) + return "" + } + addr, err := nym.DecodeSelfAddressResponse(frame) + if err != nil { + in.hooks.errorf(fmt.Errorf("selfAddress decode: %w", err)) + return "" + } + return addr +} + +// ── receive loop ────────────────────────────────────────────────────────────── + +// ReceiveLoop blocks, decrypting and saving submissions as they arrive. +// Call it in its own goroutine when driving a GUI; it returns when the +// WebSocket is closed (e.g. by Close). +func (in *Instance) ReceiveLoop() { + for { + var frame []byte + if err := websocket.Message.Receive(in.ws, &frame); err != nil { + if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") { + return + } + in.hooks.errorf(fmt.Errorf("ws recv: %w", err)) + time.Sleep(500 * time.Millisecond) + continue + } + if len(frame) == 0 { + continue + } + if in.cfg.Debug { + preview := frame + if len(preview) > 200 { + preview = preview[:200] + } + in.hooks.logf("[debug] frame: %d bytes, lead=0x%02x: %q", len(frame), frame[0], preview) + } + + raw, ok := extractMessage(frame) + if !ok { + continue + } + + plaintext, err := decryptMessage(raw, in.privKey) + if err != nil { + in.hooks.errorf(fmt.Errorf("decrypt failed: %w", err)) + continue + } + + in.saveSubmission(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 querySelfAddress) and +// text/JSON (kept as a defensive fallback only — should never fire here). +func extractMessage(frame []byte) ([]byte, bool) { + if frame[0] == nym.RespReceived { + return nym.DecodeReceived(frame) + } + + 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 +} + +// ── 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) { + 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:] + + 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) + } + + sharedSecret, err := privKey.ECDH(ephPub) + if err != nil { + return "", fmt.Errorf("ecdh: %w", err) + } + sharedBuf := memguard.NewBufferFromBytes(sharedSecret) + defer sharedBuf.Destroy() + + 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() + + 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 (in *Instance) saveSubmission(plaintext string) { + ts := time.Now().UTC().Format("20060102-150405") + path := filepath.Join(in.cfg.OutDir, ts+".txt") + + for i := 1; ; i++ { + if _, err := os.Stat(path); os.IsNotExist(err) { + break + } + path = filepath.Join(in.cfg.OutDir, fmt.Sprintf("%s-%d.txt", ts, i)) + } + + 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 { + in.hooks.errorf(fmt.Errorf("save submission: %w", err)) + return + } + preview := body + if len(preview) > 200 { + preview = preview[:200] + "..." + } + if in.hooks.Submission != nil { + in.hooks.Submission(path, strings.TrimSpace(preview)) + } + + if fileSection != "" { + in.saveAttachments(ts, fileSection) + } +} + +func (in *Instance) saveAttachments(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 { + in.hooks.errorf(fmt.Errorf("attachment decode %q: %w", currentName, err)) + return + } + safeName := filepath.Base(filepath.Clean(currentName)) + attPath := filepath.Join(in.cfg.OutDir, ts+"-"+safeName) + if err := os.WriteFile(attPath, raw, 0600); err != nil { + in.hooks.errorf(fmt.Errorf("save attachment %q: %w", currentName, err)) + return + } + if in.hooks.Attachment != nil { + in.hooks.Attachment(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() +} diff --git a/cmd/nymdrop-reader/memguard_verify_test.go b/internal/reader/reader_test.go index 7b240e6..896f8c0 100644 --- a/cmd/nymdrop-reader/memguard_verify_test.go +++ b/internal/reader/reader_test.go @@ -1,4 +1,4 @@ -package main +package reader import ( "crypto/aes" diff --git a/scripts/fetch-nym-binaries.sh b/scripts/fetch-nym-binaries.sh index ff8f7b6..8876698 100755 --- a/scripts/fetch-nym-binaries.sh +++ b/scripts/fetch-nym-binaries.sh @@ -9,7 +9,7 @@ VERSION="v1.1.76" URL="https://github.com/nymtech/nym/releases/download/nym-binaries-${VERSION}/nym-client" DEST_DIRS=( "cmd/nymdrop/bin" - "cmd/nymdrop-reader/bin" + "internal/reader/bin" ) root="$(cd "$(dirname "$0")/.." && pwd)" |
