summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-07-12 19:48:39 +0200
committerGab Virebent <gabriel1@virebent.art>2026-07-12 19:48:39 +0200
commit41d3abd30cb30be8a8e95c8396da5bb84ab72881 (patch)
tree4061a284058c1ce60a423757b9370a329a96cfc1
parentbec3734676faf3868ea225e0316e77ad5a7e4421 (diff)
downloadnymdrop-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.go135
-rw-r--r--cmd/nymdrop-reader/main.go476
-rw-r--r--go.mod33
-rw-r--r--go.sum67
-rw-r--r--internal/reader/reader.go573
-rw-r--r--internal/reader/reader_test.go (renamed from cmd/nymdrop-reader/memguard_verify_test.go)2
-rwxr-xr-xscripts/fetch-nym-binaries.sh2
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)
diff --git a/go.mod b/go.mod
index 1a89b6f..1ca2138 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index d9183bd..729fa53 100644
--- a/go.sum
+++ b/go.sum
@@ -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)"