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}