• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codetheweb / tuyapi / 14993741621

13 May 2025 09:59AM UTC coverage: 58.014% (-0.3%) from 58.291%
14993741621

push

github

web-flow
Merge pull request #690 from codetheweb/optimizations2701

Optimize 3.5 detection and prevent crashes

208 of 364 branches covered (57.14%)

Branch coverage included in aggregate %.

10 of 23 new or added lines in 3 files covered. (43.48%)

1 existing line in 1 file now uncovered.

382 of 653 relevant lines covered (58.5%)

129.76 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

40.48
/lib/cipher.js
1
const crypto = require('crypto');
35✔
2
/**
3
* Low-level class for encrypting and decrypting payloads.
4
* @class
5
* @param {Object} options - Options for the cipher.
6
* @param {String} options.key localKey of cipher
7
* @param {Number} options.version protocol version
8
* @example
9
* const cipher = new TuyaCipher({key: 'xxxxxxxxxxxxxxxx', version: 3.1})
10
*/
11
class TuyaCipher {
12
  constructor(options) {
13
    this.sessionKey = null;
196✔
14
    this.key = options.key;
196✔
15
    this.version = options.version.toString();
196✔
16
  }
17

18
  /**
19
   * Sets the session key used for Protocol 3.4, 3.5
20
   * @param {Buffer} sessionKey Session key
21
   */
22
  setSessionKey(sessionKey) {
23
    this.sessionKey = sessionKey;
56✔
24
  }
25

26
  /**
27
  * Encrypts data.
28
  * @param {Object} options Options for encryption
29
  * @param {String} options.data data to encrypt
30
  * @param {Boolean} [options.base64=true] `true` to return result in Base64
31
  * @example
32
  * TuyaCipher.encrypt({data: 'hello world'})
33
  * @returns {Buffer|String} returns Buffer unless options.base64 is true
34
  */
35
  encrypt(options) {
36
    if (this.version === '3.4') {
42!
37
      return this._encrypt34(options);
×
38
    }
39

40
    if (this.version === '3.5') {
42!
41
      return this._encrypt35(options);
×
42
    }
43

44
    return this._encryptPre34(options);
42✔
45
  }
46

47
  /**
48
   * Encrypt data for protocol 3.3 and before
49
   * @param {Object} options Options for encryption
50
   * @param {String} options.data data to encrypt
51
   * @param {Boolean} [options.base64=true] `true` to return result in Base64
52
   * @returns {Buffer|String} returns Buffer unless options.base64 is true
53
   */
54
  _encryptPre34(options) {
55
    const cipher = crypto.createCipheriv('aes-128-ecb', this.getKey(), '');
42✔
56

57
    let encrypted = cipher.update(options.data, 'utf8', 'base64');
42✔
58
    encrypted += cipher.final('base64');
42✔
59

60
    // Default base64 enable
61
    if (options.base64 === false) {
42✔
62
      return Buffer.from(encrypted, 'base64');
21✔
63
    }
64

65
    return encrypted;
21✔
66
  }
67

68
  /**
69
   * Encrypt data for protocol 3.4
70
   * @param {Object} options Options for encryption
71
   * @param {String} options.data data to encrypt
72
   * @param {Boolean} [options.base64=true] `true` to return result in Base64
73
   * @returns {Buffer|String} returns Buffer unless options.base64 is true
74
   */
75
  _encrypt34(options) {
76
    const cipher = crypto.createCipheriv('aes-128-ecb', this.getKey(), null);
×
77
    cipher.setAutoPadding(false);
×
78
    const encrypted = cipher.update(options.data);
×
79
    cipher.final();
×
80

81
    // Default base64 enable TODO: check if this is needed?
82
    // if (options.base64 === false) {
83
    //   return Buffer.from(encrypted, 'base64');
84
    // }
85

86
    return encrypted;
×
87
  }
88

89
  /**
90
   * Encrypt data for protocol 3.5
91
   * @param {Object} options Options for encryption
92
   * @param {String} options.data data to encrypt
93
   * @param {Boolean} [options.base64=true] `true` to return result in Base64
94
   * @returns {Buffer|String} returns Buffer unless options.base64 is true
95
   */
96
  _encrypt35(options) {
97
    let encrypted;
98
    let localIV = Buffer.from((Date.now() * 10).toString().slice(0, 12));
×
99
    if (options.iv !== undefined) {
×
100
      localIV = options.iv.slice(0, 12);
×
101
    }
102

103
    const cipher = crypto.createCipheriv('aes-128-gcm', this.getKey(), localIV);
×
104
    if (options.aad === undefined) {
×
105
      encrypted = Buffer.concat([cipher.update(options.data), cipher.final()]);
×
106
    } else {
107
      cipher.setAAD(options.aad);
×
108
      encrypted = Buffer.concat([localIV, cipher.update(options.data), cipher.final(), cipher.getAuthTag(), Buffer.from([0x00, 0x00, 0x99, 0x66])]);
×
109
    }
110

111
    return encrypted;
×
112
  }
113

114
  /**
115
   * Decrypts data.
116
   * @param {String|Buffer} data to decrypt
117
   * @param {String} [version] protocol version
118
   * @returns {Object|String}
119
   * returns object if data is JSON, else returns string
120
   */
121
  decrypt(data, version) {
122
    version = version || this.version;
218✔
123
    if (version === '3.4') {
218!
UNCOV
124
      return this._decrypt34(data);
×
125
    }
126

127
    if (version === '3.5') {
218!
128
      return this._decrypt35(data);
×
129
    }
130

131
    return this._decryptPre34(data);
218✔
132
  }
133

134
  /**
135
   * Decrypts data for protocol 3.3 and before
136
   * @param {String|Buffer} data to decrypt
137
   * @returns {Object|String}
138
   * returns object if data is JSON, else returns string
139
   */
140
  _decryptPre34(data) {
141
    // Incoming data format
142
    let format = 'buffer';
218✔
143

144
    if (data.indexOf(this.version) === 0) {
218✔
145
      if (this.version === '3.3' || this.version === '3.2') {
21✔
146
        // Remove 3.3/3.2 header
147
        data = data.slice(15);
7✔
148
      } else {
149
        // Data has version number and is encoded in base64
150

151
        // Remove prefix of version number and MD5 hash
152
        data = data.slice(19).toString();
14✔
153
        // Decode incoming data as base64
154
        format = 'base64';
14✔
155
      }
156
    }
157

158
    // Decrypt data
159
    let result;
160
    try {
218✔
161
      const decipher = crypto.createDecipheriv('aes-128-ecb', this.getKey(), '');
218✔
162
      result = decipher.update(data, format, 'utf8');
218✔
163
      result += decipher.final('utf8');
218✔
164
    } catch (_) {
165
      throw new Error('Decrypt failed');
183✔
166
    }
167

168
    // Try to parse data as JSON,
169
    // otherwise return as string.
170
    try {
35✔
171
      return JSON.parse(result);
35✔
172
    } catch (_) {
173
      return result;
7✔
174
    }
175
  }
176

177
  /**
178
   * Decrypts data for protocol 3.4
179
   * @param {String|Buffer} data to decrypt
180
   * @returns {Object|String}
181
   * returns object if data is JSON, else returns string
182
   */
183
  _decrypt34(data) {
184
    let result;
185
    try {
×
186
      const decipher = crypto.createDecipheriv('aes-128-ecb', this.getKey(), null);
×
187
      decipher.setAutoPadding(false);
×
188
      result = decipher.update(data);
×
189
      decipher.final();
×
190
      // Remove padding
191
      result = result.slice(0, (result.length - result[result.length - 1]));
×
192
    } catch (_) {
193
      throw new Error('Decrypt failed');
×
194
    }
195

196
    // Try to parse data as JSON,
197
    // otherwise return as string.
198
    // 3.4 protocol
199
    // {"protocol":4,"t":1632405905,"data":{"dps":{"101":true},"cid":"00123456789abcde"}}
200
    try {
×
201
      if (result.indexOf(this.version) === 0) {
×
202
        result = result.slice(15);
×
203
      }
204

205
      const res = JSON.parse(result);
×
206
      if ('data' in res) {
×
207
        const resData = res.data;
×
208
        resData.t = res.t;
×
209
        return resData; // Or res.data // for compatibility with tuya-mqtt
×
210
      }
211

212
      return res;
×
213
    } catch (_) {
214
      return result;
×
215
    }
216
  }
217

218
  /**
219
   * Decrypts data for protocol 3.5
220
   * @param {String|Buffer} data to decrypt
221
   * @returns {Object|String}
222
   * returns object if data is JSON, else returns string
223
   */
224
  _decrypt35(data) {
225
    let result;
226
    const header = data.slice(0, 14);
×
227
    const iv = data.slice(14, 26);
×
228
    const tag = data.slice(data.length - 16);
×
229
    data = data.slice(26, data.length - 16);
×
230

231
    try {
×
232
      const decipher = crypto.createDecipheriv('aes-128-gcm', this.getKey(), iv);
×
233
      decipher.setAuthTag(tag);
×
234
      decipher.setAAD(header);
×
235

236
      result = Buffer.concat([decipher.update(data), decipher.final()]);
×
237
      result = result.slice(4); // Remove 32bit return code
×
238
    } catch (_) {
239
      throw new Error('Decrypt failed');
×
240
    }
241

242
    // Try to parse data as JSON, otherwise return as string.
243
    // 3.5 protocol
244
    // {"protocol":4,"t":1632405905,"data":{"dps":{"101":true},"cid":"00123456789abcde"}}
245
    try {
×
246
      if (result.indexOf(this.version) === 0) {
×
247
        result = result.slice(15);
×
248
      }
249

250
      const res = JSON.parse(result);
×
251
      if ('data' in res) {
×
252
        const resData = res.data;
×
253
        resData.t = res.t;
×
254
        return resData; // Or res.data // for compatibility with tuya-mqtt
×
255
      }
256

257
      return res;
×
258
    } catch (_) {
259
      return result;
×
260
    }
261
  }
262

263
  /**
264
  * Calculates a MD5 hash.
265
  * @param {String} data to hash
266
  * @returns {String} characters 8 through 16 of hash of data
267
  */
268
  md5(data) {
269
    const md5hash = crypto.createHash('md5').update(data, 'utf8').digest('hex');
21✔
270
    return md5hash.slice(8, 24);
21✔
271
  }
272

273
  /**
274
   * Gets the key used for encryption/decryption
275
   * @returns {String} sessionKey (if set for protocol 3.4, 3.5) or key
276
   */
277
  getKey() {
278
    return this.sessionKey === null ? this.key : this.sessionKey;
260!
279
  }
280

281
  /**
282
   * Returns the HMAC for the current key (sessionKey if set for protocol 3.4, 3.5 or key)
283
   * @param {Buffer} data data to hash
284
   * @returns {Buffer} HMAC
285
   */
286
  hmac(data) {
287
    return crypto.createHmac('sha256', this.getKey()).update(data, 'utf8').digest(); // .digest('hex');
×
288
  }
289

290
  /**
291
   * Returns 16 random bytes
292
   * @returns {Buffer} Random bytes
293
   */
294
  random() {
295
    return crypto.randomBytes(16);
×
296
  }
297
}
298
module.exports = TuyaCipher;
35✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc