code wiki / (root) / nx_iot_local_kasa.nx

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}