summaryrefslogtreecommitdiffstats
path: root/cmd
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 /cmd
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.
Diffstat (limited to 'cmd')
-rw-r--r--cmd/nymdrop-reader/gui.go135
-rw-r--r--cmd/nymdrop-reader/main.go476
-rw-r--r--cmd/nymdrop-reader/memguard_verify_test.go64
3 files changed, 181 insertions, 494 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/cmd/nymdrop-reader/memguard_verify_test.go b/cmd/nymdrop-reader/memguard_verify_test.go
deleted file mode 100644
index 7b240e6..0000000
--- a/cmd/nymdrop-reader/memguard_verify_test.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package main
-
-import (
- "crypto/aes"
- "crypto/cipher"
- "crypto/ecdh"
- "crypto/rand"
- "crypto/sha256"
- "encoding/hex"
- "io"
- "testing"
-
- "golang.org/x/crypto/hkdf"
-)
-
-// Verifies the decryptMessage path still produces the correct plaintext after
-// wrapping the ECDH/HKDF secrets in memguard.LockedBuffer, using the exact
-// wire format the browser client (static/crypto.js) and relay produce.
-func TestDecryptMessageAfterMemguard(t *testing.T) {
- readerPriv, err := ecdh.X25519().GenerateKey(rand.Reader)
- if err != nil {
- t.Fatal(err)
- }
- ephPriv, err := ecdh.X25519().GenerateKey(rand.Reader)
- if err != nil {
- t.Fatal(err)
- }
-
- shared, err := ephPriv.ECDH(readerPriv.PublicKey())
- if err != nil {
- t.Fatal(err)
- }
- aesKey := make([]byte, 32)
- hkdfR := hkdf.New(sha256.New, shared, make([]byte, 32), []byte("nymdrop-v1"))
- if _, err := io.ReadFull(hkdfR, aesKey); err != nil {
- t.Fatal(err)
- }
- block, err := aes.NewCipher(aesKey)
- if err != nil {
- t.Fatal(err)
- }
- gcm, err := cipher.NewGCM(block)
- if err != nil {
- t.Fatal(err)
- }
- iv := make([]byte, 12)
- if _, err := rand.Read(iv); err != nil {
- t.Fatal(err)
- }
- want := "E2E memguard verification message"
- ciphertext := gcm.Seal(nil, iv, []byte(want), nil)
-
- packet := append(append(append([]byte{}, ephPriv.PublicKey().Bytes()...), iv...), ciphertext...)
- noncePrefix := hex.EncodeToString(make([]byte, 16)) + ":"
- raw := append([]byte(noncePrefix), packet...)
-
- got, err := decryptMessage(raw, readerPriv)
- if err != nil {
- t.Fatalf("decryptMessage: %v", err)
- }
- if got != want {
- t.Fatalf("plaintext mismatch: got %q want %q", got, want)
- }
-}