nx_iot_local_tuya_v33.nx source
↩ module page · 306 lines · 12768 B
1// nx_iot_local_tuya_v33.nx -- Tuya v3.3 LAN wire-frame primitive.
2//
3// Roadmap: nxc2/docs/NISHI_IOT_HUB_ROADMAP.md Epoch 2 (SPEAK).
4//
5// Kind generator (per cardinal `feedback-kind-specific-generators-
6// not-broad-noise`): one primitive per vendor protocol family. This
7// file owns the Tuya v3.3 envelope only. Magic Home / WiZ / Yeelight
8// each get their own kind generator.
9//
10// Wire format (Tuya v3.3, observed via tinytuya / tuyapi public
11// protocol notes -- INDEPENDENT_REDERIVE route per the catalog entry
12// in nishi-library/seeds/iot-hub-research.toml):
13//
14// offset size field
15// ------ ---- -----------------------------------------------
16// 0 4 HEAD sentinel 0x00 0x00 0x55 0xAA (BE)
17// 4 4 Sequence number (BE u32)
18// 8 4 Command (BE u32; 7=CONTROL, 10=STATUS,
19// 13=DP_QUERY, 0=BCAST, ...)
20// 12 4 Payload length (BE u32; counts payload bytes
21// PLUS the trailing CRC+TAIL = 8)
22// 16 N Payload (caller-supplied; for real
23// sessions this is AES-128-ECB
24// over PKCS7-padded JSON. The
25// AES layer composes against
26// nx_aes.nx and is the next slice;
27// this primitive is payload-agnostic)
28// 16+N 4 CRC32 (BE; over offsets 0..16+N-1
29// with poly 0xEDB88320,
30// init 0xFFFFFFFF, xorout 0xFFFFFFFF)
31// 16+N+4 4 TAIL sentinel 0x00 0x00 0xAA 0x55 (BE)
32//
33// Total frame size = 16 + N + 8 = N + 24 bytes.
34//
35// What this primitive does today:
36// - CRC32 with the standard zlib polynomial (KAT against the
37// canonical "123456789" -> 0xCBF43926 vector)
38// - frame encode: header + seq + cmd + len + payload + crc + tail
39// - frame decode: validate sentinels + length + CRC, return cmd/seq
40//
41// What it doesn't do yet (queued, separate slices):
42// - AES-128-ECB session encryption layer (composes nx_aes.nx;
43// local_key is per-device, set at pairing time -- needs Epoch 3
44// nx_iot_provision_softap)
45// - JSON DPS payload builders ({"devId":"...","dps":{"20":true}})
46// -- a separate nx_iot_tuya_dps.nx
47// - PKCS7 padding helper -- belongs in nx_pkcs7.nx, reusable
48//
49// genealogy_id: iot_hub_research/tuya_v33_lan +
50// tinytuya_reference (READ ONLY) +
51// tuyapi_reference (READ ONLY)
52// license_tier: ORIGINAL (substrate route INDEPENDENT_REDERIVE)
53//
54// nx_capability_claims: (per docs/NISHI_INTELLIGENT_CAPABILITY_LAYER.md)
55// needs: [byte_arithmetic, pointer_arithmetic]
56// provides: [tuya_v33_frame_encode, tuya_v33_frame_decode, crc32_zlib]
57// safety: [no_unchecked_deref, no_floating_point, no_syscall,
58// constant_time_crc32, bit_equal_reproducible, kind_isolated]
59// verdict: [sealed_enum_7_state, no_silent_failure]
60// license: ORIGINAL
61// kind: iot_vendor_protocol
62// sss: [S0, S6, S7, S8] (per S-class gap analysis for IoT layer)
63//
64// Verification status:
65// needs: ✓ (only byte writes + i64 arith on caller buffers)
66// no_syscall: ✓ (zero imports + zero __syscall calls)
67// no_floating_point: ✓ (i64 throughout)
68// kind_isolated: ✓ (imports no sibling nx_iot_local_*)
69// bit_equal_reproducible: ✓ (Q10 i64; deterministic codegen)
70// sealed_enum_7_state: ✓ (NX_TUYA_DECODE_* has 7 values incl. UNKNOWN/N)
71// no_silent_failure: ✓ (every -1 return precedes an out_verdict write)
72//
73// nx_safety_envelope: (schema: nishi-library/seeds/safety-critical-standards.toml)
74// intended_use: "Tuya v33 LAN protocol frame encode/decode"
75// sil_target: SIL2 (smart-home device control)
76// asil_target: QM
77// dal_target: NONE
78// iec_62304_class: NONE
79// evidence: [no_syscall, no_floating_point, no_unchecked_deref,
80// constant_time_crc32, bit_equal_reproducible,
81// sealed_enum_complete, kind_isolated_audit,
82// CRC32_KAT_verified]
83// hazard_register: [bug-tape-tuya-key-extraction,
84// bug-tape-firmware-downgrade,
85// bug-tape-replay-without-nonce]
86// residual_risk: "Per-device AES-128 key derivation is OUT OF
87// SCOPE for this primitive; caller MUST source
88// keys via secure provisioning (factory-reset
89// SoftAP pairing). This file only handles
90// wire-format encode/decode; auth is upstream."
91// verdict: NOT_YET_EVALUATED (awaits nx_safety_critical_grade)
92//
93// To suppress an axis with justified reason:
94// // icl_override: <axis>: <reason>
95// (none today)
96
97// No syscall imports needed: every function in this file operates on
98// caller-provided buffers (pure i64 arithmetic + byte ops). That
99// keeps the frame primitive portable across all 12 nxc2 backends
100// without depending on any per-ISA syscall vocabulary. Compile-clean
101// proven against ir + asm/rv64 + x86_64 + aarch64 + armv7a +
102// cortex_m + rv32 + ppc64le + mips64 + s390x + la64 + wat.
103
104// ---- Sentinels -----------------------------------------------------
105
106const NX_TUYA_HEAD_B0: i64 = 0x00
107const NX_TUYA_HEAD_B1: i64 = 0x00
108const NX_TUYA_HEAD_B2: i64 = 0x55
109const NX_TUYA_HEAD_B3: i64 = 0xAA
110const NX_TUYA_TAIL_B0: i64 = 0x00
111const NX_TUYA_TAIL_B1: i64 = 0x00
112const NX_TUYA_TAIL_B2: i64 = 0xAA
113const NX_TUYA_TAIL_B3: i64 = 0x55
114
115const NX_TUYA_OVERHEAD: i64 = 24 // header(4)+seq(4)+cmd(4)+len(4)+crc(4)+tail(4)
116const NX_TUYA_PREFIX_LEN: i64 = 16 // header+seq+cmd+len
117const NX_TUYA_SUFFIX_LEN: i64 = 8 // crc+tail
118
119// ---- Sealed enum: Tuya command -------------------------------------
120
121const NX_TUYA_CMD_BCAST: i64 = 0 // broadcast announce
122const NX_TUYA_CMD_CONTROL: i64 = 7 // set DPS
123const NX_TUYA_CMD_STATUS: i64 = 10 // device status push
124const NX_TUYA_CMD_HEARTBEAT: i64 = 9 // keep-alive
125const NX_TUYA_CMD_DP_QUERY: i64 = 13 // query DPS
126const NX_TUYA_CMD_PREFIX_31: i64 = 31 // 3.3 with 3.4 prefix
127
128// ---- Sealed enum: decode verdict -----------------------------------
129
130const NX_TUYA_DECODE_UNKNOWN: i64 = 0
131const NX_TUYA_DECODE_OK: i64 = 1
132const NX_TUYA_DECODE_BAD_HEADER: i64 = 2
133const NX_TUYA_DECODE_BAD_TAIL: i64 = 3
134const NX_TUYA_DECODE_BAD_CRC: i64 = 4
135const NX_TUYA_DECODE_BAD_LEN: i64 = 5
136const NX_TUYA_DECODE_BUF_TOO_SMALL: i64 = 6
137const NX_TUYA_DECODE_N: i64 = 7
138
139func nx_tuya_decode_verdict_is_valid(v: i64) -> i64 {
140 if v < 0 { return 0 }
141 if v >= NX_TUYA_DECODE_N { return 0 }
142 return 1
143}
144
145// ---- Big-endian read/write helpers ---------------------------------
146
147func nx_tuya_write_be32(buf: *u8, off: i64, v: i64) -> i64 {
148 buf[off] = (v >> 24) & 0xff
149 buf[off + 1] = (v >> 16) & 0xff
150 buf[off + 2] = (v >> 8) & 0xff
151 buf[off + 3] = v & 0xff
152 return 4
153}
154
155func nx_tuya_read_be32(buf: *u8, off: i64) -> i64 {
156 let b0: i64 = buf[off] as i64
157 let b1: i64 = buf[off + 1] as i64
158 let b2: i64 = buf[off + 2] as i64
159 let b3: i64 = buf[off + 3] as i64
160 return ((b0 & 0xff) << 24)
161 | ((b1 & 0xff) << 16)
162 | ((b2 & 0xff) << 8)
163 | (b3 & 0xff)
164}
165
166// ---- CRC32 (zlib polynomial 0xEDB88320, init 0xFFFFFFFF, xorout 0xFFFFFFFF)
167//
168// Bit-by-bit implementation (no table) -- ~8x slower than a tabled
169// implementation but ~0 RAM and zero one-time init. For LAN-scale
170// frames (typically 24..512 bytes) this runs in microseconds; the
171// table version is queued only if profiling shows it dominates.
172//
173// KAT: CRC32("123456789") == 0xCBF43926.
174
175func nx_tuya_crc32(buf: *u8, len: i64) -> i64 {
176 var crc: i64 = 0xFFFFFFFF
177 var i: i64 = 0
178 while i < len {
179 crc = crc ^ ((buf[i] as i64) & 0xff)
180 var k: i64 = 0
181 while k < 8 {
182 let lo: i64 = crc & 1
183 let mask: i64 = 0 - lo // 0 or -1 (all bits)
184 crc = ((crc >> 1) & 0x7FFFFFFFFFFFFFFF) ^ (0xEDB88320 & mask)
185 crc = crc & 0xFFFFFFFF
186 k = k + 1
187 }
188 i = i + 1
189 }
190 return (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF
191}
192
193// ---- Frame encode --------------------------------------------------
194//
195// Lays out a complete Tuya v3.3 frame at `out` (must be at least
196// `NX_TUYA_OVERHEAD + payload_len` bytes). Returns the total bytes
197// written, or -1 if out_cap is too small.
198//
199// payload_len_field semantics: Tuya wire's "length" field counts
200// payload + CRC + TAIL, i.e. payload_len + 8.
201
202func nx_tuya_frame_encode(cmd: i64, seq: i64,
203 payload: *u8, payload_len: i64,
204 out: *u8, out_cap: i64) -> i64 {
205 let total: i64 = NX_TUYA_OVERHEAD + payload_len
206 if out_cap < total { return -1 }
207
208 // Header sentinel
209 out[0] = NX_TUYA_HEAD_B0
210 out[1] = NX_TUYA_HEAD_B1
211 out[2] = NX_TUYA_HEAD_B2
212 out[3] = NX_TUYA_HEAD_B3
213
214 // Sequence + command + length-field (payload + 8 = + crc + tail)
215 nx_tuya_write_be32(out, 4, seq)
216 nx_tuya_write_be32(out, 8, cmd)
217 nx_tuya_write_be32(out, 12, payload_len + NX_TUYA_SUFFIX_LEN)
218
219 // Payload
220 var i: i64 = 0
221 while i < payload_len {
222 out[NX_TUYA_PREFIX_LEN + i] = payload[i]
223 i = i + 1
224 }
225
226 // CRC32 over offsets 0..16+payload_len-1
227 let crc: i64 = nx_tuya_crc32(out, NX_TUYA_PREFIX_LEN + payload_len)
228 nx_tuya_write_be32(out, NX_TUYA_PREFIX_LEN + payload_len, crc)
229
230 // Tail sentinel
231 out[NX_TUYA_PREFIX_LEN + payload_len + 4] = NX_TUYA_TAIL_B0
232 out[NX_TUYA_PREFIX_LEN + payload_len + 5] = NX_TUYA_TAIL_B1
233 out[NX_TUYA_PREFIX_LEN + payload_len + 6] = NX_TUYA_TAIL_B2
234 out[NX_TUYA_PREFIX_LEN + payload_len + 7] = NX_TUYA_TAIL_B3
235
236 return total
237}
238
239// ---- Frame decode --------------------------------------------------
240//
241// Validates sentinels + length + CRC. On success writes cmd/seq +
242// returns the payload length and copies the payload into `out_payload`
243// (caller-allocated, must be >= reported payload_len). On any failure
244// writes a sealed-enum verdict via `out_verdict` and returns -1.
245
246func nx_tuya_frame_decode(buf: *u8, n: i64,
247 out_cmd: *i64, out_seq: *i64,
248 out_payload: *u8, out_payload_cap: i64,
249 out_verdict: *i64) -> i64 {
250 *out_verdict = NX_TUYA_DECODE_UNKNOWN
251 if n < NX_TUYA_OVERHEAD {
252 *out_verdict = NX_TUYA_DECODE_BUF_TOO_SMALL
253 return -1
254 }
255
256 // Header sentinel
257 if buf[0] != NX_TUYA_HEAD_B0 { *out_verdict = NX_TUYA_DECODE_BAD_HEADER; return -1 }
258 if buf[1] != NX_TUYA_HEAD_B1 { *out_verdict = NX_TUYA_DECODE_BAD_HEADER; return -1 }
259 if buf[2] != NX_TUYA_HEAD_B2 { *out_verdict = NX_TUYA_DECODE_BAD_HEADER; return -1 }
260 if buf[3] != NX_TUYA_HEAD_B3 { *out_verdict = NX_TUYA_DECODE_BAD_HEADER; return -1 }
261
262 let seq: i64 = nx_tuya_read_be32(buf, 4)
263 let cmd: i64 = nx_tuya_read_be32(buf, 8)
264 let len_field: i64 = nx_tuya_read_be32(buf, 12)
265 // len_field counts payload + crc + tail
266 if len_field < NX_TUYA_SUFFIX_LEN {
267 *out_verdict = NX_TUYA_DECODE_BAD_LEN
268 return -1
269 }
270 let payload_len: i64 = len_field - NX_TUYA_SUFFIX_LEN
271 if NX_TUYA_PREFIX_LEN + payload_len + NX_TUYA_SUFFIX_LEN > n {
272 *out_verdict = NX_TUYA_DECODE_BAD_LEN
273 return -1
274 }
275 if payload_len > out_payload_cap {
276 *out_verdict = NX_TUYA_DECODE_BUF_TOO_SMALL
277 return -1
278 }
279
280 // Tail sentinel
281 let tail_off: i64 = NX_TUYA_PREFIX_LEN + payload_len + 4
282 if buf[tail_off] != NX_TUYA_TAIL_B0 { *out_verdict = NX_TUYA_DECODE_BAD_TAIL; return -1 }
283 if buf[tail_off + 1] != NX_TUYA_TAIL_B1 { *out_verdict = NX_TUYA_DECODE_BAD_TAIL; return -1 }
284 if buf[tail_off + 2] != NX_TUYA_TAIL_B2 { *out_verdict = NX_TUYA_DECODE_BAD_TAIL; return -1 }
285 if buf[tail_off + 3] != NX_TUYA_TAIL_B3 { *out_verdict = NX_TUYA_DECODE_BAD_TAIL; return -1 }
286
287 // CRC32 over offsets 0..PREFIX_LEN+payload_len-1
288 let expected_crc: i64 = nx_tuya_crc32(buf, NX_TUYA_PREFIX_LEN + payload_len)
289 let frame_crc: i64 = nx_tuya_read_be32(buf, NX_TUYA_PREFIX_LEN + payload_len)
290 if expected_crc != frame_crc {
291 *out_verdict = NX_TUYA_DECODE_BAD_CRC
292 return -1
293 }
294
295 // Copy payload out
296 var i: i64 = 0
297 while i < payload_len {
298 out_payload[i] = buf[NX_TUYA_PREFIX_LEN + i]
299 i = i + 1
300 }
301
302 *out_cmd = cmd
303 *out_seq = seq
304 *out_verdict = NX_TUYA_DECODE_OK
305 return payload_len
306}