Files
udp_nostr/src/udp_nostr_recv.js
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

170 lines
6.6 KiB
JavaScript

#!/usr/bin/env node
/**
* UDP Nostr Receiver (JavaScript)
*
* Listens for Nostr events as single UDP datagrams.
* No handshake required. No connection state. Just receive and print.
*
* Usage:
* node udp_nostr_recv.js [bind_host] [port] [--relay-secret <relay_sec_hex>]
*
* # Plaintext mode (unchanged):
* node udp_nostr_recv.js 0.0.0.0 8888
*
* # Encrypted mode (also accepts plaintext on the same port):
* node udp_nostr_recv.js 0.0.0.0 8888 --relay-secret <relay_sec_hex>
*
* The relay secret may also be supplied via the UDP_NOSTR_RELAY_SECRET
* environment variable (the flag takes precedence).
*
* Both plaintext and encrypted events are accepted on the same port:
* 1. If the datagram starts with '{', attempt strict plaintext JSON/event
* validation (JSON object with the required Nostr event fields).
* 2. If that fails (or the datagram does not start with '{'), fall back to
* encrypted framing detection: length >= 131, bytes 0..31 a valid
* x-only secp256k1 pubkey, byte 32 == NIP-44 version 0x02. This handles
* the leading-'{' ephemeral-pubkey collision.
*
* Encrypted datagrams are decrypted with the relay secret (NIP-44 v2, wire
* format: ephemeral_xonly_pubkey[32] || native payload). Any framing, key,
* version, padding, or MAC failure drops the datagram silently. Without
* --relay-secret, encrypted datagrams cannot be processed and are dropped.
*
* SINGLE-PACKET DETECTION:
* The receiver cannot directly observe IP-level fragmentation — the OS
* reassembles fragments before delivering to the callback. But we can
* check the datagram size against the Ethernet MTU boundary (1472 bytes).
*/
const dgram = require("dgram");
const path = require("path");
const { argv, env } = require("process");
const nip44 = require(path.join(__dirname, "nip44.js"));
// Maximum UDP payload for a single unfragmented Ethernet frame
const MAX_SINGLE_PACKET = 1472;
const REQUIRED_FIELDS = ["id", "pubkey", "created_at", "kind", "tags", "content", "sig"];
// ── Parse arguments ─────────────────────────────────────────────────────
function parseArgs(argv) {
const args = { bindHost: argv[2] || "0.0.0.0", bindPort: parseInt(argv[3]) || 8888, relaySecret: null };
const rest = argv.slice(2);
for (let i = 0; i < rest.length; i++) {
if (rest[i] === "--relay-secret" && rest[i + 1]) {
args.relaySecret = rest[i + 1];
i++;
}
}
return args;
}
const args = parseArgs(argv);
let relaySecret = args.relaySecret || env.UDP_NOSTR_RELAY_SECRET || null;
if (relaySecret) {
try {
relaySecret = nip44.validateSecret(relaySecret);
} catch (e) {
console.error(`error: invalid --relay-secret: ${e.message}`);
process.exit(2);
}
}
// ── Plaintext event validation ──────────────────────────────────────────
function parsePlaintextEvent(data) {
let event;
try {
event = JSON.parse(data.toString("utf-8"));
} catch (e) {
return null;
}
if (typeof event !== "object" || event === null || Array.isArray(event)) return null;
const missing = REQUIRED_FIELDS.filter((f) => !(f in event));
if (missing.length > 0) return null;
return event;
}
// ── Handle one datagram ─────────────────────────────────────────────────
// Returns "plaintext", "encrypted", or null (dropped).
function handleDatagram(data, rinfo) {
// ── Single-packet check ──────────────────────────────────────────────
if (data.length > MAX_SINGLE_PACKET) {
console.log(` ⚠ Datagram exceeds Ethernet MTU (${MAX_SINGLE_PACKET}B)`);
console.log(" This means IP-level fragmentation occurred on the path.");
}
// Stage 1: strict plaintext validation (only when it plausibly is JSON).
if (data[0] === 0x7b) {
const event = parsePlaintextEvent(data);
if (event !== null) {
console.log(`Received ${data.length} bytes from ${rinfo.address}:${rinfo.port} (plaintext)`);
printEvent(event);
return "plaintext";
}
// Fall through: a leading '{' can also be the first byte of an
// ephemeral pubkey — try encrypted framing below.
}
// Stage 2: encrypted framing detection and decryption.
if (nip44.looksEncrypted(data)) {
if (relaySecret === null) {
return null; // encrypted traffic not enabled; drop silently
}
let plaintext;
try {
plaintext = nip44.decrypt(data, relaySecret);
} catch (e) {
return null; // wrong key, tampering, or malformed framing; drop silently
}
const event = parsePlaintextEvent(plaintext);
if (event === null) {
return null; // decrypted bytes are not a valid event; drop silently
}
console.log(
`Received ${data.length} bytes from ${rinfo.address}:${rinfo.port} ` +
`(encrypted, ${plaintext.length}-byte inner event)`
);
printEvent(event);
return "encrypted";
}
return null; // neither valid plaintext nor plausible encrypted framing
}
function printEvent(event) {
console.log(` Kind: ${event.kind}`);
console.log(` Pubkey: ${String(event.pubkey).slice(0, 16)}...`);
console.log(` Created: ${event.created_at}`);
console.log(` Content: ${String(event.content).slice(0, 80)}`);
console.log(` Event ID: ${String(event.id).slice(0, 16)}...`);
console.log(` Signature: ${String(event.sig).slice(0, 16)}...`);
console.log(" (Signature verification skipped — assuming valid)\n");
}
// ── Create socket ───────────────────────────────────────────────────────
const sock = dgram.createSocket("udp4");
sock.on("listening", () => {
const addr = sock.address();
const mode = relaySecret ? "plaintext + encrypted" : "plaintext only";
console.log(`Listening for Nostr events on UDP ${addr.address}:${addr.port} (${mode})`);
console.log("No handshake required. Waiting for datagrams...\n");
});
sock.on("message", (data, rinfo) => {
handleDatagram(data, rinfo);
});
sock.on("error", (err) => {
console.log(`Socket error: ${err.message}`);
sock.close();
});
// ── Bind ────────────────────────────────────────────────────────────────
sock.bind(args.bindPort, args.bindHost);