- 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
170 lines
6.6 KiB
JavaScript
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);
|