- 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
117 lines
4.2 KiB
Python
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())
|