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

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.
@@ -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/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.35",
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