diff options
| author | Gab Virebent <gabriel1@virebent.art> | 2026-07-11 13:37:23 +0200 |
|---|---|---|
| committer | Gab Virebent <gabriel1@virebent.art> | 2026-07-11 13:37:23 +0200 |
| commit | bec3734676faf3868ea225e0316e77ad5a7e4421 (patch) | |
| tree | 78bc62532fcdb9f42eb92c41f0841b5fdeeb063d | |
| parent | f21e4d8781c06c0686f4a8697ce0697f9bf6a5cc (diff) | |
| download | nymdrop-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.go | 108 | ||||
| -rw-r--r-- | cmd/nymdrop-reader/memguard_verify_test.go | 5 | ||||
| -rw-r--r-- | cmd/nymdrop/main.go | 6 | ||||
| -rw-r--r-- | internal/nym/address.go | 131 | ||||
| -rw-r--r-- | internal/nym/address_test.go | 68 | ||||
| -rw-r--r-- | internal/nym/client.go | 45 | ||||
| -rw-r--r-- | internal/nym/protocol.go | 70 | ||||
| -rw-r--r-- | internal/relay/relay.go | 24 |
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 { |
