summaryrefslogtreecommitdiffstats
path: root/internal/reader/reader.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/reader/reader.go')
-rw-r--r--internal/reader/reader.go573
1 files changed, 573 insertions, 0 deletions
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()
+}