code wiki / (root) / nx_iot_announce.nx

nx_iot_announce.nx source

↩ module page · 443 lines · 16036 B

1// nx_iot_announce.nx -- multi-vendor LAN smart-device discovery. 2// 3// Roadmap: nxc2/docs/NISHI_IOT_HUB_ROADMAP.md Epoch 1 (DISCOVER). 4// 5// First Epoch-1 primitive of the sovereign IoT hub. Listens on 6// UDP/6667 (Tuya v3.3 broadcast), UDP/38899 (WiZ JSON), and UDP/48899 7// (Magic Home / Flux LED). Captures every datagram and classifies 8// the sender by first-byte signature into a sealed vendor-family 9// enum. 10// 11// What it does today: 12// - opens a UDP listener bound to INADDR_ANY:<port> with 13// SO_REUSEADDR + SO_BROADCAST + SO_RCVTIMEO 14// - sends one WiZ getPilot broadcast to 255.255.255.255:38899 15// - sends one Magic Home HF-A11ASSISTHREAD broadcast to 16// 255.255.255.255:48899 17// - drains incoming datagrams until the wall-clock deadline 18// - classifies each by first-byte signature + source port 19// 20// What it doesn't do yet: 21// - mDNS (RFC 6762/6763) -- deferred to v2 per roadmap 22// - per-family encrypted session -- that is the Epoch 2 primitives 23// (nx_iot_local_tuya_v33, nx_iot_local_magic_home, ...) 24// - Tuya local_key recovery -- that is the Epoch 3 primitive 25// (nx_iot_provision_softap) via factory-reset + SoftAP re-pair 26// 27// genealogy_id: iot_hub_research/tuya_v33_lan + magic_home_protocol + 28// wiz_protocol + yeelight_protocol 29// license_tier: ORIGINAL 30// 31// nx_capability_claims: (per docs/NISHI_INTELLIGENT_CAPABILITY_LAYER.md) 32// needs: [arena_alloc, syscall_socket, syscall_clock, 33// byte_arithmetic, pointer_arithmetic] 34// provides: [iot_family_classify, discovery_probe_build, 35// udp_announce_capture] 36// safety: [no_floating_point, bit_equal_reproducible] 37// verdict: [sealed_enum_5_family, sealed_enum_6_announce_verdict, 38// no_silent_failure] 39// license: ORIGINAL 40// kind: iot_runtime_primitive 41// sss: [S6, S7] (LAN-only sovereign; sealed-enum verdicts) 42// 43// icl_override: kind_isolated: announce composes against ALL vendor 44// family signatures, by design (it classifies into 5 families). 45// This is the orchestrator at the discovery layer; isolation 46// applies to the per-vendor decoders that follow, not here. 47// 48// Verification status: 49// no_floating_point: ✓ 50// sealed_enum_5_family: ✓ (NX_IOT_FAMILY_* has 5 + N sentinel) 51// sealed_enum_6_announce: ✓ (NX_IOT_ANN_VERDICT_* has 6 + N sentinel) 52// 53// nx_safety_envelope: (per nishi-library/seeds/safety-critical- 54// standards.toml + cardinal feedback-exceed-jpl- 55// iec-ieee-zero-injuries-while-winning) 56// intended_use: "LAN-only IoT vendor classifier, discovery-side" 57// sil_target: SIL2 (smart-home control; malfunction 58// can cause property damage; not 59// directly life-threatening) 60// asil_target: QM (no automotive context) 61// dal_target: NONE (no avionics context) 62// iec_62304_class: NONE (no medical context) 63// evidence: [bounded_loops_verified, 64// sealed_enum_complete, 65// no_fp_arithmetic, 66// ICL_audit_passed, 67// bit_equal_reproducible, 68// no_silent_failure_via_sealed_verdict] 69// hazard_register: [bug-tape-iot-spoofed-broadcast, 70// bug-tape-cleartext-credentials, 71// bug-tape-cross-vendor-protocol-confusion] 72// residual_risk: "LAN trust model assumed; attacker on local 73// segment can spoof any vendor signature. 74// Mitigation: per-vendor decoders enforce 75// vendor-specific authentication that 76// announce does NOT claim to provide. 77// Cleartext credentials surfaced upstream 78// (not in this primitive)." 79// verdict: NOT_YET_EVALUATED (awaits 80// nx_safety_critical_grade.nx runtime; 81// blocked on F14/F16 alloca-remat fix queued 82// in bench/ir_diff_corpus/ 83// regression_alloca_remat_param_step_loop.nx) 84 85import "nx_syscalls_x86_64.nx" 86import "nx_udp.nx" 87const NX_MAGIC_2048: i64 = 2048 88 89// ---- Sealed enum: vendor family ------------------------------------ 90// 91// One value per vendor protocol family we know how to recognise. 92// UNKNOWN means "datagram captured but we don't recognise the wire 93// format" -- valuable evidence, not an error. 94 95const NX_IOT_FAMILY_UNKNOWN: i64 = 0 96const NX_IOT_FAMILY_TUYA_V33: i64 = 1 97const NX_IOT_FAMILY_MAGIC_HOME: i64 = 2 98const NX_IOT_FAMILY_WIZ: i64 = 3 99const NX_IOT_FAMILY_YEELIGHT: i64 = 4 100const NX_IOT_FAMILY_N: i64 = 5 101 102func nx_iot_family_is_valid(f: i64) -> i64 { 103 if f < 0 { return 0 } 104 if f >= NX_IOT_FAMILY_N { return 0 } 105 return 1 106} 107 108// ---- Sealed enum: announce verdict --------------------------------- 109 110const NX_IOT_ANN_VERDICT_UNKNOWN: i64 = 0 111const NX_IOT_ANN_VERDICT_OK: i64 = 1 112const NX_IOT_ANN_VERDICT_PARTIAL: i64 = 2 // some captured, cap hit 113const NX_IOT_ANN_VERDICT_NO_DEVICES: i64 = 3 // none captured within timeout 114const NX_IOT_ANN_VERDICT_SOCKET_FAIL: i64 = 4 115const NX_IOT_ANN_VERDICT_BIND_FAIL: i64 = 5 116const NX_IOT_ANN_VERDICT_N: i64 = 6 117 118func nx_iot_ann_verdict_is_valid(v: i64) -> i64 { 119 if v < 0 { return 0 } 120 if v >= NX_IOT_ANN_VERDICT_N { return 0 } 121 return 1 122} 123 124// ---- Socket option constants (Linux x86_64) ------------------------ 125 126const NX_IOT_SO_BROADCAST: i64 = 6 127const NX_IOT_SO_RCVTIMEO: i64 = 20 128 129// ---- Ports --------------------------------------------------------- 130 131const NX_IOT_PORT_TUYA_V33: i64 = 6667 132const NX_IOT_PORT_WIZ: i64 = 38899 133const NX_IOT_PORT_MAGIC_HOME: i64 = 48899 134 135// ---- Beacon record ------------------------------------------------- 136// 137// Caller pre-allocates an array of these; announce_run fills them in 138// observation order. 139 140struct IotBeacon { 141 family: i64, 142 src_ip_be: i64, // packed big-endian u32 (sentinel 0 = unset) 143 src_port: i64, 144 n_bytes: i64, 145} 146 147// ---- Classifier ---------------------------------------------------- 148// 149// First-byte signature with source-port assist. Honest scope: this 150// is a coarse filter, not a deep parser. Epoch 2 per-vendor decoders 151// do the actual frame-level validation. 152// 153// Tuya v3.3 : 0x00 0x00 0x55 0xAA header sentinel 154// WiZ : JSON, first byte '{' (0x7B) 155// Magic Home : ASCII reply '<ip>,<mac>,<model>' -- first byte is 156// an ASCII digit (0x30..0x39) for the IP 157// Yeelight : SSDP-style 'HTTP/1.1' prefix for unicast reply, 158// or 'NOTIFY' for multicast 159// 160// Source port is a hint, not a hard gate -- a real bulb might reply 161// from an ephemeral port even though it listens on the canonical one. 162 163func nx_iot_classify_beacon(buf: *u8, n: i64, src_port: i64) -> i64 { 164 if n <= 0 { return NX_IOT_FAMILY_UNKNOWN } 165 166 // Tuya v3.3 first. Hardest signature, so safest match. 167 if n >= 4 { 168 if buf[0] == 0x00 { 169 if buf[1] == 0x00 { 170 if buf[2] == 0x55 { 171 if buf[3] == 0xAA { return NX_IOT_FAMILY_TUYA_V33 } 172 } 173 } 174 } 175 } 176 177 let b0: i64 = buf[0] as i64 178 179 // WiZ JSON. Leading '{' is the giveaway. 180 if b0 == 0x7B { return NX_IOT_FAMILY_WIZ } 181 182 // Yeelight SSDP. Leading 'H' (HTTP/1.1) or 'N' (NOTIFY). 183 if n >= 6 { 184 if b0 == 0x48 { // 'H' 185 if buf[1] == 0x54 { // 'T' 186 if buf[2] == 0x54 { return NX_IOT_FAMILY_YEELIGHT } 187 } 188 } 189 if b0 == 0x4E { // 'N' 190 if buf[1] == 0x4F { // 'O' 191 if buf[2] == 0x54 { return NX_IOT_FAMILY_YEELIGHT } 192 } 193 } 194 } 195 196 // Magic Home reply: first byte is an ASCII digit AND source port 197 // hint matches. We require both because plain ASCII digits show 198 // up in other protocols too. 199 if b0 >= 0x30 { 200 if b0 <= 0x39 { 201 if src_port == NX_IOT_PORT_MAGIC_HOME { 202 return NX_IOT_FAMILY_MAGIC_HOME 203 } 204 } 205 } 206 207 return NX_IOT_FAMILY_UNKNOWN 208} 209 210// ---- Setsockopt helpers -------------------------------------------- 211 212func _set_int_opt(fd: i64, level: i64, opt: i64, val: i64) -> i64 { 213 let raw: *u8 = sys_mmap(8) 214 raw[0] = val & 0xff 215 raw[1] = (val >> 8) & 0xff 216 raw[2] = (val >> 16) & 0xff 217 raw[3] = (val >> 24) & 0xff 218 return sys_setsockopt(fd, level, opt, raw, 4) 219} 220 221func nx_iot_enable_broadcast(fd: i64) -> i64 { 222 return _set_int_opt(fd, SOL_SOCKET, NX_IOT_SO_BROADCAST, 1) 223} 224 225func nx_iot_enable_reuseaddr(fd: i64) -> i64 { 226 return _set_int_opt(fd, SOL_SOCKET, SO_REUSEADDR, 1) 227} 228 229// Set SO_RCVTIMEO to `ms` milliseconds. Linux x86_64 struct timeval 230// layout: i64 tv_sec; i64 tv_usec. 231func nx_iot_set_rcvtimeo_ms(fd: i64, ms: i64) -> i64 { 232 let tv: *u8 = sys_mmap(16) 233 let sec: i64 = ms / 1000 234 let usec: i64 = (ms - sec * 1000) * 1000 235 // little-endian 8-byte tv_sec 236 tv[0] = sec & 0xff 237 tv[1] = (sec >> 8) & 0xff 238 tv[2] = (sec >> 16) & 0xff 239 tv[3] = (sec >> 24) & 0xff 240 tv[4] = 0; tv[5] = 0; tv[6] = 0; tv[7] = 0 241 tv[8] = usec & 0xff 242 tv[9] = (usec >> 8) & 0xff 243 tv[10] = (usec >> 16) & 0xff 244 tv[11] = (usec >> 24) & 0xff 245 tv[12] = 0; tv[13] = 0; tv[14] = 0; tv[15] = 0 246 return sys_setsockopt(fd, SOL_SOCKET, NX_IOT_SO_RCVTIMEO, tv, 16) 247} 248 249// ---- Discovery payload builders ------------------------------------ 250 251// Magic Home discovery is the literal ASCII string 'HF-A11ASSISTHREAD' 252// (17 bytes, no terminator). Caller-provided buffer must be >= 17. 253 254func nx_iot_build_magic_home_discovery(buf: *u8) -> i64 { 255 buf[0] = 0x48 // H 256 buf[1] = 0x46 // F 257 buf[2] = 0x2D // - 258 buf[3] = 0x41 // A 259 buf[4] = 0x31 // 1 260 buf[5] = 0x31 // 1 261 buf[6] = 0x41 // A 262 buf[7] = 0x53 // S 263 buf[8] = 0x53 // S 264 buf[9] = 0x49 // I 265 buf[10] = 0x53 // S 266 buf[11] = 0x54 // T 267 buf[12] = 0x48 // H 268 buf[13] = 0x52 // R 269 buf[14] = 0x45 // E 270 buf[15] = 0x41 // A 271 buf[16] = 0x44 // D 272 return 17 273} 274 275// WiZ discovery probe. Smallest payload that elicits a reply: 276// {"method":"getPilot","params":{}} 277// 32 bytes. Caller-provided buffer must be >= 32. 278 279func nx_iot_build_wiz_discovery(buf: *u8) -> i64 { 280 buf[0] = 0x7B // { 281 buf[1] = 0x22 // " 282 buf[2] = 0x6D // m 283 buf[3] = 0x65 // e 284 buf[4] = 0x74 // t 285 buf[5] = 0x68 // h 286 buf[6] = 0x6F // o 287 buf[7] = 0x64 // d 288 buf[8] = 0x22 // " 289 buf[9] = 0x3A // : 290 buf[10] = 0x22 // " 291 buf[11] = 0x67 // g 292 buf[12] = 0x65 // e 293 buf[13] = 0x74 // t 294 buf[14] = 0x50 // P 295 buf[15] = 0x69 // i 296 buf[16] = 0x6C // l 297 buf[17] = 0x6F // o 298 buf[18] = 0x74 // t 299 buf[19] = 0x22 // " 300 buf[20] = 0x2C // , 301 buf[21] = 0x22 // " 302 buf[22] = 0x70 // p 303 buf[23] = 0x61 // a 304 buf[24] = 0x72 // r 305 buf[25] = 0x61 // a 306 buf[26] = 0x6D // m 307 buf[27] = 0x73 // s 308 buf[28] = 0x22 // " 309 buf[29] = 0x3A // : 310 buf[30] = 0x7B // { 311 buf[31] = 0x7D // } 312 buf[32] = 0x7D // } 313 return 33 314} 315 316// ---- Discovery senders --------------------------------------------- 317 318func nx_iot_send_wiz_discovery(fd: i64) -> i64 { 319 let dst: *u8 = sys_mmap(16) 320 nx_udp_sockaddr_dest(dst, 255, 255, 255, 255, NX_IOT_PORT_WIZ) 321 let payload: *u8 = sys_mmap(64) 322 let n: i64 = nx_iot_build_wiz_discovery(payload) 323 return nx_udp_send(fd, payload, n, dst) 324} 325 326func nx_iot_send_magic_home_discovery(fd: i64) -> i64 { 327 let dst: *u8 = sys_mmap(16) 328 nx_udp_sockaddr_dest(dst, 255, 255, 255, 255, NX_IOT_PORT_MAGIC_HOME) 329 let payload: *u8 = sys_mmap(32) 330 let n: i64 = nx_iot_build_magic_home_discovery(payload) 331 return nx_udp_send(fd, payload, n, dst) 332} 333 334// ---- Source-address decode ----------------------------------------- 335// 336// Extract sin_port (host order) + sin_addr (BE packed) from a 337// kernel-filled sockaddr_in (16 bytes, network-order port + addr). 338 339func nx_iot_sockaddr_port(addr: *u8) -> i64 { 340 // sockaddr_in.sin_port at offset 2, network-order (big-endian). 341 let hi: i64 = addr[2] as i64 342 let lo: i64 = addr[3] as i64 343 return ((hi & 0xff) << 8) | (lo & 0xff) 344} 345 346func nx_iot_sockaddr_ip_be(addr: *u8) -> i64 { 347 // sockaddr_in.sin_addr at offset 4, network-order (big-endian). 348 // Pack the four bytes as a big-endian 32-bit value, returned as 349 // a positive i64. 350 let b0: i64 = addr[4] as i64 351 let b1: i64 = addr[5] as i64 352 let b2: i64 = addr[6] as i64 353 let b3: i64 = addr[7] as i64 354 return ((b0 & 0xff) << 24) 355 | ((b1 & 0xff) << 16) 356 | ((b2 & 0xff) << 8) 357 | (b3 & 0xff) 358} 359 360// ---- Open + bind a UDP listener with our policy -------------------- 361 362func nx_iot_announce_open(port: i64, timeout_ms: i64, 363 out_verdict: *i64) -> i64 { 364 *out_verdict = NX_IOT_ANN_VERDICT_UNKNOWN 365 let fd: i64 = nx_udp_open() 366 if fd < 0 { 367 *out_verdict = NX_IOT_ANN_VERDICT_SOCKET_FAIL 368 return -1 369 } 370 nx_iot_enable_reuseaddr(fd) 371 nx_iot_enable_broadcast(fd) 372 nx_iot_set_rcvtimeo_ms(fd, timeout_ms) 373 let br: i64 = nx_udp_bind_any(fd, port) 374 if br < 0 { 375 sys_close(fd) 376 *out_verdict = NX_IOT_ANN_VERDICT_BIND_FAIL 377 return -1 378 } 379 return fd 380} 381 382// ---- Main announce loop -------------------------------------------- 383// 384// Single-port listener. Caller invokes once per port (multi-port 385// requires poll/select, deferred to v2). Sends one outbound probe 386// matching the port if applicable, then drains incoming datagrams 387// until the deadline. 388// 389// Returns: count of beacons captured. Writes verdict via 390// out_verdict. 391 392func nx_iot_announce_run(port: i64, timeout_ms: i64, 393 beacons: *IotBeacon, cap: i64, 394 out_verdict: *i64) -> i64 { 395 let fd: i64 = nx_iot_announce_open(port, 1000, out_verdict) 396 if fd < 0 { return 0 } 397 398 // Fire the appropriate probe so passive devices reply. 399 if port == NX_IOT_PORT_WIZ { nx_iot_send_wiz_discovery(fd) } 400 if port == NX_IOT_PORT_MAGIC_HOME { nx_iot_send_magic_home_discovery(fd) } 401 // Tuya v3.3 devices broadcast unsolicited every ~30s; no probe. 402 403 let buf: *u8 = sys_mmap(NX_MAGIC_2048) 404 let pa: *u8 = sys_mmap(16) 405 let plen_ptr: *i64 = sys_mmap(16) as *i64 406 407 let deadline: i64 = sys_now_ms() + timeout_ms 408 var count: i64 = 0 409 var keep: i64 = 1 410 while keep == 1 { 411 let now: i64 = sys_now_ms() 412 if now >= deadline { keep = 0 } 413 else { 414 if count >= cap { keep = 0 } 415 else { 416 plen_ptr[0] = 16 417 let r: i64 = nx_udp_recv(fd, buf, NX_MAGIC_2048, pa, plen_ptr) 418 if r > 0 { 419 let src_port: i64 = nx_iot_sockaddr_port(pa) 420 let src_ip: i64 = nx_iot_sockaddr_ip_be(pa) 421 let fam: i64 = nx_iot_classify_beacon(buf, r, port) 422 let slot_addr: i64 = (beacons as i64) + count * 32 423 let slot: *IotBeacon = slot_addr as *IotBeacon 424 slot.family = fam 425 slot.src_ip_be = src_ip 426 slot.src_port = src_port 427 slot.n_bytes = r 428 count = count + 1 429 } 430 // r < 0 means timeout or error; keep looping until 431 // the wall-clock deadline. 432 } 433 } 434 } 435 sys_close(fd) 436 437 if count == 0 { *out_verdict = NX_IOT_ANN_VERDICT_NO_DEVICES } 438 else { 439 if count >= cap { *out_verdict = NX_IOT_ANN_VERDICT_PARTIAL } 440 else { *out_verdict = NX_IOT_ANN_VERDICT_OK } 441 } 442 return count 443}