homebridge-tuya-plus 3.14.0-dev.34 → 3.14.0-dev.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -144,6 +144,12 @@ optional, off-by-default flags read through `_debugConfig()` and coerced with
144
144
  the whole platform runs over the Tuya Cloud fallback. Lets the cloud path be
145
145
  exercised end-to-end without taking devices off the LAN (needs a configured
146
146
  `cloud` block to be useful).
147
+ - `debug.logCloudHttp` — trace every Tuya Cloud HTTP request/response (method,
148
+ path, headers, body, status, response) at `debug`, with all credentials
149
+ (token, signature, password, access key/id, uid) redacted. Use it to see
150
+ exactly what a failing `POST …/commands` sent and how the cloud replied (e.g.
151
+ the `code`/`value` behind a `2008`). Verbose and per-request — keep it off in
152
+ normal operation, and remember Homebridge debug logging must be on to see it.
147
153
 
148
154
  ## Git & PR workflow
149
155
 
package/index.js CHANGED
@@ -402,7 +402,11 @@ class TuyaLan {
402
402
  );
403
403
  }
404
404
 
405
- this.cloudApi = new TuyaCloudApi({ ...cloudCfg, log: this.log });
405
+ this.cloudApi = new TuyaCloudApi({
406
+ ...cloudCfg,
407
+ log: this.log,
408
+ logHttp: coerceBoolean(this._debugConfig().logCloudHttp),
409
+ });
406
410
  this.cloudMessaging = new TuyaCloudMessaging({
407
411
  api: this.cloudApi,
408
412
  log: this.log,
@@ -48,6 +48,21 @@ class TuyaAccessory extends EventEmitter {
48
48
  this.log.info(`${this.context.name} reports protocol ${this.context.version}, newer than the newest known Tuya LAN protocol (3.5); using the 3.5 (GCM) stack with ${this.context.version} headers.`);
49
49
  }
50
50
 
51
+ // A Tuya local key is the AES-128 key used for every LAN frame and must be
52
+ // exactly 16 bytes. A wrong-length key (mistyped, truncated, or pasted with
53
+ // stray characters) makes crypto.createCipheriv throw "Invalid key length"
54
+ // the moment we first talk to the device. That throw originates inside a
55
+ // socket callback, so it goes unhandled and crashes Homebridge - which then
56
+ // restarts and crashes again: a boot loop. Refuse to bring the LAN backend
57
+ // up for such a device and say why; with a Tuya Cloud session configured it
58
+ // still works as a cloud-only device, and other devices are unaffected.
59
+ this._invalidKey = !this.context.fake && !TuyaAccessory.isValidLocalKey(this.context.key);
60
+ if (this._invalidKey && this.log) {
61
+ const key = this.context.key;
62
+ const keyLength = typeof key === 'string' ? Buffer.byteLength(key, 'utf8') : (Buffer.isBuffer(key) ? key.length : 0);
63
+ this.log.error(`${this.context.name} (${this.context.id}) has an invalid local key: a Tuya local key must be exactly 16 characters (this one has ${keyLength}). LAN control is disabled for this device until the key is corrected.`);
64
+ }
65
+
51
66
  this.state = {};
52
67
  this._cachedBuffer = Buffer.allocUnsafe(0);
53
68
 
@@ -72,7 +87,19 @@ class TuyaAccessory extends EventEmitter {
72
87
  this.session_key = null;
73
88
  }
74
89
 
90
+ // A Tuya local key is the AES-128 key for the LAN protocol, so it must be
91
+ // exactly 16 bytes (createCipheriv rejects any other length). Measure a
92
+ // string the way createCipheriv does - by its UTF-8 byte length - so a key
93
+ // that merely looks 16 characters long but isn't (e.g. a stray multibyte
94
+ // character) is still caught.
95
+ static isValidLocalKey(key) {
96
+ if (Buffer.isBuffer(key)) return key.length === 16;
97
+ if (typeof key === 'string') return Buffer.byteLength(key, 'utf8') === 16;
98
+ return false;
99
+ }
100
+
75
101
  _connect() {
102
+ if (this._invalidKey) return;
76
103
  if (this.context.fake) {
77
104
  this.connected = true;
78
105
  return setTimeout(() => {
@@ -767,6 +794,7 @@ class TuyaAccessory extends EventEmitter {
767
794
 
768
795
  _send(o) {
769
796
  if (this.context.fake) return;
797
+ if (this._invalidKey) return false;
770
798
  if (!this.connected) return false;
771
799
 
772
800
  if (this._protocolVersion < 3.2) return this._send_3_1(o);
@@ -53,9 +53,18 @@ const REGION_ENDPOINTS = {
53
53
  // see one we drop the cached token and retry the call once.
54
54
  const TOKEN_INVALID_CODES = new Set([1010, 1011, 1004]);
55
55
 
56
+ // Header/body/response field names whose values may be a credential or token.
57
+ // Their values are masked before an HTTP trace is logged (debug.logCloudHttp),
58
+ // so the trace never leaks the access token, signature, account password, uid,
59
+ // or access key/id.
60
+ const SECRET_FIELDS = /^(access_token|refresh_token|password|sign|secret|access_?key|access_?id|client_id|uid|localKey|key)$/i;
61
+
56
62
  class TuyaCloudApi {
57
- constructor({accessId, accessKey, region, endpoint, username, password, countryCode, schema, log} = {}) {
63
+ constructor({accessId, accessKey, region, endpoint, username, password, countryCode, schema, log, logHttp} = {}) {
58
64
  this.log = log || console;
65
+ // Dev/diagnostic switch (debug.logCloudHttp): trace every cloud HTTP
66
+ // request/response, with credentials redacted. Off by default.
67
+ this.logHttp = !!logHttp;
59
68
  this.accessId = ('' + (accessId || '')).trim();
60
69
  this.accessKey = ('' + (accessKey || '')).trim();
61
70
 
@@ -280,6 +289,46 @@ class TuyaCloudApi {
280
289
 
281
290
  /* ------------------------- transport ------------------------------- */
282
291
 
292
+ _maskSecret(value) {
293
+ const s = '' + value;
294
+ return s.length <= 8 ? '***' : `${s.slice(0, 4)}…${s.slice(-4)}`;
295
+ }
296
+
297
+ // Deep copy with any credential-looking field masked, so a full HTTP trace
298
+ // can be logged without leaking the token, signature, account password, or
299
+ // access key/id (the device `value`s themselves are not secret and stay).
300
+ _redactForLog(value) {
301
+ if (Array.isArray(value)) return value.map(v => this._redactForLog(v));
302
+ if (value && typeof value === 'object') {
303
+ const out = {};
304
+ for (const k of Object.keys(value)) {
305
+ out[k] = SECRET_FIELDS.test(k) ? this._maskSecret(value[k]) : this._redactForLog(value[k]);
306
+ }
307
+ return out;
308
+ }
309
+ return value;
310
+ }
311
+
312
+ // One redacted request/response trace line, emitted only when the
313
+ // debug.logCloudHttp switch is set. It's routine, per-request and verbose, so
314
+ // it goes to debug. Never throws and never leaks a secret (see _redactForLog).
315
+ _traceHttp(method, urlPath, headers, bodyStr, statusCode, responseBody) {
316
+ if (!this.logHttp) return;
317
+ try {
318
+ let reqBody = '(none)';
319
+ if (bodyStr) {
320
+ try { reqBody = JSON.stringify(this._redactForLog(JSON.parse(bodyStr))); }
321
+ catch (_) { reqBody = ('' + bodyStr).slice(0, 200); }
322
+ }
323
+ this.log.debug(
324
+ `[TuyaCloud HTTP] ${method} ${urlPath} → HTTP ${statusCode}` +
325
+ ` | req.headers=${JSON.stringify(this._redactForLog(headers))}` +
326
+ ` | req.body=${reqBody}` +
327
+ ` | res=${JSON.stringify(this._redactForLog(responseBody))}`
328
+ );
329
+ } catch (_) { /* logging must never throw */ }
330
+ }
331
+
283
332
  _httpsRequest(method, urlPath, headers, bodyStr) {
284
333
  return new Promise((resolve, reject) => {
285
334
  let url;
@@ -300,15 +349,22 @@ class TuyaCloudApi {
300
349
  resp.setEncoding('utf8');
301
350
  resp.on('data', chunk => { data += chunk; });
302
351
  resp.on('end', () => {
352
+ let parsed;
303
353
  try {
304
- resolve(JSON.parse(data));
354
+ parsed = JSON.parse(data);
305
355
  } catch (ex) {
306
- reject(new Error(`Tuya Cloud returned non-JSON (HTTP ${resp.statusCode}): ${('' + data).slice(0, 200)}`));
356
+ this._traceHttp(method, urlPath, headers, bodyStr, resp.statusCode, data);
357
+ return reject(new Error(`Tuya Cloud returned non-JSON (HTTP ${resp.statusCode}): ${('' + data).slice(0, 200)}`));
307
358
  }
359
+ this._traceHttp(method, urlPath, headers, bodyStr, resp.statusCode, parsed);
360
+ resolve(parsed);
308
361
  });
309
362
  });
310
363
 
311
- req.on('error', reject);
364
+ req.on('error', err => {
365
+ if (this.logHttp) { try { this.log.debug(`[TuyaCloud HTTP] ${method} ${urlPath} → error: ${err.message}`); } catch (_) { /* never throw */ } }
366
+ reject(err);
367
+ });
312
368
  req.setTimeout(20000, () => req.destroy(new Error('request timed out')));
313
369
  if (bodyStr) req.write(bodyStr);
314
370
  req.end();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-tuya-plus",
3
- "version": "3.14.0-dev.34",
3
+ "version": "3.14.0-dev.36",
4
4
  "description": "A community-maintained Homebridge plugin for controlling Tuya devices in HomeKit. LAN-first, with an optional Tuya Cloud fallback for any device the LAN can't reach.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -155,6 +155,78 @@ describe('protocol version normalization', () => {
155
155
  });
156
156
  });
157
157
 
158
+ // A wrong-length local key made crypto.createCipheriv throw "Invalid key length"
159
+ // from inside a socket callback (see _send_3_3), which goes unhandled and crashes
160
+ // Homebridge into a restart loop. An out-of-spec key must be refused clearly,
161
+ // up front, without ever reaching the cipher or taking the process down.
162
+ describe('invalid local key handling', () => {
163
+ describe('isValidLocalKey', () => {
164
+ test.each([
165
+ ['0123456789abcdef', true], // 16 ASCII chars
166
+ ['short', false],
167
+ ['0123456789abcdefXX', false], // 18 chars
168
+ ['', false],
169
+ ['012345678901234é', false], // 16 chars but 17 UTF-8 bytes
170
+ [Buffer.alloc(16), true],
171
+ [Buffer.alloc(8), false],
172
+ [null, false],
173
+ [undefined, false],
174
+ [12345678901234567, false], // not a string/Buffer
175
+ ])('isValidLocalKey(%p) === %p', (key, expected) => {
176
+ expect(TuyaAccessory.isValidLocalKey(key)).toBe(expected);
177
+ });
178
+ });
179
+
180
+ test('a wrong-length key is flagged and logged without throwing', () => {
181
+ let device;
182
+ expect(() => { device = makeDevice('3.3', {key: 'too-short'}); }).not.toThrow();
183
+ expect(device._invalidKey).toBe(true);
184
+ expect(device.log.error).toHaveBeenCalledWith(expect.stringContaining('16 characters'));
185
+ const logged = device.log.error.mock.calls.flat().join(' ');
186
+ expect(logged).toContain('Protocol Test Device');
187
+ });
188
+
189
+ test('a valid 16-character key is accepted and logs no error', () => {
190
+ const device = makeDevice('3.3');
191
+ expect(device._invalidKey).toBe(false);
192
+ expect(device.log.error).not.toHaveBeenCalled();
193
+ });
194
+
195
+ test('_connect() opens no socket for an invalid-key device', () => {
196
+ const device = makeDevice('3.5', {key: 'bad'});
197
+ device._connect();
198
+ expect(device._socket).toBeUndefined();
199
+ expect(device.connected).toBe(false);
200
+ });
201
+
202
+ // The exact path that used to crash: a connected device whose update() flows
203
+ // into _send -> _send_3_x -> createCipheriv. It must short-circuit to false.
204
+ test.each(['3.1', '3.3', '3.4', '3.5'])(
205
+ 'update() never reaches the cipher for an invalid-key %s device, even when connected',
206
+ version => {
207
+ const device = makeDevice(version, {key: 'nope'});
208
+ const written = attachSocketStub(device); // also forces connected = true
209
+
210
+ let result;
211
+ expect(() => { result = device.update({1: true}); }).not.toThrow();
212
+ expect(result).toBe(false);
213
+ expect(written).toHaveLength(0);
214
+ }
215
+ );
216
+
217
+ test('a fake device without a key is never treated as invalid', () => {
218
+ const device = new TuyaAccessory({
219
+ id: 'bf1234567890abcdef12',
220
+ name: 'Fake Device',
221
+ fake: true,
222
+ connect: false,
223
+ log: makeLog(),
224
+ });
225
+ expect(device._invalidKey).toBe(false);
226
+ expect(device.log.error).not.toHaveBeenCalled();
227
+ });
228
+ });
229
+
158
230
  describe('message handler routing', () => {
159
231
  const HANDLERS = ['_msgHandler_3_1', '_msgHandler_3_3', '_msgHandler_3_4', '_msgHandler_3_5'];
160
232
 
@@ -219,3 +219,58 @@ describe('TuyaCloudApi — endpoints', () => {
219
219
  await expect(api.request('GET', '/x')).rejects.toThrow(/no permissions/);
220
220
  });
221
221
  });
222
+
223
+ describe('TuyaCloudApi — HTTP trace (debug.logCloudHttp)', () => {
224
+ const makeLog = () => ({info: () => {}, warn: () => {}, error: () => {}, debug: jest.fn()});
225
+
226
+ test('logHttp is off unless explicitly enabled', () => {
227
+ expect(makeApi().logHttp).toBe(false);
228
+ expect(makeApi({logHttp: true}).logHttp).toBe(true);
229
+ });
230
+
231
+ test('redaction masks credentials but keeps device data', () => {
232
+ const api = makeApi();
233
+ const redacted = api._redactForLog({
234
+ client_id: 'aid1234567890', access_token: 'tok_abcdef123456', sign: 'SIGNATUREVALUE', t: '1700', nonce: 'n-1',
235
+ result: {access_token: 'inner-token-xyz', refresh_token: 'refresh-xyz', uid: 'uid-1234567', online: true},
236
+ commands: [{code: 'switch_1', value: true}]
237
+ });
238
+
239
+ expect(redacted.client_id).not.toBe('aid1234567890');
240
+ expect(redacted.access_token).not.toBe('tok_abcdef123456');
241
+ expect(redacted.sign).not.toBe('SIGNATUREVALUE');
242
+ expect(redacted.result.access_token).not.toBe('inner-token-xyz');
243
+ expect(redacted.result.refresh_token).not.toBe('refresh-xyz');
244
+ expect(redacted.result.uid).not.toBe('uid-1234567');
245
+ // Non-secret fields and the actual device data-points pass through intact.
246
+ expect(redacted.t).toBe('1700');
247
+ expect(redacted.nonce).toBe('n-1');
248
+ expect(redacted.result.online).toBe(true);
249
+ expect(redacted.commands).toEqual([{code: 'switch_1', value: true}]);
250
+ });
251
+
252
+ test('no trace is logged when the switch is off', () => {
253
+ const log = makeLog();
254
+ const api = makeApi({log});
255
+ api._traceHttp('POST', '/v1.0/devices/dev/commands', {sign: 'X'}, '{"commands":[]}', 200, {success: true});
256
+ expect(log.debug).not.toHaveBeenCalled();
257
+ });
258
+
259
+ test('a trace carries the request/response but never the raw token or signature', () => {
260
+ const log = makeLog();
261
+ const api = makeApi({log, logHttp: true});
262
+ const headers = {client_id: 'aid', sign: 'SIGN_SECRET_VALUE', access_token: 'TOKEN_SECRET_VALUE', t: '1', nonce: 'n', 'Content-Type': 'application/json'};
263
+ const body = JSON.stringify({commands: [{code: 'wfh_open', value: true}]});
264
+
265
+ api._traceHttp('POST', '/v1.0/devices/dev/commands', headers, body, 200, {success: false, code: 2008, msg: 'command or value not support'});
266
+
267
+ expect(log.debug).toHaveBeenCalledTimes(1);
268
+ const line = log.debug.mock.calls[0][0];
269
+ expect(line).toContain('POST /v1.0/devices/dev/commands');
270
+ expect(line).toContain('wfh_open'); // the attempted command is visible
271
+ expect(line).toContain('2008'); // and the cloud's verdict
272
+ expect(line).toContain('command or value not support');
273
+ expect(line).not.toContain('TOKEN_SECRET_VALUE'); // …but secrets are not
274
+ expect(line).not.toContain('SIGN_SECRET_VALUE');
275
+ });
276
+ });