summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-07-11 13:37:23 +0200
committerGab Virebent <gabriel1@virebent.art>2026-07-11 13:37:23 +0200
commitbec3734676faf3868ea225e0316e77ad5a7e4421 (patch)
tree78bc62532fcdb9f42eb92c41f0841b5fdeeb063d
parentf21e4d8781c06c0686f4a8697ce0697f9bf6a5cc (diff)
downloadnymdrop-bec3734676faf3868ea225e0316e77ad5a7e4421.tar.gz
nymdrop-bec3734676faf3868ea225e0316e77ad5a7e4421.tar.xz
nymdrop-bec3734676faf3868ea225e0316e77ad5a7e4421.zip
Switch Nym WS transport to nym-client's binary protocol, drop second base64 layer
Client.Send now speaks nym-client's native binary websocket protocol (tag || recipient || conn_id || data_len || data) instead of wrapping the ciphertext in a JSON text message, which required base64-encoding it a second time on top of the browser's own base64 layer. The reader's self-address query also switched to the binary protocol: nym-client picks text-vs-binary for every later "received" push based on the format of the last request seen on a connection, so leaving the reader in JSON/text mode would have made nym-client run a lossy UTF-8 conversion over the now-unencoded binary payload, corrupting it. Fixed the binary Received-frame parsing along the way (previous code assumed a fixed 16-byte tag with no length prefix, which never matched the real protocol and was never exercised while the connection stayed text-mode). Verified end-to-end against the real embedded nym-client 1.1.76 binary, with the exact wire format cross-checked against upstream nym source at the pinned build commit.
-rw-r--r--cmd/nymdrop-reader/main.go108
-rw-r--r--cmd/nymdrop-reader/memguard_verify_test.go5
-rw-r--r--cmd/nymdrop/main.go6
-rw-r--r--internal/nym/address.go131
-rw-r--r--internal/nym/address_test.go68
-rw-r--r--internal/nym/client.go45
-rw-r--r--internal/nym/protocol.go70
-rw-r--r--internal/relay/relay.go24
8 files changed, 360 insertions, 97 deletions
diff --git a/cmd/nymdrop-reader/main.go b/cmd/nymdrop-reader/main.go
index e2632cb..8e235d9 100644
--- a/cmd/nymdrop-reader/main.go
+++ b/cmd/nymdrop-reader/main.go
@@ -16,6 +16,7 @@ import (
"fmt"
"io"
"io/fs"
+ "nymdrop/internal/nym"
"os"
"os/exec"
"os/signal"
@@ -206,23 +207,33 @@ type wsReceived struct {
SenderTag string `json:"senderTag"`
}
-type wsSelfAddress struct {
- Type string `json:"type"`
- Address string `json:"address"`
-}
-
+// 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) {
- req := map[string]string{"type": "selfAddress"}
- if err := websocket.JSON.Send(ws, req); err != nil {
+ if err := websocket.Message.Send(ws, nym.EncodeSelfAddressRequest()); err != nil {
fmt.Fprintf(os.Stderr, "selfAddress send: %v\n", err)
return
}
- var resp wsSelfAddress
- if err := websocket.JSON.Receive(ws, &resp); err != nil {
+ var frame []byte
+ if err := websocket.Message.Receive(ws, &frame); err != nil {
fmt.Fprintf(os.Stderr, "selfAddress recv: %v\n", err)
return
}
- fmt.Printf("Nym inbox address: %s\n\n", resp.Address)
+ 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 ──────────────────────────────────────────────────────────────
@@ -256,12 +267,12 @@ func receiveLoop(ws *websocket.Conn, privKey *ecdh.PrivateKey, outDir string) {
len(frame), frame[0], preview)
}
- dataB64, ok := extractMessageB64(frame)
+ raw, ok := extractMessage(frame)
if !ok {
continue
}
- plaintext, err := decryptMessage(dataB64, privKey)
+ plaintext, err := decryptMessage(raw, privKey)
if err != nil {
fmt.Fprintf(os.Stderr, "decrypt failed: %v\n", err)
continue
@@ -271,72 +282,53 @@ func receiveLoop(ws *websocket.Conn, privKey *ecdh.PrivateKey, outDir string) {
}
}
-// extractMessageB64 pulls the base64 payload string out of a nym-client
-// "received" frame. It tolerates the two encodings nym-client uses across
-// versions: text/JSON (the common case) and the native binary protocol.
-func extractMessageB64(frame []byte) (string, bool) {
+// 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 "", false
+ return nil, false
}
- // message is normally a JSON string; tolerate the legacy
- // {"data":"<base64>"} object shape just in case.
var s string
- if err := json.Unmarshal(msg.Message, &s); err == nil {
- return s, true
- }
- var obj struct {
- Data string `json:"data"`
- }
- if err := json.Unmarshal(msg.Message, &obj); err == nil && obj.Data != "" {
- return obj.Data, true
+ if err := json.Unmarshal(msg.Message, &s); err != nil {
+ return nil, false
}
- return "", false
- }
-
- // Binary frame (nym native binary protocol):
- // 0x01 (Received tag) | senderTag flag(1) | [senderTag 16 bytes] | message
- // The message bytes are exactly what the sender transmitted — for nymdrop
- // that is the base64 ASCII string produced by the server.
- if frame[0] == 0x01 {
- buf := frame[1:]
- if len(buf) < 1 {
- return "", false
- }
- hasTag := buf[0]
- buf = buf[1:]
- if hasTag == 1 {
- if len(buf) < 16 {
- return "", false
- }
- buf = buf[16:]
- }
- if len(buf) == 0 {
- return "", false
+ raw, err := base64.StdEncoding.DecodeString(s)
+ if err != nil {
+ return nil, false
}
- return string(buf), true
+ return raw, true
}
- return "", false
+ return nil, false
}
// ── decryption ────────────────────────────────────────────────────────────────
-// decryptMessage decrypts a base64-encoded packet from the relay.
+// 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(dataB64 string, privKey *ecdh.PrivateKey) (string, error) {
- raw, err := base64.StdEncoding.DecodeString(dataB64)
- if err != nil {
- return "", fmt.Errorf("base64: %w", err)
- }
-
+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 {
diff --git a/cmd/nymdrop-reader/memguard_verify_test.go b/cmd/nymdrop-reader/memguard_verify_test.go
index 3ab5fce..7b240e6 100644
--- a/cmd/nymdrop-reader/memguard_verify_test.go
+++ b/cmd/nymdrop-reader/memguard_verify_test.go
@@ -6,7 +6,6 @@ import (
"crypto/ecdh"
"crypto/rand"
"crypto/sha256"
- "encoding/base64"
"encoding/hex"
"io"
"testing"
@@ -53,9 +52,9 @@ func TestDecryptMessageAfterMemguard(t *testing.T) {
packet := append(append(append([]byte{}, ephPriv.PublicKey().Bytes()...), iv...), ciphertext...)
noncePrefix := hex.EncodeToString(make([]byte, 16)) + ":"
- dataB64 := base64.StdEncoding.EncodeToString(append([]byte(noncePrefix), packet...))
+ raw := append([]byte(noncePrefix), packet...)
- got, err := decryptMessage(dataB64, readerPriv)
+ got, err := decryptMessage(raw, readerPriv)
if err != nil {
t.Fatalf("decryptMessage: %v", err)
}
diff --git a/cmd/nymdrop/main.go b/cmd/nymdrop/main.go
index c2090ce..0ae6acc 100644
--- a/cmd/nymdrop/main.go
+++ b/cmd/nymdrop/main.go
@@ -33,7 +33,11 @@ func main() {
if *dryRun {
nymClient = nym.NewDryRun(*journalistAddr)
} else {
- nymClient = nym.New(*journalistAddr, nymBin)
+ var err error
+ nymClient, err = nym.New(*journalistAddr, nymBin)
+ if err != nil {
+ log.Fatalf("nym client: %v", err)
+ }
}
if err := nymClient.Start(); err != nil {
log.Fatalf("nym client: %v", err)
diff --git a/internal/nym/address.go b/internal/nym/address.go
new file mode 100644
index 0000000..f8c251a
--- /dev/null
+++ b/internal/nym/address.go
@@ -0,0 +1,131 @@
+package nym
+
+import (
+ "fmt"
+ "math/big"
+ "strings"
+)
+
+const (
+ pubkeySize = 32
+ // RecipientLen is the wire length of a nym-client Recipient: identity ||
+ // encryption || gateway pubkeys, 32 bytes each.
+ RecipientLen = 3 * pubkeySize
+)
+
+var base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
+
+// decodeBase58 decodes plain base58 (Bitcoin alphabet, no checksum), matching
+// the Rust `bs58` crate defaults used by nym-sphinx for identity/encryption keys.
+func decodeBase58(s string) ([]byte, error) {
+ if s == "" {
+ return nil, fmt.Errorf("empty base58 string")
+ }
+
+ num := new(big.Int)
+ base := big.NewInt(58)
+ for _, r := range s {
+ idx := strings.IndexRune(base58Alphabet, r)
+ if idx < 0 {
+ return nil, fmt.Errorf("invalid base58 character %q", r)
+ }
+ num.Mul(num, base)
+ num.Add(num, big.NewInt(int64(idx)))
+ }
+
+ decoded := num.Bytes()
+
+ // Each leading '1' encodes a leading zero byte.
+ leadingZeros := 0
+ for _, r := range s {
+ if r != '1' {
+ break
+ }
+ leadingZeros++
+ }
+
+ out := make([]byte, leadingZeros+len(decoded))
+ copy(out[leadingZeros:], decoded)
+ return out, nil
+}
+
+// ParseRecipient converts a nym address string "identity.encryption@gateway"
+// into the 96-byte wire encoding expected by the nym-client binary WS protocol
+// (identity_pubkey || encryption_pubkey || gateway_identity_pubkey).
+func ParseRecipient(addr string) ([]byte, error) {
+ atParts := strings.Split(addr, "@")
+ if len(atParts) != 2 {
+ return nil, fmt.Errorf("nym address must contain exactly one '@': %q", addr)
+ }
+ clientHalf, gatewayHalf := atParts[0], atParts[1]
+
+ dotParts := strings.Split(clientHalf, ".")
+ if len(dotParts) != 2 {
+ return nil, fmt.Errorf("nym address client part must contain exactly one '.': %q", addr)
+ }
+
+ identity, err := decodeBase58(dotParts[0])
+ if err != nil {
+ return nil, fmt.Errorf("decode identity key: %w", err)
+ }
+ encryption, err := decodeBase58(dotParts[1])
+ if err != nil {
+ return nil, fmt.Errorf("decode encryption key: %w", err)
+ }
+ gateway, err := decodeBase58(gatewayHalf)
+ if err != nil {
+ return nil, fmt.Errorf("decode gateway key: %w", err)
+ }
+
+ if len(identity) != pubkeySize || len(encryption) != pubkeySize || len(gateway) != pubkeySize {
+ return nil, fmt.Errorf("nym address %q: expected 3x%d byte keys, got %d/%d/%d",
+ addr, pubkeySize, len(identity), len(encryption), len(gateway))
+ }
+
+ out := make([]byte, 0, RecipientLen)
+ out = append(out, identity...)
+ out = append(out, encryption...)
+ out = append(out, gateway...)
+ return out, nil
+}
+
+// FormatRecipient is the inverse of ParseRecipient: it encodes 96 raw
+// recipient bytes back into "identity.encryption@gateway" base58 form.
+func FormatRecipient(b []byte) (string, error) {
+ if len(b) != RecipientLen {
+ return "", fmt.Errorf("expected %d recipient bytes, got %d", RecipientLen, len(b))
+ }
+ identity := encodeBase58(b[:pubkeySize])
+ encryption := encodeBase58(b[pubkeySize : 2*pubkeySize])
+ gateway := encodeBase58(b[2*pubkeySize:])
+ return identity + "." + encryption + "@" + gateway, nil
+}
+
+// encodeBase58 is the inverse of decodeBase58 (plain base58, Bitcoin alphabet,
+// no checksum).
+func encodeBase58(data []byte) string {
+ leadingZeros := 0
+ for _, b := range data {
+ if b != 0 {
+ break
+ }
+ leadingZeros++
+ }
+
+ num := new(big.Int).SetBytes(data)
+ base := big.NewInt(58)
+ mod := new(big.Int)
+ var out []byte
+ for num.Sign() > 0 {
+ num.DivMod(num, base, mod)
+ out = append(out, base58Alphabet[mod.Int64()])
+ }
+ for i := 0; i < leadingZeros; i++ {
+ out = append(out, '1')
+ }
+ // out was built least-significant-digit first; reverse it.
+ for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
+ out[i], out[j] = out[j], out[i]
+ }
+ return string(out)
+}
diff --git a/internal/nym/address_test.go b/internal/nym/address_test.go
new file mode 100644
index 0000000..d73210a
--- /dev/null
+++ b/internal/nym/address_test.go
@@ -0,0 +1,68 @@
+package nym
+
+import "testing"
+
+func TestParseRecipient(t *testing.T) {
+ // Real nymdrop-inbox address (see project session.md).
+ addr := "5xme8W448FjvSG69GaNWNkSwYpurgzmLrooPMt6XWThU.GpKLJHB2oiSqRsG5u55t4h2D25tA7BNSfvCQbfup3j1k@6mMs1GQkDRa7rPPtzG9vkFscmEBmAvQX4zjLSoaDg9Wa"
+
+ got, err := ParseRecipient(addr)
+ if err != nil {
+ t.Fatalf("parseRecipient: %v", err)
+ }
+ if len(got) != RecipientLen {
+ t.Fatalf("expected %d bytes, got %d", RecipientLen, len(got))
+ }
+}
+
+func TestParseRecipientRustTestVector(t *testing.T) {
+ // Same address used in nym-client's own Rust unit tests
+ // (websocket-requests/src/requests.rs: send_request_serialization_works).
+ addr := "CytBseW6yFXUMzz4SGAKdNLGR7q3sJLLYxyBGvutNEQV.4QXYyEVc5fUDjmmi8PrHN9tdUFV4PCvSJE1278cHyvoe@4sBbL1ngf1vtNqykydQKTFh26sQCw888GpUqvPvyNB4f"
+
+ got, err := ParseRecipient(addr)
+ if err != nil {
+ t.Fatalf("parseRecipient: %v", err)
+ }
+ if len(got) != RecipientLen {
+ t.Fatalf("expected %d bytes, got %d", RecipientLen, len(got))
+ }
+}
+
+func TestParseRecipientMalformed(t *testing.T) {
+ cases := []string{
+ "",
+ "no-at-sign",
+ "a@b@c",
+ "nodot@gateway",
+ "id.enc@0OIl", // invalid base58 chars
+ }
+ for _, addr := range cases {
+ if _, err := ParseRecipient(addr); err == nil {
+ t.Errorf("ParseRecipient(%q): expected error, got none", addr)
+ }
+ }
+}
+
+func TestMakeSendRequestLayout(t *testing.T) {
+ c := &Client{recipient: make([]byte, RecipientLen)}
+ for i := range c.recipient {
+ c.recipient[i] = byte(i)
+ }
+ payload := []byte("hello mixnet")
+
+ req := make([]byte, 0, 1+len(c.recipient)+8+8+len(payload))
+ req = append(req, ReqSend)
+ req = append(req, c.recipient...)
+ req = append(req, 0, 0, 0, 0, 0, 0, 0, 0) // connection_id = 0
+ req = append(req, 0, 0, 0, 0, 0, 0, 0, byte(len(payload)))
+ req = append(req, payload...)
+
+ wantLen := 1 + RecipientLen + 8 + 8 + len(payload)
+ if len(req) != wantLen {
+ t.Fatalf("expected length %d, got %d", wantLen, len(req))
+ }
+ if req[0] != 0x00 {
+ t.Fatalf("expected tag 0x00, got 0x%02x", req[0])
+ }
+}
diff --git a/internal/nym/client.go b/internal/nym/client.go
index 89fcf52..e0b14d2 100644
--- a/internal/nym/client.go
+++ b/internal/nym/client.go
@@ -1,8 +1,7 @@
package nym
import (
- "encoding/base64"
- "encoding/json"
+ "encoding/binary"
"fmt"
"io"
"io/fs"
@@ -18,6 +17,7 @@ import (
// Client sends payloads through the Nym mixnet via an embedded nym-client subprocess.
type Client struct {
journalistAddr string
+ recipient []byte // parsed 96-byte wire form of journalistAddr
wsPort int
dryRun bool
process *exec.Cmd
@@ -26,8 +26,12 @@ type Client struct {
configDir string
}
-func New(journalistAddr string, binFS fs.FS) *Client {
- return &Client{journalistAddr: journalistAddr, wsPort: 1978, binFS: binFS}
+func New(journalistAddr string, binFS fs.FS) (*Client, error) {
+ recipient, err := ParseRecipient(journalistAddr)
+ if err != nil {
+ return nil, fmt.Errorf("parse journalist nym address: %w", err)
+ }
+ return &Client{journalistAddr: journalistAddr, recipient: recipient, wsPort: 1978, binFS: binFS}, nil
}
func NewDryRun(journalistAddr string) *Client {
@@ -75,31 +79,26 @@ func (c *Client) Start() error {
return fmt.Errorf("nym-client websocket did not start in time")
}
-type sendMsg struct {
- Type string `json:"type"`
- Message string `json:"message"`
- Recipient string `json:"recipient"`
- WithReplySurb bool `json:"withReplySurb"`
-}
-
-// Send delivers payload to the journalist's Nym address through the mixnet.
+// Send delivers payload to the journalist's Nym address through the mixnet
+// using nym-client's binary websocket protocol (tag || recipient || conn_id ||
+// data_len || data). This avoids the JSON+base64 wrapping of the old text
+// protocol, which inflated every payload by ~4/3 before it ever reached the
+// nym-client's own 16MB hardcoded websocket message limit.
func (c *Client) Send(payload []byte) error {
if c.dryRun {
fmt.Printf("[dry-run] payload received: %d bytes — would send to %s\n",
len(payload), c.journalistAddr)
return nil
}
- msg := sendMsg{
- Type: "send",
- Message: base64.StdEncoding.EncodeToString(payload),
- Recipient: c.journalistAddr,
- WithReplySurb: false,
- }
- raw, err := json.Marshal(msg)
- if err != nil {
- return fmt.Errorf("marshal: %w", err)
- }
- if err := websocket.Message.Send(c.ws, string(raw)); err != nil {
+
+ req := make([]byte, 0, 1+len(c.recipient)+8+8+len(payload))
+ req = append(req, ReqSend)
+ req = append(req, c.recipient...)
+ req = binary.BigEndian.AppendUint64(req, 0) // connection_id: none
+ req = binary.BigEndian.AppendUint64(req, uint64(len(payload)))
+ req = append(req, payload...)
+
+ if err := websocket.Message.Send(c.ws, req); err != nil {
return fmt.Errorf("ws send: %w", err)
}
return nil
diff --git a/internal/nym/protocol.go b/internal/nym/protocol.go
new file mode 100644
index 0000000..577ac5b
--- /dev/null
+++ b/internal/nym/protocol.go
@@ -0,0 +1,70 @@
+package nym
+
+import (
+ "encoding/binary"
+ "fmt"
+)
+
+// Wire-protocol tags for nym-client's native binary websocket protocol.
+// See clients/native/websocket-requests/src/{requests,responses}.rs upstream
+// (pinned to commit f84de25302e886d4bd97a898885c569724c002a7, the exact
+// nym-client 1.1.76 build embedded in this project).
+const (
+ ReqSend byte = 0x00
+ ReqSelfAddress byte = 0x03
+
+ RespError byte = 0x00
+ RespReceived byte = 0x01
+ RespSelfAddress byte = 0x02
+)
+
+// senderTagSize is AnonymousSenderTag's wire size, only present on a
+// Received push when the original sender used SendAnonymous/reply SURBs.
+const senderTagSize = 16
+
+// EncodeSelfAddressRequest builds the binary "self address" request.
+func EncodeSelfAddressRequest() []byte {
+ return []byte{ReqSelfAddress}
+}
+
+// DecodeSelfAddressResponse parses a binary SelfAddress response
+// (tag || 96-byte recipient) and formats it back into "id.enc@gw" form.
+func DecodeSelfAddressResponse(frame []byte) (string, error) {
+ if len(frame) != 1+RecipientLen || frame[0] != RespSelfAddress {
+ return "", fmt.Errorf("not a self-address response (%d bytes, tag 0x%02x)", len(frame), safeTag(frame))
+ }
+ return FormatRecipient(frame[1:])
+}
+
+// DecodeReceived extracts the message payload from a binary "Received" push:
+//
+// tag(1) || has_sender_tag(1) || [sender_tag(16) if flag=1] || msg_len(8, BE) || msg
+func DecodeReceived(frame []byte) ([]byte, bool) {
+ if len(frame) < 2 || frame[0] != RespReceived {
+ return nil, false
+ }
+ i := 2
+ switch frame[1] {
+ case 0:
+ case 1:
+ i += senderTagSize
+ default:
+ return nil, false
+ }
+ if len(frame) < i+8 {
+ return nil, false
+ }
+ msgLen := binary.BigEndian.Uint64(frame[i : i+8])
+ i += 8
+ if uint64(len(frame)-i) != msgLen {
+ return nil, false
+ }
+ return frame[i:], true
+}
+
+func safeTag(frame []byte) byte {
+ if len(frame) == 0 {
+ return 0xff
+ }
+ return frame[0]
+}
diff --git a/internal/relay/relay.go b/internal/relay/relay.go
index 9a17fab..54d4571 100644
--- a/internal/relay/relay.go
+++ b/internal/relay/relay.go
@@ -8,18 +8,18 @@ import (
"nymdrop/internal/nym"
)
-// Two base64 layers stack between the raw file and the wire: the client
-// base64-encodes the file into the plaintext before encrypting (see
-// static/crypto.js, ~4/3 inflation), then nym.Client.Send base64-encodes
-// the whole ciphertext again to embed it in the JSON message sent to the
-// local nym-client over its control WebSocket (~4/3 again). The combined
-// ~1.78x inflation is bounded above by nym-client's own hardcoded 16MB
-// WebSocket message limit, not by anything in this codebase, so this
-// buffer (and the nginx/http-layer cap) must stay well under 16MB / 1.78
-// once the second layer is applied. 11MB here keeps the second-layer
-// base64 (~14.7MB) with real margin below the 16MB nym-client ceiling,
-// which works out to a safe raw file size of roughly 8MB, not 10MB.
-const maxPayloadBytes = 11 * 1024 * 1024 // 11 MB wire (first base64 layer only)
+// One base64 layer remains between the raw file and the wire: the browser
+// client base64-encodes the file into the plaintext before encrypting (see
+// static/crypto.js, ~4/3 inflation). nym.Client.Send used to base64-encode
+// the whole ciphertext a second time to embed it in a JSON message for the
+// local nym-client's WebSocket control channel; it now sends the raw bytes
+// via nym-client's binary protocol instead, so that second inflation is
+// gone. The remaining ~4/3 inflation is still bounded above by nym-client's
+// own hardcoded 16MB WebSocket message limit. This cap (11MB) predates the
+// binary-protocol fix and still reflects the old two-layer math (safe raw
+// file size ~8MB) — raising it towards the ~16MB/1.33 headroom the fix
+// actually unlocked is a deliberate follow-up, not done here.
+const maxPayloadBytes = 11 * 1024 * 1024 // 11 MB wire
// Relay receives ciphertext and forwards it through Nym. Nothing is written to disk.
type Relay struct {