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 +6 -0
- package/index.js +5 -1
- package/lib/TuyaAccessory.js +28 -0
- package/lib/TuyaCloudApi.js +60 -4
- package/package.json +1 -1
- package/test/TuyaAccessory.protocol.test.js +72 -0
- package/test/TuyaCloudApi.test.js +55 -0
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({
|
|
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,
|
package/lib/TuyaAccessory.js
CHANGED
|
@@ -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);
|
package/lib/TuyaCloudApi.js
CHANGED
|
@@ -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
|
-
|
|
354
|
+
parsed = JSON.parse(data);
|
|
305
355
|
} catch (ex) {
|
|
306
|
-
|
|
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',
|
|
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.
|
|
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
|
+
});
|