nx_iot_local_kasa.nx source
↩ module page · 404 lines · 15402 B
1// nx_iot_local_kasa.nx -- TP-Link Kasa (TP-Link Smart Home) LAN protocol.
2//
3// Roadmap: NISHI_IOT_HUB_ROADMAP.md Epoch 2 (SPEAK). The 5th vendor
4// kind generator, added 2026-06-18 because the device found blinking
5// on the operator's LAN is a Kasa HS210 (open SoftAP TP-LINK_HS210_C209,
6// BSSID d8:0d:17:a1:c2:09). Siblings: nx_iot_local_tuya_v33,
7// nx_iot_local_magic_home, nx_iot_local_wiz, nx_iot_local_yeelight.
8//
9// S-class invariants: S0 (bit-equal encode), S6 (no cloud -- pure LAN),
10// S7 (sealed-enum verdicts), S8 (kind-isolated -- imports no sibling).
11//
12// Wire format (community RE per python-kasa / tplink-smarthome-api;
13// RESEARCH_CATALOG_ONLY route -> INDEPENDENT_REDERIVE):
14//
15// Transport: TCP/9999 (length-prefixed) or UDP/9999 (no prefix)
16// Crypto: "autokey" XOR stream -- NOT real crypto, just obfuscation.
17// key starts at 0xAB (171); cipher_byte = plain XOR key;
18// the cipher byte THEN becomes the next key (autokey).
19// Decrypt mirrors it: plain = cipher XOR key; key = cipher.
20// Framing: TCP prepends a 4-byte big-endian length of the cipher
21// payload. UDP omits the length.
22// Format: JSON commands.
23//
24// On/off (HS210 switch, HS100/110 plug):
25// {"system":{"set_relay_state":{"state":1}}}
26// Discovery / capability probe:
27// {"system":{"get_sysinfo":{}}}
28// Colour bulb (KL130 etc.) -- hue/sat/brightness/colour-temp:
29// {"smartlife.iot.smartbulb.lightingservice":{"transition_light_state":
30// {"on_off":1,"hue":120,"saturation":100,"brightness":75,
31// "color_temp":0,"transition_period":0}}}
32//
33// WHY a Kasa driver matters to the operator's outcome:
34// The HS210 that keeps dropping is Kasa. Owning the LAN protocol
35// means the hub controls it WITHOUT the Kasa cloud (which is the
36// round-trip that introduces the disconnect/blink variance), and the
37// capability builders below give the "change colour + light intensity"
38// control the operator asked for, locally + deterministically.
39//
40// VERIFICATION (per the project verification doctrine -- this is a
41// NON-NOVEL standard protocol, so: implement 100% sovereign AND prove
42// vs a 3rd-party reference in the harness, never in product):
43// - SOVEREIGN self-proof shipped here: autokey round-trip identity +
44// a hand-derived KAT ("{}" -> 0xD0 0xAD, derivable from the spec) +
45// TCP frame length round-trip.
46// - PENDING (honest): cross-check the cipher + frame against
47// python-kasa output in the TEST HARNESS only, when we wire live.
48// python is allowed there as an ORACLE; it never enters the program
49// path. Not yet done -> not claimed.
50//
51// NOTE on JSON building: NishiLang string literals DO support escape
52// sequences (\" \\ \n \t) and ARE null-terminated -- VERIFIED
53// 2026-06-18 via nx_strlit_escape_probe / _probe2. (The old
54// nx_iot_local_wiz.nx "no string literals as data" comment was stale;
55// it led to ~60 lines of hand byte-emission that this file replaces
56// with natural escaped literals copied via puts_z.) Pure caller-
57// buffer -> no syscalls -> portable across every nxc2 backend.
58//
59// genealogy_id: iot_hub_research/kasa_protocol + python_kasa_reference (READ ONLY)
60// license_tier: ORIGINAL (substrate route INDEPENDENT_REDERIVE)
61//
62// nx_capability_claims:
63// needs: [byte_arithmetic, pointer_arithmetic]
64// provides: [kasa_autokey_encrypt, kasa_autokey_decrypt,
65// kasa_tcp_frame, kasa_tcp_deframe, kasa_set_relay_payload,
66// kasa_get_sysinfo_probe, kasa_set_light_payload,
67// kasa_set_stainfo_payload, kasa_json_escape,
68// kasa_reply_classify]
69// safety: [no_unchecked_deref, no_floating_point, no_syscall,
70// bounded_iteration, bit_equal_reproducible, kind_isolated]
71// verdict: [sealed_enum_5_state, no_silent_failure]
72// license: ORIGINAL
73// kind: iot_vendor_protocol
74// sss: [S0, S6, S7, S8]
75//
76// nx_safety_envelope:
77// intended_use: "Kasa LAN protocol -- autokey cipher + TCP/UDP
78// framing + relay/lighting payload builders"
79// sil_target: SIL2 (lighting/switch control)
80// asil_target: QM
81// evidence: [no_syscall, no_floating_point, bounded_iteration,
82// bit_equal_reproducible, sealed_enum_complete,
83// autokey_roundtrip_KAT, frame_length_roundtrip]
84// hazard_register: [bug-tape-autokey-key-desync,
85// bug-tape-frame-length-mismatch,
86// bug-tape-json-unescaped-injection]
87// residual_risk: "Autokey is obfuscation, NOT security -- anyone
88// on the LAN can read/forge. That is the Kasa
89// protocol's property, not ours; the hub's trust
90// boundary is the LAN + the anchor identity, not
91// this cipher. 3rd-party (python-kasa) KAT
92// cross-check pending in harness."
93// verdict: NOT_YET_EVALUATED
94
95// No imports -- pure caller-provided-buffer arithmetic.
96
97// ---- Sealed enum: verdict -----------------------------------------
98
99const NX_IOT_KASA_VERDICT_UNKNOWN: i64 = 0
100const NX_IOT_KASA_VERDICT_OK: i64 = 1
101const NX_IOT_KASA_VERDICT_BUF_TOO_SMALL: i64 = 2
102const NX_IOT_KASA_VERDICT_BAD_LEN: i64 = 3
103const NX_IOT_KASA_VERDICT_BAD_ARG: i64 = 4
104const NX_IOT_KASA_VERDICT_N: i64 = 5
105
106func nx_iot_kasa_verdict_is_valid(v: i64) -> i64 {
107 if v < 0 { return 0 }
108 if v >= NX_IOT_KASA_VERDICT_N { return 0 }
109 return 1
110}
111
112// ---- Wire constants -----------------------------------------------
113
114const NX_IOT_KASA_PORT: i64 = 9999
115const NX_IOT_KASA_INIT_KEY: i64 = 0xAB
116
117// ---- Autokey cipher ------------------------------------------------
118//
119// Encrypt: c = p XOR key; key := c. Decrypt: p = c XOR key; key := c.
120// Deterministic + reversible -> round-trip is the sovereign self-proof.
121
122func nx_iot_kasa_encrypt(plain: *u8, n: i64, out: *u8) -> i64 {
123 var key: i64 = NX_IOT_KASA_INIT_KEY
124 var i: i64 = 0
125 while i < n {
126 let c: i64 = ((plain[i] as i64) & 0xff) ^ key
127 out[i] = (c & 0xff) as u8
128 key = c & 0xff
129 i = i + 1
130 }
131 return n
132}
133
134func nx_iot_kasa_decrypt(cipher: *u8, n: i64, out: *u8) -> i64 {
135 var key: i64 = NX_IOT_KASA_INIT_KEY
136 var i: i64 = 0
137 while i < n {
138 let c: i64 = (cipher[i] as i64) & 0xff
139 let p: i64 = c ^ key
140 out[i] = (p & 0xff) as u8
141 key = c
142 i = i + 1
143 }
144 return n
145}
146
147// ---- Big-endian u32 ------------------------------------------------
148
149func nx_iot_kasa_put_u32_be(buf: *u8, off: i64, v: i64) -> i64 {
150 buf[off] = ((v >> 24) & 0xff) as u8
151 buf[off + 1] = ((v >> 16) & 0xff) as u8
152 buf[off + 2] = ((v >> 8) & 0xff) as u8
153 buf[off + 3] = (v & 0xff) as u8
154 return off + 4
155}
156
157func nx_iot_kasa_get_u32_be(buf: *u8, off: i64) -> i64 {
158 let b0: i64 = buf[off] as i64
159 let b1: i64 = buf[off + 1] as i64
160 let b2: i64 = buf[off + 2] as i64
161 let b3: i64 = buf[off + 3] as i64
162 return (b0 << 24) | (b1 << 16) | (b2 << 8) | b3
163}
164
165// ---- String helpers (literals are null-terminated -- verified) ----
166
167func nx_iot_kasa_strlen(s: *u8) -> i64 {
168 var i: i64 = 0
169 var keep: i64 = 1
170 while keep == 1 {
171 if s[i] == (0 as u8) { keep = 0 }
172 else { i = i + 1 }
173 }
174 return i
175}
176
177// Copy a null-terminated literal into out at off; return new offset.
178func nx_iot_kasa_puts_z(out: *u8, off: i64, s: *u8) -> i64 {
179 var i: i64 = 0
180 var keep: i64 = 1
181 while keep == 1 {
182 let ch: u8 = s[i]
183 if ch == (0 as u8) { keep = 0 }
184 else {
185 out[off + i] = ch
186 i = i + 1
187 }
188 }
189 return off + i
190}
191
192// Non-negative decimal writer. Returns the new offset.
193func nx_iot_kasa_itoa(out: *u8, off: i64, v: i64) -> i64 {
194 if v == 0 {
195 out[off] = 0x30
196 return off + 1
197 }
198 var tmp: i64 = v
199 if tmp < 0 { tmp = -tmp }
200 var n: i64 = 0
201 var t2: i64 = tmp
202 while t2 > 0 {
203 n = n + 1
204 t2 = t2 / 10
205 }
206 var i: i64 = n - 1
207 while i >= 0 {
208 out[off + i] = ((0x30 + (tmp % 10)) & 0xff) as u8
209 tmp = tmp / 10
210 i = i - 1
211 }
212 return off + n
213}
214
215// ---- Substring find (naive, hub-scale) ----------------------------
216
217func nx_iot_kasa_find(buf: *u8, len: i64, pat: *u8, pat_len: i64) -> i64 {
218 if pat_len == 0 { return 0 }
219 if pat_len > len { return -1 }
220 var i: i64 = 0
221 let end: i64 = len - pat_len + 1
222 var hit: i64 = -1
223 while i < end {
224 if hit == -1 {
225 var j: i64 = 0
226 var ok: i64 = 1
227 while j < pat_len {
228 if (buf[i + j] as i64) != (pat[j] as i64) {
229 ok = 0
230 j = pat_len
231 } else {
232 j = j + 1
233 }
234 }
235 if ok == 1 { hit = i }
236 }
237 i = i + 1
238 }
239 return hit
240}
241
242// ---- Payload builders (natural escaped JSON literals) -------------
243
244// {"system":{"set_relay_state":{"state":N}}} (on/off; switch + plug)
245func nx_iot_kasa_pt_relay(out: *u8, out_cap: i64, on: i64) -> i64 {
246 if out_cap < 48 { return -1 }
247 var o: i64 = nx_iot_kasa_puts_z(out, 0, "{\"system\":{\"set_relay_state\":{\"state\":")
248 var d: i64 = 0
249 if on != 0 { d = 1 }
250 o = nx_iot_kasa_itoa(out, o, d)
251 o = nx_iot_kasa_puts_z(out, o, "}}}")
252 return o
253}
254
255// {"system":{"get_sysinfo":{}}} (discovery + capability probe)
256func nx_iot_kasa_pt_sysinfo(out: *u8, out_cap: i64) -> i64 {
257 if out_cap < 32 { return -1 }
258 return nx_iot_kasa_puts_z(out, 0, "{\"system\":{\"get_sysinfo\":{}}}")
259}
260
261// Colour bulb capability command -- hue/saturation/brightness/colour-temp.
262func nx_iot_kasa_pt_light(out: *u8, out_cap: i64, on: i64,
263 hue: i64, sat: i64, bri: i64,
264 color_temp: i64) -> i64 {
265 if out_cap < 176 { return -1 }
266 var oo: i64 = 0
267 if on != 0 { oo = 1 }
268 var o: i64 = nx_iot_kasa_puts_z(out, 0,
269 "{\"smartlife.iot.smartbulb.lightingservice\":{\"transition_light_state\":{\"on_off\":")
270 o = nx_iot_kasa_itoa(out, o, oo)
271 o = nx_iot_kasa_puts_z(out, o, ",\"hue\":")
272 o = nx_iot_kasa_itoa(out, o, hue)
273 o = nx_iot_kasa_puts_z(out, o, ",\"saturation\":")
274 o = nx_iot_kasa_itoa(out, o, sat)
275 o = nx_iot_kasa_puts_z(out, o, ",\"brightness\":")
276 o = nx_iot_kasa_itoa(out, o, bri)
277 o = nx_iot_kasa_puts_z(out, o, ",\"color_temp\":")
278 o = nx_iot_kasa_itoa(out, o, color_temp)
279 o = nx_iot_kasa_puts_z(out, o, ",\"transition_period\":0}}}")
280 return o
281}
282
283// ---- SoftAP provisioning: push house Wi-Fi creds (set_stainfo) ----
284//
285// The payload the hub sends over a device's open pairing SoftAP to hand
286// it the home network, so it re-joins the LAN on its own -- the concrete
287// PUSH_CREDS step of nx_iot_provision_softap (the auto-pair state
288// machine). This writes a REVERSIBLE Wi-Fi credential via Kasa's
289// documented pairing path; it is NOT a firmware write (never-brick,
290// CLAUDE.md #26 -- a rejected/garbled push just leaves the device in
291// pairing mode). key_type 3 = WPA2-PSK.
292//
293// {"netif":{"set_stainfo":{"ssid":"<ssid>","password":"<pw>","key_type":N}}}
294//
295// SSID + password are caller data, so they are JSON-escaped: a quote or
296// backslash in the credential becomes \" / \\ instead of breaking the
297// frame or injecting keys (closes bug-tape-json-unescaped-injection for
298// those cases). Returns the byte length, or -1 if out_cap is too small
299// or a length is negative.
300
301func nx_iot_kasa_puts_json_escaped(out: *u8, off: i64, s: *u8, n: i64) -> i64 {
302 var i: i64 = 0
303 var o: i64 = off
304 while i < n {
305 let ch: i64 = (s[i] as i64) & 0xff
306 if ch == 0x22 { // " -> \"
307 out[o] = 0x5C as u8
308 out[o + 1] = 0x22 as u8
309 o = o + 2
310 } else {
311 if ch == 0x5C { // \ -> \\
312 out[o] = 0x5C as u8
313 out[o + 1] = 0x5C as u8
314 o = o + 2
315 } else {
316 out[o] = s[i]
317 o = o + 1
318 }
319 }
320 i = i + 1
321 }
322 return o
323}
324
325func nx_iot_kasa_pt_set_stainfo(out: *u8, out_cap: i64,
326 ssid: *u8, ssid_n: i64,
327 pw: *u8, pw_n: i64,
328 key_type: i64) -> i64 {
329 if ssid_n < 0 { return -1 }
330 if pw_n < 0 { return -1 }
331 // Worst case: every byte escapes to 2, plus the ~66-byte fixed
332 // template + key_type digits. Reserve generously.
333 let need: i64 = 80 + 2 * ssid_n + 2 * pw_n
334 if out_cap < need { return -1 }
335 var o: i64 = nx_iot_kasa_puts_z(out, 0, "{\"netif\":{\"set_stainfo\":{\"ssid\":\"")
336 o = nx_iot_kasa_puts_json_escaped(out, o, ssid, ssid_n)
337 o = nx_iot_kasa_puts_z(out, o, "\",\"password\":\"")
338 o = nx_iot_kasa_puts_json_escaped(out, o, pw, pw_n)
339 o = nx_iot_kasa_puts_z(out, o, "\",\"key_type\":")
340 o = nx_iot_kasa_itoa(out, o, key_type)
341 o = nx_iot_kasa_puts_z(out, o, "}}}")
342 return o
343}
344
345// ---- TCP framing (4-byte BE length + autokey payload) -------------
346
347func nx_iot_kasa_frame_tcp(plain: *u8, n: i64, out: *u8, out_cap: i64,
348 out_verdict: *i64) -> i64 {
349 *out_verdict = NX_IOT_KASA_VERDICT_UNKNOWN
350 if n < 0 {
351 *out_verdict = NX_IOT_KASA_VERDICT_BAD_ARG
352 return -1
353 }
354 if out_cap < n + 4 {
355 *out_verdict = NX_IOT_KASA_VERDICT_BUF_TOO_SMALL
356 return -1
357 }
358 nx_iot_kasa_put_u32_be(out, 0, n)
359 let body_addr: i64 = (out as i64) + 4
360 let body: *u8 = body_addr as *u8
361 nx_iot_kasa_encrypt(plain, n, body)
362 *out_verdict = NX_IOT_KASA_VERDICT_OK
363 return n + 4
364}
365
366func nx_iot_kasa_deframe_tcp(frame: *u8, frame_n: i64, out: *u8, out_cap: i64,
367 out_verdict: *i64) -> i64 {
368 *out_verdict = NX_IOT_KASA_VERDICT_UNKNOWN
369 if frame_n < 4 {
370 *out_verdict = NX_IOT_KASA_VERDICT_BAD_LEN
371 return -1
372 }
373 let plen: i64 = nx_iot_kasa_get_u32_be(frame, 0)
374 if plen < 0 {
375 *out_verdict = NX_IOT_KASA_VERDICT_BAD_LEN
376 return -1
377 }
378 if plen + 4 > frame_n {
379 *out_verdict = NX_IOT_KASA_VERDICT_BAD_LEN
380 return -1
381 }
382 if out_cap < plen {
383 *out_verdict = NX_IOT_KASA_VERDICT_BUF_TOO_SMALL
384 return -1
385 }
386 let body_addr: i64 = (frame as i64) + 4
387 let body: *u8 = body_addr as *u8
388 nx_iot_kasa_decrypt(body, plen, out)
389 *out_verdict = NX_IOT_KASA_VERDICT_OK
390 return plen
391}
392
393// ---- Reply classifier (is this a Kasa sysinfo reply?) -------------
394//
395// Decrypted Kasa sysinfo always carries these markers. Used by the
396// universal device probe to route an unknown device to this driver.
397
398func nx_iot_kasa_looks_like_sysinfo(plain: *u8, n: i64) -> i64 {
399 if nx_iot_kasa_find(plain, n, "sw_ver", nx_iot_kasa_strlen("sw_ver")) >= 0 { return 1 }
400 if nx_iot_kasa_find(plain, n, "relay_state", nx_iot_kasa_strlen("relay_state")) >= 0 { return 1 }
401 if nx_iot_kasa_find(plain, n, "get_sysinfo", nx_iot_kasa_strlen("get_sysinfo")) >= 0 { return 1 }
402 if nx_iot_kasa_find(plain, n, "mic_type", nx_iot_kasa_strlen("mic_type")) >= 0 { return 1 }
403 return 0
404}