Files
udp_nostr/src/udp_nostr_send.py
T
Laan Tungir 16f20d01c0 Add NIP-44 encrypted event support to UDP Nostr sender/receiver
- Add shared NIP-44 v2 crypto helpers (src/nip44.py, src/nip44.js) producing
  ephemeral_xonly_pubkey[32] || native_nip44_payload framing, interoperable
  with base64-decoded nak encrypt output in both directions
- Upgrade existing senders/receivers in place (no duplicate apps): plaintext
  remains the default; --relay-pubkey / --relay-secret (or env vars) select
  encrypted mode on the same programs and ports
- Receiver detects plaintext first, then falls back to encrypted framing via
  the 32-byte pubkey boundary and NIP-44 0x02 version byte, handling the
  leading-brace ephemeral pubkey collision
- Add src/udp_encrypt_send.sh nak+nc wrapper and src/test_udp_nostr.py suite
  (46 tests: plaintext, encrypted, mixed traffic, nak interop, collision,
  malformed/tampered packets, wrong keys, size budget, env secrets)
- Update README and encryption implementation plan
2026-08-23 09:50:59 -04:00

117 lines
4.2 KiB
Python

#!/usr/bin/env python3
"""UDP Nostr Sender — reads signed event JSON from stdin, sends one datagram.
Usage:
# Plaintext (default, unchanged):
nak event -k 1 -c "hello" --sec <nsec> | python3 udp_nostr_send.py <host> [port]
# Encrypted (NIP-44 v2, recipient = relay pubkey):
nak event -k 1 -c "hello" --sec <nsec> \
| python3 udp_nostr_send.py <host> [port] --relay-pubkey <relay_pub_hex>
# The relay pubkey may also be supplied via the UDP_NOSTR_RELAY_PUBKEY
# environment variable (the flag takes precedence).
Plaintext is the default; encrypted mode is selected only when a relay pubkey
is provided. Encrypted datagrams are self-contained:
ephemeral_xonly_pubkey[32] || native NIP-44 v2 payload
where the native payload is version 0x02 || nonce[32] || ciphertext || mac[32]
and interoperate with base64-decoded `nak encrypt` output.
The 1472-byte single-datagram budget (Ethernet MTU) is enforced: the sender
refuses to send anything larger, sets the Don't Fragment flag, and reports
the maximum supported plaintext size in encrypted mode.
"""
import argparse
import os
import socket
import sys
try:
import nip44
except ImportError: # running from another directory
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import nip44
MAX_DATAGRAM = 1472 # 1500 Ethernet MTU - 20 IP header - 8 UDP header
def parse_args(argv):
parser = argparse.ArgumentParser(
description="Send one Nostr event as a single UDP datagram.",
epilog="Reads the signed event JSON from stdin.",
)
parser.add_argument("host", help="target host (IP or hostname)")
parser.add_argument("port", nargs="?", type=int, default=8888,
help="target UDP port (default: 8888)")
parser.add_argument("--relay-pubkey", default=None,
help="relay x-only pubkey (64 hex chars) — enables NIP-44 v2 "
"encrypted mode; also readable from UDP_NOSTR_RELAY_PUBKEY")
return parser.parse_args(argv)
def main(argv=None):
args = parse_args(argv)
relay_pubkey = args.relay_pubkey or os.environ.get("UDP_NOSTR_RELAY_PUBKEY")
if relay_pubkey:
try:
relay_pubkey = nip44.validate_pubkey(relay_pubkey)
except nip44.NIP44Error as e:
print(f"error: invalid --relay-pubkey: {e}", file=sys.stderr)
return 2
payload = sys.stdin.buffer.read()
if not payload:
print("error: no event read from stdin", file=sys.stderr)
return 2
if relay_pubkey:
if len(payload) > nip44.MAX_PLAINTEXT:
print(f"error: event is {len(payload)} bytes; NIP-44 plaintext limit is "
f"{nip44.MAX_PLAINTEXT}", file=sys.stderr)
return 2
datagram = nip44.encrypt(payload, relay_pubkey)
mode = "encrypted (NIP-44 v2)"
else:
datagram = payload
mode = "plaintext"
if len(datagram) > MAX_DATAGRAM:
if relay_pubkey:
# Largest plaintext whose encrypted form fits: solve
# encrypted_size(n) <= MAX_DATAGRAM by shrinking from the top.
max_plain = 0
for n in range(MAX_DATAGRAM - 99, 0, -1):
if nip44.encrypted_size(n) <= MAX_DATAGRAM:
max_plain = n
break
else:
max_plain = MAX_DATAGRAM
print(f"error: {mode} datagram is {len(datagram)} bytes, exceeding the "
f"{MAX_DATAGRAM}-byte single-datagram budget (Ethernet MTU); "
f"max plaintext ≈ {max_plain} bytes", file=sys.stderr)
return 2
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
# IP_MTU_DISCOVER = 10, IP_PMTUDISC_DO = 2 (Linux): set Don't Fragment
# so an oversized datagram fails loudly instead of fragmenting silently.
try:
sock.setsockopt(socket.IPPROTO_IP, 10, 2)
except OSError:
pass # non-Linux: best effort, size check above still applies
sock.sendto(datagram, (args.host, args.port))
except OSError as e:
print(f"error: send failed: {e}", file=sys.stderr)
return 1
finally:
sock.close()
print(f"Sent {len(datagram)} bytes ({mode}) to {args.host}:{args.port}")
return 0
if __name__ == "__main__":
sys.exit(main())