@lumiastream/tapo-cove 3.11.0 → 3.12.1

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/CHANGELOG.md CHANGED
@@ -3,6 +3,22 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [3.12.1](https://github.com/lumiastream/rgb/compare/v3.12.0...v3.12.1) (2023-10-26)
7
+
8
+ ### Bug Fixes
9
+
10
+ - tapo host set error ([6437ed3](https://github.com/lumiastream/rgb/commit/6437ed3f0a8f44fbb9ddad2b78d815f71deae7b0))
11
+
12
+ # [3.12.0](https://github.com/lumiastream/rgb/compare/v3.11.2...v3.12.0) (2023-10-25)
13
+
14
+ ### Bug Fixes
15
+
16
+ - tapo build error ([7dd7658](https://github.com/lumiastream/rgb/commit/7dd7658e391108e6aa6c2e4ec28aab47d183f4e5))
17
+
18
+ ### Features
19
+
20
+ - tapo new klap cipher implementation ([a82c8f8](https://github.com/lumiastream/rgb/commit/a82c8f8c300108e76494b0ba264a0710537dc386))
21
+
6
22
  # [3.11.0](https://github.com/lumiastream/rgb/compare/v3.10.1...v3.11.0) (2023-08-15)
7
23
 
8
24
  **Note:** Version bump only for package @lumiastream/tapo-cove
package/dist/index.d.mts CHANGED
@@ -58,22 +58,37 @@ declare class LightState extends SuperState {
58
58
  }) => this;
59
59
  }
60
60
 
61
- type TapoDeviceKey = {
62
- key: string | Buffer;
63
- iv: string | Buffer;
64
- deviceIp: string;
65
- sessionCookie: string;
66
- token?: string;
67
- };
61
+ declare class KlapCipher {
62
+ private readonly key;
63
+ private readonly sig;
64
+ private readonly iv;
65
+ private seq;
66
+ constructor(localSeed: Buffer, remoteSeed: Buffer, authHash: Buffer);
67
+ encrypt(msg: Buffer | string): {
68
+ encrypted: Buffer;
69
+ seq: number;
70
+ };
71
+ decrypt(msg: Buffer): string;
72
+ private keyDerive;
73
+ private ivDerive;
74
+ private sigDerive;
75
+ private ivSeq;
76
+ }
68
77
 
69
78
  declare class TapoApi {
79
+ protected readonly terminalUUID: string;
80
+ protected loginToken?: string;
70
81
  _baseUrl: string;
71
- _baseTapoCareUrl: string;
82
+ private static readonly TP_TEST_USER;
83
+ private static readonly TP_TEST_PASSWORD;
84
+ private session?;
72
85
  private axiosInstance;
73
86
  private _config;
74
87
  private _token;
75
88
  private _devices;
76
89
  constructor(config?: {
90
+ email?: string;
91
+ password?: string;
77
92
  token?: string;
78
93
  timeout?: number;
79
94
  httpTimeout?: number;
@@ -89,7 +104,7 @@ declare class TapoApi {
89
104
  id: string;
90
105
  host: string;
91
106
  }>;
92
- }) => Promise<Map<string, TapoDeviceKey>>;
107
+ }) => Promise<Map<string, DeviceSession>>;
93
108
  sendState: (config: {
94
109
  device: {
95
110
  id: string;
@@ -105,6 +120,26 @@ declare class TapoApi {
105
120
  };
106
121
  power: boolean;
107
122
  }) => void;
123
+ private sessionPost;
124
+ needsNewHandshake(): boolean;
125
+ private handshake;
126
+ private firstHandshake;
127
+ private secondHandshake;
128
+ private sha256;
129
+ private sha1;
130
+ private hashAuth;
131
+ }
132
+ declare class DeviceSession {
133
+ ip: string;
134
+ private readonly cookie;
135
+ readonly cipher?: KlapCipher | undefined;
136
+ readonly handshakeCompleted: boolean;
137
+ private readonly expireAt;
138
+ private readonly rawTimeout;
139
+ constructor(timeout: string, ip: string, cookie: string, cipher?: KlapCipher | undefined);
140
+ get IsExpired(): boolean;
141
+ get Cookie(): string;
142
+ completeHandshake(ip: string, cipher: KlapCipher): DeviceSession;
108
143
  }
109
144
 
110
145
  declare const TapoDeviceTypes: {
package/dist/index.d.ts CHANGED
@@ -58,22 +58,37 @@ declare class LightState extends SuperState {
58
58
  }) => this;
59
59
  }
60
60
 
61
- type TapoDeviceKey = {
62
- key: string | Buffer;
63
- iv: string | Buffer;
64
- deviceIp: string;
65
- sessionCookie: string;
66
- token?: string;
67
- };
61
+ declare class KlapCipher {
62
+ private readonly key;
63
+ private readonly sig;
64
+ private readonly iv;
65
+ private seq;
66
+ constructor(localSeed: Buffer, remoteSeed: Buffer, authHash: Buffer);
67
+ encrypt(msg: Buffer | string): {
68
+ encrypted: Buffer;
69
+ seq: number;
70
+ };
71
+ decrypt(msg: Buffer): string;
72
+ private keyDerive;
73
+ private ivDerive;
74
+ private sigDerive;
75
+ private ivSeq;
76
+ }
68
77
 
69
78
  declare class TapoApi {
79
+ protected readonly terminalUUID: string;
80
+ protected loginToken?: string;
70
81
  _baseUrl: string;
71
- _baseTapoCareUrl: string;
82
+ private static readonly TP_TEST_USER;
83
+ private static readonly TP_TEST_PASSWORD;
84
+ private session?;
72
85
  private axiosInstance;
73
86
  private _config;
74
87
  private _token;
75
88
  private _devices;
76
89
  constructor(config?: {
90
+ email?: string;
91
+ password?: string;
77
92
  token?: string;
78
93
  timeout?: number;
79
94
  httpTimeout?: number;
@@ -89,7 +104,7 @@ declare class TapoApi {
89
104
  id: string;
90
105
  host: string;
91
106
  }>;
92
- }) => Promise<Map<string, TapoDeviceKey>>;
107
+ }) => Promise<Map<string, DeviceSession>>;
93
108
  sendState: (config: {
94
109
  device: {
95
110
  id: string;
@@ -105,6 +120,26 @@ declare class TapoApi {
105
120
  };
106
121
  power: boolean;
107
122
  }) => void;
123
+ private sessionPost;
124
+ needsNewHandshake(): boolean;
125
+ private handshake;
126
+ private firstHandshake;
127
+ private secondHandshake;
128
+ private sha256;
129
+ private sha1;
130
+ private hashAuth;
131
+ }
132
+ declare class DeviceSession {
133
+ ip: string;
134
+ private readonly cookie;
135
+ readonly cipher?: KlapCipher | undefined;
136
+ readonly handshakeCompleted: boolean;
137
+ private readonly expireAt;
138
+ private readonly rawTimeout;
139
+ constructor(timeout: string, ip: string, cookie: string, cipher?: KlapCipher | undefined);
140
+ get IsExpired(): boolean;
141
+ get Cookie(): string;
142
+ completeHandshake(ip: string, cipher: KlapCipher): DeviceSession;
108
143
  }
109
144
 
110
145
  declare const TapoDeviceTypes: {
package/dist/index.js CHANGED
@@ -73,70 +73,19 @@ module.exports = __toCommonJS(src_exports);
73
73
 
74
74
  // src/discovery.ts
75
75
  var import_lumia_rgb_types = require("@lumiastream/lumia-rgb-types");
76
- var import_axios3 = __toESM(require("axios"));
76
+ var import_axios2 = __toESM(require("axios"));
77
77
  var import_local_devices = __toESM(require("local-devices"));
78
78
 
79
79
  // src/shared/cipher.ts
80
80
  var import_crypto = __toESM(require("crypto"));
81
- var import_util = __toESM(require("util"));
82
- var RSA_CIPHER_ALGORITHM = "rsa";
83
- var AES_CIPHER_ALGORITHM = "aes-128-cbc";
84
- var PASSPHRASE = "top secret";
85
- var generateKeyPair = () => __async(void 0, null, function* () {
86
- const RSA_OPTIONS = {
87
- modulusLength: 1024,
88
- publicKeyEncoding: {
89
- type: "spki",
90
- format: "pem"
91
- },
92
- privateKeyEncoding: {
93
- type: "pkcs1",
94
- format: "pem",
95
- cipher: "aes-256-cbc",
96
- passphrase: PASSPHRASE
97
- }
98
- };
99
- const generateKeyPair2 = import_util.default.promisify(import_crypto.default.generateKeyPair);
100
- return generateKeyPair2(RSA_CIPHER_ALGORITHM, RSA_OPTIONS);
101
- });
102
- var encrypt = (data, deviceKey) => {
103
- var cipher = import_crypto.default.createCipheriv(AES_CIPHER_ALGORITHM, deviceKey.key, deviceKey.iv);
104
- var ciphertext = cipher.update(Buffer.from(JSON.stringify(data)));
105
- return Buffer.concat([ciphertext, cipher.final()]).toString("base64");
106
- };
107
- var decrypt = (data, deviceKey) => {
108
- var cipher = import_crypto.default.createDecipheriv(AES_CIPHER_ALGORITHM, deviceKey.key, deviceKey.iv);
109
- var ciphertext = cipher.update(Buffer.from(data, "base64"));
110
- return JSON.parse(Buffer.concat([ciphertext, cipher.final()]).toString());
111
- };
112
- var readDeviceKey = (pemKey, privateKey) => {
113
- const keyBytes = Buffer.from(pemKey, "base64");
114
- const deviceKey = import_crypto.default.privateDecrypt(
115
- {
116
- key: privateKey,
117
- padding: import_crypto.default.constants.RSA_PKCS1_PADDING,
118
- passphrase: PASSPHRASE
119
- },
120
- keyBytes
121
- );
122
- return deviceKey;
123
- };
124
- var base64Encode = (data) => {
125
- return Buffer.from(data).toString("base64");
126
- };
127
81
  var base64Decode = (data) => {
128
82
  return Buffer.from(data, "base64").toString();
129
83
  };
130
- var shaDigest = (data) => {
131
- var shasum = import_crypto.default.createHash("sha1");
132
- shasum.update(data);
133
- return shasum.digest("hex");
134
- };
135
84
 
136
85
  // src/shared/helpers.ts
137
- var import_axios = __toESM(require("axios"));
138
86
  var throwErrorIfFound = (responseData) => {
139
87
  const errorCode = responseData["error_code"];
88
+ console.debug("[Tapo] errorCode: ", errorCode);
140
89
  if (errorCode) {
141
90
  switch (errorCode) {
142
91
  case 0:
@@ -162,64 +111,9 @@ var throwErrorIfFound = (responseData) => {
162
111
  }
163
112
  }
164
113
  };
165
- var handshake = (deviceIp) => __async(void 0, null, function* () {
166
- var _a, _b, _c, _d;
167
- const keyPair = yield generateKeyPair();
168
- const handshakeRequest = {
169
- method: "handshake",
170
- params: {
171
- key: keyPair.publicKey
172
- }
173
- };
174
- const response = yield (globalThis.nodeAxios ? globalThis.nodeAxios : import_axios.default)({
175
- method: "post",
176
- url: `http://${deviceIp}/app`,
177
- data: handshakeRequest,
178
- timeout: 3e3
179
- });
180
- throwErrorIfFound(response.data);
181
- let setCookieHeader;
182
- if (response.headers["set-cookie"]) {
183
- setCookieHeader = (_b = (_a = response.headers["set-cookie"]) == null ? void 0 : _a[0]) != null ? _b : response.headers["set-cookie"];
184
- } else if (response.headers["bypass-cookie"]) {
185
- setCookieHeader = (_d = (_c = response.headers["bypass-cookie"]) == null ? void 0 : _c[0]) != null ? _d : response.headers["bypass-cookie"];
186
- } else {
187
- setCookieHeader = response.headers.get("set-cookie");
188
- }
189
- const sessionCookie = setCookieHeader.substring(0, setCookieHeader.indexOf(";"));
190
- const deviceKey = readDeviceKey(response.data.result.key, keyPair.privateKey);
191
- return {
192
- key: deviceKey.subarray(0, 16),
193
- iv: deviceKey.subarray(16, 32),
194
- deviceIp,
195
- sessionCookie
196
- };
197
- });
198
- var securePassthrough = (deviceRequest, deviceKey) => __async(void 0, null, function* () {
199
- const encryptedRequest = encrypt(deviceRequest, deviceKey);
200
- const securePassthroughRequest = {
201
- method: "securePassthrough",
202
- params: {
203
- request: encryptedRequest
204
- }
205
- };
206
- const response = yield (globalThis.nodeAxios ? globalThis.nodeAxios : import_axios.default)({
207
- method: "post",
208
- url: `http://${deviceKey.deviceIp}/app?token=${deviceKey.token}`,
209
- data: securePassthroughRequest,
210
- timeout: 3e3,
211
- headers: {
212
- Cookie: deviceKey.sessionCookie
213
- }
214
- });
215
- throwErrorIfFound(response.data);
216
- const decryptedResponse = decrypt(response.data.result.response, deviceKey);
217
- throwErrorIfFound(decryptedResponse);
218
- return decryptedResponse.result;
219
- });
220
114
 
221
115
  // src/tapo-api.ts
222
- var import_axios2 = __toESM(require("axios"));
116
+ var import_axios = __toESM(require("axios"));
223
117
 
224
118
  // src/lightstate.ts
225
119
  var import_lumia_rgb_utils = require("@lumiastream/lumia-rgb-utils");
@@ -336,14 +230,140 @@ var LightState = class extends SuperState {
336
230
  };
337
231
 
338
232
  // src/tapo-api.ts
339
- var import_fetch_cove = require("@lumiastream/fetch-cove");
340
233
  var import_lumia_rgb_utils2 = require("@lumiastream/lumia-rgb-utils");
341
- var TapoApi = class {
234
+
235
+ // src/shared/tplink-cypher.ts
236
+ var import_crypto2 = __toESM(require("crypto"));
237
+ var TpLinkCipher = class {
238
+ constructor(key, iv) {
239
+ this.key = key;
240
+ this.iv = iv;
241
+ }
242
+ static toBase64(data) {
243
+ return Buffer.from(data.normalize("NFKC"), "utf-8").toString("base64");
244
+ }
245
+ static encodeUsername(data) {
246
+ const sha = import_crypto2.default.createHash("sha1");
247
+ sha.update(data.normalize("NFKC"));
248
+ return sha.digest("hex");
249
+ }
250
+ static createKeyPair() {
251
+ return new Promise((resolve, reject) => {
252
+ import_crypto2.default.generateKeyPair(
253
+ "rsa",
254
+ {
255
+ modulusLength: 1024
256
+ },
257
+ (err, publicK, privateK) => {
258
+ if (err) {
259
+ return reject(err);
260
+ }
261
+ const pub = publicK.export({
262
+ format: "pem",
263
+ type: "spki"
264
+ }).toString("base64");
265
+ const priv = privateK.export({
266
+ format: "pem",
267
+ type: "pkcs1"
268
+ }).toString("base64");
269
+ resolve({
270
+ public: pub,
271
+ private: priv
272
+ });
273
+ }
274
+ );
275
+ });
276
+ }
277
+ encrypt(data) {
278
+ const cipher = import_crypto2.default.createCipheriv("aes-128-cbc", this.key, this.iv);
279
+ const encrypted = cipher.update(data, "utf8", "base64");
280
+ return `${encrypted}${cipher.final("base64")}`;
281
+ }
282
+ decrypt(data) {
283
+ const decipher = import_crypto2.default.createDecipheriv("aes-128-cbc", this.key, this.iv);
284
+ const decrypted = decipher.update(data, "base64", "utf8");
285
+ return `${decrypted}${decipher.final("utf8")}`;
286
+ }
287
+ };
288
+
289
+ // src/tapo-api.ts
290
+ var import_crypto4 = __toESM(require("crypto"));
291
+ var import_http = __toESM(require("http"));
292
+
293
+ // src/shared/klap-cipher.ts
294
+ var import_crypto3 = __toESM(require("crypto"));
295
+ var KlapCipher = class {
296
+ constructor(localSeed, remoteSeed, authHash) {
297
+ const { iv, seq } = this.ivDerive(localSeed, remoteSeed, authHash);
298
+ this.key = this.keyDerive(localSeed, remoteSeed, authHash);
299
+ this.sig = this.sigDerive(localSeed, remoteSeed, authHash);
300
+ this.iv = iv;
301
+ this.seq = seq;
302
+ }
303
+ encrypt(msg) {
304
+ this.seq += 1;
305
+ if (typeof msg === "string") {
306
+ msg = Buffer.from(msg, "utf8");
307
+ }
308
+ if (!Buffer.isBuffer(msg)) {
309
+ throw new Error("msg must be a string or buffer");
310
+ }
311
+ const cipher = import_crypto3.default.createCipheriv("aes-128-cbc", this.key, this.ivSeq());
312
+ const cipherText = Buffer.concat([cipher.update(msg), cipher.final()]);
313
+ const seqBuffer = Buffer.alloc(4);
314
+ seqBuffer.writeInt32BE(this.seq, 0);
315
+ const hash = import_crypto3.default.createHash("sha256");
316
+ hash.update(Buffer.concat([this.sig, seqBuffer, cipherText]));
317
+ const signature = hash.digest();
318
+ return {
319
+ encrypted: Buffer.concat([signature, cipherText]),
320
+ seq: this.seq
321
+ };
322
+ }
323
+ decrypt(msg) {
324
+ if (!Buffer.isBuffer(msg)) {
325
+ throw new Error("msg must be a buffer");
326
+ }
327
+ const decipher = import_crypto3.default.createDecipheriv("aes-128-cbc", this.key, this.ivSeq());
328
+ const decrypted = Buffer.concat([decipher.update(msg.subarray(32)), decipher.final()]);
329
+ return decrypted.toString("utf8");
330
+ }
331
+ keyDerive(l, r, h) {
332
+ const payload = Buffer.concat([Buffer.from("lsk"), l, r, h]);
333
+ const hash = import_crypto3.default.createHash("sha256").update(payload).digest();
334
+ return hash.subarray(0, 16);
335
+ }
336
+ ivDerive(l, r, h) {
337
+ const payload = Buffer.concat([Buffer.from("iv"), l, r, h]);
338
+ const fullIv = import_crypto3.default.createHash("sha256").update(payload).digest();
339
+ const seq = fullIv.subarray(-4).readInt32BE(0);
340
+ return { iv: fullIv.subarray(0, 12), seq };
341
+ }
342
+ sigDerive(l, r, h) {
343
+ const payload = Buffer.concat([Buffer.from("ldk"), l, r, h]);
344
+ const hash = import_crypto3.default.createHash("sha256").update(payload).digest();
345
+ return hash.subarray(0, 28);
346
+ }
347
+ ivSeq() {
348
+ const seq = Buffer.alloc(4);
349
+ seq.writeInt32BE(this.seq, 0);
350
+ const iv = Buffer.concat([this.iv, seq]);
351
+ if (iv.length !== 16) {
352
+ throw new Error("Length of iv is not 16");
353
+ }
354
+ return iv;
355
+ }
356
+ };
357
+
358
+ // src/tapo-api.ts
359
+ var _TapoApi = class _TapoApi {
342
360
  constructor(config) {
343
361
  this._baseUrl = "https://eu-wap.tplinkcloud.com/";
344
- // https://n-euw1-wap-gw.tplinkcloud.com
345
- this._baseTapoCareUrl = "https://euw1-app-tapo-care.i.tplinknbu.com";
346
362
  this._config = {
363
+ rawEmail: "",
364
+ rawPassword: "",
365
+ email: "",
366
+ password: "",
347
367
  authToken: null,
348
368
  timeout: 1e4,
349
369
  httpTimeout: 4e3
@@ -360,7 +380,7 @@ var TapoApi = class {
360
380
  terminalUUID: (0, import_lumia_rgb_utils2.uuidv4)()
361
381
  }
362
382
  };
363
- const response = yield (0, import_axios2.default)({
383
+ const response = yield (0, import_axios.default)({
364
384
  method: "post",
365
385
  url: this._baseUrl,
366
386
  data: loginRequest
@@ -371,65 +391,204 @@ var TapoApi = class {
371
391
  });
372
392
  this.setup = (_0) => __async(this, [_0], function* ({ email, password, devices }) {
373
393
  const promises = devices.map((device) => __async(this, null, function* () {
374
- const deviceKey = yield handshake(device.host);
375
- const loginDeviceRequest = {
376
- method: "login_device",
377
- params: {
378
- username: base64Encode(shaDigest(email)),
379
- password: base64Encode(password)
380
- },
381
- requestTimeMils: 0
382
- };
383
- const loginDeviceResponse = yield securePassthrough(loginDeviceRequest, deviceKey);
384
- deviceKey.token = loginDeviceResponse.token;
385
- this._devices.set(device.id, deviceKey);
394
+ const deviceSession = yield this.handshake(device.host);
395
+ this._devices.set(device.id, deviceSession);
386
396
  return;
387
397
  }));
388
- yield Promise.allSettled(promises);
398
+ try {
399
+ const responses = yield Promise.allSettled(promises);
400
+ } catch (err) {
401
+ console.error("tapo setup err: ", err);
402
+ }
389
403
  return this._devices;
390
404
  });
391
405
  // Change state using lightstate
392
- this.sendState = (config) => {
406
+ this.sendState = (config) => __async(this, null, function* () {
393
407
  const deviceKey = this._devices.get(config.device.id);
408
+ if (!deviceKey) {
409
+ return Promise.resolve(false);
410
+ }
411
+ yield this.handshake(deviceKey == null ? void 0 : deviceKey.ip, false);
394
412
  let shouldWait = false;
395
413
  if (config.fetchConfig && config.fetchConfig.shouldWait) {
396
414
  shouldWait = true;
397
415
  }
398
- if (!deviceKey) {
399
- return Promise.resolve(false);
400
- }
401
416
  const values = config.state.getValues();
402
- const deviceRequest = {
417
+ const deviceRequest = JSON.stringify({
403
418
  method: "set_device_info",
404
- params: values,
405
- terminalUUID: (0, import_lumia_rgb_utils2.uuidv4)()
406
- };
407
- const encryptedRequest = encrypt(deviceRequest, deviceKey);
408
- const securePassthroughRequest = {
409
- method: "securePassthrough",
410
- params: {
411
- request: encryptedRequest
412
- }
413
- };
414
- return (0, import_fetch_cove.fetchWork)({
415
- url: `http://${deviceKey.deviceIp}/app?token=${deviceKey.token}`,
416
- data: {
417
- method: "POST",
418
- body: securePassthroughRequest,
419
- headers: {
420
- Cookie: deviceKey.sessionCookie,
421
- BypassCookie: deviceKey.sessionCookie
422
- }
423
- },
424
- shouldWait
419
+ params: values
420
+ // terminalUUID: uuidv4(),
425
421
  });
426
- };
422
+ const requestData = this.session.cipher.encrypt(deviceRequest);
423
+ const response = yield this.sessionPost(deviceKey.ip, "/request", requestData.encrypted, "arraybuffer", this.session.Cookie, {
424
+ seq: requestData.seq.toString()
425
+ });
426
+ if (response.status !== 200) {
427
+ throw new Error("[KLAP] Request failed");
428
+ }
429
+ const data = JSON.parse(this.session.cipher.decrypt(response.data));
430
+ return {
431
+ response,
432
+ body: data
433
+ };
434
+ });
427
435
  this.sendPower = (config) => {
428
436
  this.sendState({ device: config.device, state: new LightState({ on: config.power }) });
429
437
  };
430
- this.axiosInstance = import_axios2.default.create();
438
+ this.axiosInstance = import_axios.default.create();
431
439
  this.axiosInstance.defaults.timeout = (config == null ? void 0 : config.httpTimeout) || 4e3;
432
440
  this._config = __spreadValues(__spreadValues({}, this._config), config);
441
+ this._config.rawEmail = this._config.email;
442
+ this._config.rawPassword = this._config.password;
443
+ this._config.email = TpLinkCipher.toBase64(TpLinkCipher.encodeUsername(this._config.email));
444
+ this._config.password = TpLinkCipher.toBase64(this._config.password);
445
+ this.terminalUUID = import_crypto4.default.randomUUID();
446
+ }
447
+ // Helpers
448
+ sessionPost(deviceIp, path, payload, responseType, cookie, params) {
449
+ return __async(this, null, function* () {
450
+ return import_axios.default.post(`http://${deviceIp}/app${path}`, payload, {
451
+ responseType,
452
+ params,
453
+ headers: __spreadValues({
454
+ Accept: "*/*",
455
+ "Content-Type": "application/octet-stream"
456
+ }, cookie && {
457
+ Cookie: cookie
458
+ }),
459
+ httpAgent: new import_http.default.Agent({
460
+ keepAlive: false
461
+ })
462
+ });
463
+ });
464
+ }
465
+ needsNewHandshake() {
466
+ if (!this.session) {
467
+ return true;
468
+ }
469
+ if (!this.session.cipher) {
470
+ return true;
471
+ }
472
+ if (this.session.IsExpired) {
473
+ return true;
474
+ }
475
+ if (!this.session.Cookie) {
476
+ return true;
477
+ }
478
+ return false;
479
+ }
480
+ handshake(deviceIp, force = false) {
481
+ return __async(this, null, function* () {
482
+ if (!this.needsNewHandshake() && !force) {
483
+ return;
484
+ }
485
+ const { localSeed, remoteSeed, authHash } = yield this.firstHandshake(deviceIp);
486
+ return yield this.secondHandshake(deviceIp, localSeed, remoteSeed, authHash);
487
+ });
488
+ }
489
+ firstHandshake(deviceIp, seed) {
490
+ return __async(this, null, function* () {
491
+ var _a;
492
+ const localSeed = seed ? seed : import_crypto4.default.randomBytes(16);
493
+ const handshake1Result = yield this.sessionPost(deviceIp, "/handshake1", localSeed, "arraybuffer");
494
+ if (handshake1Result.status !== 200) {
495
+ throw new Error("Handshake1 failed");
496
+ }
497
+ if (handshake1Result.headers["content-length"] !== "48") {
498
+ throw new Error("Handshake1 failed due to invalid content length");
499
+ }
500
+ const cookie = (_a = handshake1Result.headers["set-cookie"]) == null ? void 0 : _a[0];
501
+ const data = handshake1Result.data;
502
+ const [cookieValue, timeout] = cookie.split(";");
503
+ const timeoutValue = timeout.split("=").pop();
504
+ this.session = new DeviceSession(timeoutValue, deviceIp, cookieValue);
505
+ const remoteSeed = data.subarray(0, 16);
506
+ const serverHash = data.subarray(16);
507
+ console.debug("[KLAP] First handshake decoded successfully:\nRemote Seed:", remoteSeed.toString("hex"), "\nServer Hash:", serverHash.toString("hex"), "\nCookie:", cookieValue);
508
+ const localHash = this.hashAuth(this._config.rawEmail, this._config.rawPassword);
509
+ const localAuthHash = this.sha256(Buffer.concat([localSeed, remoteSeed, localHash]));
510
+ if (Buffer.compare(localAuthHash, serverHash) === 0) {
511
+ console.debug("[KLAP] Local auth hash matches server hash");
512
+ return {
513
+ localSeed,
514
+ remoteSeed,
515
+ authHash: localHash
516
+ };
517
+ }
518
+ const emptyHash = this.sha256(Buffer.concat([localSeed, remoteSeed, this.hashAuth("", "")]));
519
+ if (Buffer.compare(emptyHash, serverHash) === 0) {
520
+ console.debug("[KLAP] [WARN] Empty auth hash matches server hash");
521
+ return {
522
+ localSeed,
523
+ remoteSeed,
524
+ authHash: emptyHash
525
+ };
526
+ }
527
+ const testHash = this.sha256(Buffer.concat([localSeed, remoteSeed, this.hashAuth(_TapoApi.TP_TEST_USER, _TapoApi.TP_TEST_PASSWORD)]));
528
+ if (Buffer.compare(testHash, serverHash) === 0) {
529
+ console.debug("[KLAP] [WARN] Test auth hash matches server hash");
530
+ return {
531
+ localSeed,
532
+ remoteSeed,
533
+ authHash: testHash
534
+ };
535
+ }
536
+ this.session = void 0;
537
+ throw new Error("Failed to verify server hash");
538
+ });
539
+ }
540
+ secondHandshake(deviceIp, localSeed, remoteSeed, authHash) {
541
+ return __async(this, null, function* () {
542
+ const localAuthHash = this.sha256(Buffer.concat([remoteSeed, localSeed, authHash]));
543
+ try {
544
+ const handshake2Result = yield this.sessionPost(deviceIp, "/handshake2", localAuthHash, "text", this.session.Cookie);
545
+ if (handshake2Result.status === 200) {
546
+ console.debug("[KLAP] Second handshake successful");
547
+ const deviceSession = this.session.completeHandshake(deviceIp, new KlapCipher(localSeed, remoteSeed, authHash));
548
+ this.session = deviceSession;
549
+ return deviceSession;
550
+ }
551
+ console.warn("[KLAP] Second handshake failed", handshake2Result.data);
552
+ } catch (e) {
553
+ console.error("[KLAP] Second handshake failed:", e.response.data || e.message);
554
+ }
555
+ this.session = void 0;
556
+ return void 0;
557
+ });
558
+ }
559
+ sha256(data) {
560
+ return import_crypto4.default.createHash("sha256").update(data).digest();
561
+ }
562
+ sha1(data) {
563
+ return import_crypto4.default.createHash("sha1").update(data).digest();
564
+ }
565
+ hashAuth(email, password) {
566
+ return this.sha256(Buffer.concat([this.sha1(Buffer.from(email.normalize("NFKC"))), this.sha1(Buffer.from(password.normalize("NFKC")))]));
567
+ }
568
+ };
569
+ _TapoApi.TP_TEST_USER = "test@tp-link.net";
570
+ _TapoApi.TP_TEST_PASSWORD = "test";
571
+ var TapoApi = _TapoApi;
572
+ var DeviceSession = class _DeviceSession {
573
+ constructor(timeout, ip, cookie, cipher) {
574
+ this.ip = ip;
575
+ this.cookie = cookie;
576
+ this.cipher = cipher;
577
+ this.handshakeCompleted = false;
578
+ this.rawTimeout = timeout;
579
+ this.expireAt = new Date(Date.now() + parseInt(timeout) * 1e3);
580
+ if (cipher) {
581
+ this.handshakeCompleted = true;
582
+ }
583
+ }
584
+ get IsExpired() {
585
+ return this.expireAt.getTime() - Date.now() <= 40 * 1e3;
586
+ }
587
+ get Cookie() {
588
+ return this.cookie;
589
+ }
590
+ completeHandshake(ip, cipher) {
591
+ return new _DeviceSession(this.rawTimeout, ip, this.cookie, cipher);
433
592
  }
434
593
  };
435
594
 
@@ -444,7 +603,7 @@ var discover = (config) => __async(void 0, null, function* () {
444
603
  const getDeviceRequest = {
445
604
  method: "getDeviceList"
446
605
  };
447
- const response = yield (0, import_axios3.default)({
606
+ const response = yield (0, import_axios2.default)({
448
607
  method: "post",
449
608
  url: `${api._baseUrl}?token=${token}`,
450
609
  data: getDeviceRequest
package/dist/index.mjs CHANGED
@@ -46,65 +46,14 @@ import findLocalDevices from "local-devices";
46
46
 
47
47
  // src/shared/cipher.ts
48
48
  import crypto from "crypto";
49
- import util from "util";
50
- var RSA_CIPHER_ALGORITHM = "rsa";
51
- var AES_CIPHER_ALGORITHM = "aes-128-cbc";
52
- var PASSPHRASE = "top secret";
53
- var generateKeyPair = () => __async(void 0, null, function* () {
54
- const RSA_OPTIONS = {
55
- modulusLength: 1024,
56
- publicKeyEncoding: {
57
- type: "spki",
58
- format: "pem"
59
- },
60
- privateKeyEncoding: {
61
- type: "pkcs1",
62
- format: "pem",
63
- cipher: "aes-256-cbc",
64
- passphrase: PASSPHRASE
65
- }
66
- };
67
- const generateKeyPair2 = util.promisify(crypto.generateKeyPair);
68
- return generateKeyPair2(RSA_CIPHER_ALGORITHM, RSA_OPTIONS);
69
- });
70
- var encrypt = (data, deviceKey) => {
71
- var cipher = crypto.createCipheriv(AES_CIPHER_ALGORITHM, deviceKey.key, deviceKey.iv);
72
- var ciphertext = cipher.update(Buffer.from(JSON.stringify(data)));
73
- return Buffer.concat([ciphertext, cipher.final()]).toString("base64");
74
- };
75
- var decrypt = (data, deviceKey) => {
76
- var cipher = crypto.createDecipheriv(AES_CIPHER_ALGORITHM, deviceKey.key, deviceKey.iv);
77
- var ciphertext = cipher.update(Buffer.from(data, "base64"));
78
- return JSON.parse(Buffer.concat([ciphertext, cipher.final()]).toString());
79
- };
80
- var readDeviceKey = (pemKey, privateKey) => {
81
- const keyBytes = Buffer.from(pemKey, "base64");
82
- const deviceKey = crypto.privateDecrypt(
83
- {
84
- key: privateKey,
85
- padding: crypto.constants.RSA_PKCS1_PADDING,
86
- passphrase: PASSPHRASE
87
- },
88
- keyBytes
89
- );
90
- return deviceKey;
91
- };
92
- var base64Encode = (data) => {
93
- return Buffer.from(data).toString("base64");
94
- };
95
49
  var base64Decode = (data) => {
96
50
  return Buffer.from(data, "base64").toString();
97
51
  };
98
- var shaDigest = (data) => {
99
- var shasum = crypto.createHash("sha1");
100
- shasum.update(data);
101
- return shasum.digest("hex");
102
- };
103
52
 
104
53
  // src/shared/helpers.ts
105
- import axios from "axios";
106
54
  var throwErrorIfFound = (responseData) => {
107
55
  const errorCode = responseData["error_code"];
56
+ console.debug("[Tapo] errorCode: ", errorCode);
108
57
  if (errorCode) {
109
58
  switch (errorCode) {
110
59
  case 0:
@@ -130,64 +79,9 @@ var throwErrorIfFound = (responseData) => {
130
79
  }
131
80
  }
132
81
  };
133
- var handshake = (deviceIp) => __async(void 0, null, function* () {
134
- var _a, _b, _c, _d;
135
- const keyPair = yield generateKeyPair();
136
- const handshakeRequest = {
137
- method: "handshake",
138
- params: {
139
- key: keyPair.publicKey
140
- }
141
- };
142
- const response = yield (globalThis.nodeAxios ? globalThis.nodeAxios : axios)({
143
- method: "post",
144
- url: `http://${deviceIp}/app`,
145
- data: handshakeRequest,
146
- timeout: 3e3
147
- });
148
- throwErrorIfFound(response.data);
149
- let setCookieHeader;
150
- if (response.headers["set-cookie"]) {
151
- setCookieHeader = (_b = (_a = response.headers["set-cookie"]) == null ? void 0 : _a[0]) != null ? _b : response.headers["set-cookie"];
152
- } else if (response.headers["bypass-cookie"]) {
153
- setCookieHeader = (_d = (_c = response.headers["bypass-cookie"]) == null ? void 0 : _c[0]) != null ? _d : response.headers["bypass-cookie"];
154
- } else {
155
- setCookieHeader = response.headers.get("set-cookie");
156
- }
157
- const sessionCookie = setCookieHeader.substring(0, setCookieHeader.indexOf(";"));
158
- const deviceKey = readDeviceKey(response.data.result.key, keyPair.privateKey);
159
- return {
160
- key: deviceKey.subarray(0, 16),
161
- iv: deviceKey.subarray(16, 32),
162
- deviceIp,
163
- sessionCookie
164
- };
165
- });
166
- var securePassthrough = (deviceRequest, deviceKey) => __async(void 0, null, function* () {
167
- const encryptedRequest = encrypt(deviceRequest, deviceKey);
168
- const securePassthroughRequest = {
169
- method: "securePassthrough",
170
- params: {
171
- request: encryptedRequest
172
- }
173
- };
174
- const response = yield (globalThis.nodeAxios ? globalThis.nodeAxios : axios)({
175
- method: "post",
176
- url: `http://${deviceKey.deviceIp}/app?token=${deviceKey.token}`,
177
- data: securePassthroughRequest,
178
- timeout: 3e3,
179
- headers: {
180
- Cookie: deviceKey.sessionCookie
181
- }
182
- });
183
- throwErrorIfFound(response.data);
184
- const decryptedResponse = decrypt(response.data.result.response, deviceKey);
185
- throwErrorIfFound(decryptedResponse);
186
- return decryptedResponse.result;
187
- });
188
82
 
189
83
  // src/tapo-api.ts
190
- import axios2 from "axios";
84
+ import axios from "axios";
191
85
 
192
86
  // src/lightstate.ts
193
87
  import { isTruly, isFunction, rgb2hsv } from "@lumiastream/lumia-rgb-utils";
@@ -304,14 +198,140 @@ var LightState = class extends SuperState {
304
198
  };
305
199
 
306
200
  // src/tapo-api.ts
307
- import { fetchWork } from "@lumiastream/fetch-cove";
308
201
  import { uuidv4 } from "@lumiastream/lumia-rgb-utils";
309
- var TapoApi = class {
202
+
203
+ // src/shared/tplink-cypher.ts
204
+ import crypto2 from "crypto";
205
+ var TpLinkCipher = class {
206
+ constructor(key, iv) {
207
+ this.key = key;
208
+ this.iv = iv;
209
+ }
210
+ static toBase64(data) {
211
+ return Buffer.from(data.normalize("NFKC"), "utf-8").toString("base64");
212
+ }
213
+ static encodeUsername(data) {
214
+ const sha = crypto2.createHash("sha1");
215
+ sha.update(data.normalize("NFKC"));
216
+ return sha.digest("hex");
217
+ }
218
+ static createKeyPair() {
219
+ return new Promise((resolve, reject) => {
220
+ crypto2.generateKeyPair(
221
+ "rsa",
222
+ {
223
+ modulusLength: 1024
224
+ },
225
+ (err, publicK, privateK) => {
226
+ if (err) {
227
+ return reject(err);
228
+ }
229
+ const pub = publicK.export({
230
+ format: "pem",
231
+ type: "spki"
232
+ }).toString("base64");
233
+ const priv = privateK.export({
234
+ format: "pem",
235
+ type: "pkcs1"
236
+ }).toString("base64");
237
+ resolve({
238
+ public: pub,
239
+ private: priv
240
+ });
241
+ }
242
+ );
243
+ });
244
+ }
245
+ encrypt(data) {
246
+ const cipher = crypto2.createCipheriv("aes-128-cbc", this.key, this.iv);
247
+ const encrypted = cipher.update(data, "utf8", "base64");
248
+ return `${encrypted}${cipher.final("base64")}`;
249
+ }
250
+ decrypt(data) {
251
+ const decipher = crypto2.createDecipheriv("aes-128-cbc", this.key, this.iv);
252
+ const decrypted = decipher.update(data, "base64", "utf8");
253
+ return `${decrypted}${decipher.final("utf8")}`;
254
+ }
255
+ };
256
+
257
+ // src/tapo-api.ts
258
+ import crypto4 from "crypto";
259
+ import http from "http";
260
+
261
+ // src/shared/klap-cipher.ts
262
+ import crypto3 from "crypto";
263
+ var KlapCipher = class {
264
+ constructor(localSeed, remoteSeed, authHash) {
265
+ const { iv, seq } = this.ivDerive(localSeed, remoteSeed, authHash);
266
+ this.key = this.keyDerive(localSeed, remoteSeed, authHash);
267
+ this.sig = this.sigDerive(localSeed, remoteSeed, authHash);
268
+ this.iv = iv;
269
+ this.seq = seq;
270
+ }
271
+ encrypt(msg) {
272
+ this.seq += 1;
273
+ if (typeof msg === "string") {
274
+ msg = Buffer.from(msg, "utf8");
275
+ }
276
+ if (!Buffer.isBuffer(msg)) {
277
+ throw new Error("msg must be a string or buffer");
278
+ }
279
+ const cipher = crypto3.createCipheriv("aes-128-cbc", this.key, this.ivSeq());
280
+ const cipherText = Buffer.concat([cipher.update(msg), cipher.final()]);
281
+ const seqBuffer = Buffer.alloc(4);
282
+ seqBuffer.writeInt32BE(this.seq, 0);
283
+ const hash = crypto3.createHash("sha256");
284
+ hash.update(Buffer.concat([this.sig, seqBuffer, cipherText]));
285
+ const signature = hash.digest();
286
+ return {
287
+ encrypted: Buffer.concat([signature, cipherText]),
288
+ seq: this.seq
289
+ };
290
+ }
291
+ decrypt(msg) {
292
+ if (!Buffer.isBuffer(msg)) {
293
+ throw new Error("msg must be a buffer");
294
+ }
295
+ const decipher = crypto3.createDecipheriv("aes-128-cbc", this.key, this.ivSeq());
296
+ const decrypted = Buffer.concat([decipher.update(msg.subarray(32)), decipher.final()]);
297
+ return decrypted.toString("utf8");
298
+ }
299
+ keyDerive(l, r, h) {
300
+ const payload = Buffer.concat([Buffer.from("lsk"), l, r, h]);
301
+ const hash = crypto3.createHash("sha256").update(payload).digest();
302
+ return hash.subarray(0, 16);
303
+ }
304
+ ivDerive(l, r, h) {
305
+ const payload = Buffer.concat([Buffer.from("iv"), l, r, h]);
306
+ const fullIv = crypto3.createHash("sha256").update(payload).digest();
307
+ const seq = fullIv.subarray(-4).readInt32BE(0);
308
+ return { iv: fullIv.subarray(0, 12), seq };
309
+ }
310
+ sigDerive(l, r, h) {
311
+ const payload = Buffer.concat([Buffer.from("ldk"), l, r, h]);
312
+ const hash = crypto3.createHash("sha256").update(payload).digest();
313
+ return hash.subarray(0, 28);
314
+ }
315
+ ivSeq() {
316
+ const seq = Buffer.alloc(4);
317
+ seq.writeInt32BE(this.seq, 0);
318
+ const iv = Buffer.concat([this.iv, seq]);
319
+ if (iv.length !== 16) {
320
+ throw new Error("Length of iv is not 16");
321
+ }
322
+ return iv;
323
+ }
324
+ };
325
+
326
+ // src/tapo-api.ts
327
+ var _TapoApi = class _TapoApi {
310
328
  constructor(config) {
311
329
  this._baseUrl = "https://eu-wap.tplinkcloud.com/";
312
- // https://n-euw1-wap-gw.tplinkcloud.com
313
- this._baseTapoCareUrl = "https://euw1-app-tapo-care.i.tplinknbu.com";
314
330
  this._config = {
331
+ rawEmail: "",
332
+ rawPassword: "",
333
+ email: "",
334
+ password: "",
315
335
  authToken: null,
316
336
  timeout: 1e4,
317
337
  httpTimeout: 4e3
@@ -328,7 +348,7 @@ var TapoApi = class {
328
348
  terminalUUID: uuidv4()
329
349
  }
330
350
  };
331
- const response = yield axios2({
351
+ const response = yield axios({
332
352
  method: "post",
333
353
  url: this._baseUrl,
334
354
  data: loginRequest
@@ -339,65 +359,204 @@ var TapoApi = class {
339
359
  });
340
360
  this.setup = (_0) => __async(this, [_0], function* ({ email, password, devices }) {
341
361
  const promises = devices.map((device) => __async(this, null, function* () {
342
- const deviceKey = yield handshake(device.host);
343
- const loginDeviceRequest = {
344
- method: "login_device",
345
- params: {
346
- username: base64Encode(shaDigest(email)),
347
- password: base64Encode(password)
348
- },
349
- requestTimeMils: 0
350
- };
351
- const loginDeviceResponse = yield securePassthrough(loginDeviceRequest, deviceKey);
352
- deviceKey.token = loginDeviceResponse.token;
353
- this._devices.set(device.id, deviceKey);
362
+ const deviceSession = yield this.handshake(device.host);
363
+ this._devices.set(device.id, deviceSession);
354
364
  return;
355
365
  }));
356
- yield Promise.allSettled(promises);
366
+ try {
367
+ const responses = yield Promise.allSettled(promises);
368
+ } catch (err) {
369
+ console.error("tapo setup err: ", err);
370
+ }
357
371
  return this._devices;
358
372
  });
359
373
  // Change state using lightstate
360
- this.sendState = (config) => {
374
+ this.sendState = (config) => __async(this, null, function* () {
361
375
  const deviceKey = this._devices.get(config.device.id);
376
+ if (!deviceKey) {
377
+ return Promise.resolve(false);
378
+ }
379
+ yield this.handshake(deviceKey == null ? void 0 : deviceKey.ip, false);
362
380
  let shouldWait = false;
363
381
  if (config.fetchConfig && config.fetchConfig.shouldWait) {
364
382
  shouldWait = true;
365
383
  }
366
- if (!deviceKey) {
367
- return Promise.resolve(false);
368
- }
369
384
  const values = config.state.getValues();
370
- const deviceRequest = {
385
+ const deviceRequest = JSON.stringify({
371
386
  method: "set_device_info",
372
- params: values,
373
- terminalUUID: uuidv4()
374
- };
375
- const encryptedRequest = encrypt(deviceRequest, deviceKey);
376
- const securePassthroughRequest = {
377
- method: "securePassthrough",
378
- params: {
379
- request: encryptedRequest
380
- }
381
- };
382
- return fetchWork({
383
- url: `http://${deviceKey.deviceIp}/app?token=${deviceKey.token}`,
384
- data: {
385
- method: "POST",
386
- body: securePassthroughRequest,
387
- headers: {
388
- Cookie: deviceKey.sessionCookie,
389
- BypassCookie: deviceKey.sessionCookie
390
- }
391
- },
392
- shouldWait
387
+ params: values
388
+ // terminalUUID: uuidv4(),
393
389
  });
394
- };
390
+ const requestData = this.session.cipher.encrypt(deviceRequest);
391
+ const response = yield this.sessionPost(deviceKey.ip, "/request", requestData.encrypted, "arraybuffer", this.session.Cookie, {
392
+ seq: requestData.seq.toString()
393
+ });
394
+ if (response.status !== 200) {
395
+ throw new Error("[KLAP] Request failed");
396
+ }
397
+ const data = JSON.parse(this.session.cipher.decrypt(response.data));
398
+ return {
399
+ response,
400
+ body: data
401
+ };
402
+ });
395
403
  this.sendPower = (config) => {
396
404
  this.sendState({ device: config.device, state: new LightState({ on: config.power }) });
397
405
  };
398
- this.axiosInstance = axios2.create();
406
+ this.axiosInstance = axios.create();
399
407
  this.axiosInstance.defaults.timeout = (config == null ? void 0 : config.httpTimeout) || 4e3;
400
408
  this._config = __spreadValues(__spreadValues({}, this._config), config);
409
+ this._config.rawEmail = this._config.email;
410
+ this._config.rawPassword = this._config.password;
411
+ this._config.email = TpLinkCipher.toBase64(TpLinkCipher.encodeUsername(this._config.email));
412
+ this._config.password = TpLinkCipher.toBase64(this._config.password);
413
+ this.terminalUUID = crypto4.randomUUID();
414
+ }
415
+ // Helpers
416
+ sessionPost(deviceIp, path, payload, responseType, cookie, params) {
417
+ return __async(this, null, function* () {
418
+ return axios.post(`http://${deviceIp}/app${path}`, payload, {
419
+ responseType,
420
+ params,
421
+ headers: __spreadValues({
422
+ Accept: "*/*",
423
+ "Content-Type": "application/octet-stream"
424
+ }, cookie && {
425
+ Cookie: cookie
426
+ }),
427
+ httpAgent: new http.Agent({
428
+ keepAlive: false
429
+ })
430
+ });
431
+ });
432
+ }
433
+ needsNewHandshake() {
434
+ if (!this.session) {
435
+ return true;
436
+ }
437
+ if (!this.session.cipher) {
438
+ return true;
439
+ }
440
+ if (this.session.IsExpired) {
441
+ return true;
442
+ }
443
+ if (!this.session.Cookie) {
444
+ return true;
445
+ }
446
+ return false;
447
+ }
448
+ handshake(deviceIp, force = false) {
449
+ return __async(this, null, function* () {
450
+ if (!this.needsNewHandshake() && !force) {
451
+ return;
452
+ }
453
+ const { localSeed, remoteSeed, authHash } = yield this.firstHandshake(deviceIp);
454
+ return yield this.secondHandshake(deviceIp, localSeed, remoteSeed, authHash);
455
+ });
456
+ }
457
+ firstHandshake(deviceIp, seed) {
458
+ return __async(this, null, function* () {
459
+ var _a;
460
+ const localSeed = seed ? seed : crypto4.randomBytes(16);
461
+ const handshake1Result = yield this.sessionPost(deviceIp, "/handshake1", localSeed, "arraybuffer");
462
+ if (handshake1Result.status !== 200) {
463
+ throw new Error("Handshake1 failed");
464
+ }
465
+ if (handshake1Result.headers["content-length"] !== "48") {
466
+ throw new Error("Handshake1 failed due to invalid content length");
467
+ }
468
+ const cookie = (_a = handshake1Result.headers["set-cookie"]) == null ? void 0 : _a[0];
469
+ const data = handshake1Result.data;
470
+ const [cookieValue, timeout] = cookie.split(";");
471
+ const timeoutValue = timeout.split("=").pop();
472
+ this.session = new DeviceSession(timeoutValue, deviceIp, cookieValue);
473
+ const remoteSeed = data.subarray(0, 16);
474
+ const serverHash = data.subarray(16);
475
+ console.debug("[KLAP] First handshake decoded successfully:\nRemote Seed:", remoteSeed.toString("hex"), "\nServer Hash:", serverHash.toString("hex"), "\nCookie:", cookieValue);
476
+ const localHash = this.hashAuth(this._config.rawEmail, this._config.rawPassword);
477
+ const localAuthHash = this.sha256(Buffer.concat([localSeed, remoteSeed, localHash]));
478
+ if (Buffer.compare(localAuthHash, serverHash) === 0) {
479
+ console.debug("[KLAP] Local auth hash matches server hash");
480
+ return {
481
+ localSeed,
482
+ remoteSeed,
483
+ authHash: localHash
484
+ };
485
+ }
486
+ const emptyHash = this.sha256(Buffer.concat([localSeed, remoteSeed, this.hashAuth("", "")]));
487
+ if (Buffer.compare(emptyHash, serverHash) === 0) {
488
+ console.debug("[KLAP] [WARN] Empty auth hash matches server hash");
489
+ return {
490
+ localSeed,
491
+ remoteSeed,
492
+ authHash: emptyHash
493
+ };
494
+ }
495
+ const testHash = this.sha256(Buffer.concat([localSeed, remoteSeed, this.hashAuth(_TapoApi.TP_TEST_USER, _TapoApi.TP_TEST_PASSWORD)]));
496
+ if (Buffer.compare(testHash, serverHash) === 0) {
497
+ console.debug("[KLAP] [WARN] Test auth hash matches server hash");
498
+ return {
499
+ localSeed,
500
+ remoteSeed,
501
+ authHash: testHash
502
+ };
503
+ }
504
+ this.session = void 0;
505
+ throw new Error("Failed to verify server hash");
506
+ });
507
+ }
508
+ secondHandshake(deviceIp, localSeed, remoteSeed, authHash) {
509
+ return __async(this, null, function* () {
510
+ const localAuthHash = this.sha256(Buffer.concat([remoteSeed, localSeed, authHash]));
511
+ try {
512
+ const handshake2Result = yield this.sessionPost(deviceIp, "/handshake2", localAuthHash, "text", this.session.Cookie);
513
+ if (handshake2Result.status === 200) {
514
+ console.debug("[KLAP] Second handshake successful");
515
+ const deviceSession = this.session.completeHandshake(deviceIp, new KlapCipher(localSeed, remoteSeed, authHash));
516
+ this.session = deviceSession;
517
+ return deviceSession;
518
+ }
519
+ console.warn("[KLAP] Second handshake failed", handshake2Result.data);
520
+ } catch (e) {
521
+ console.error("[KLAP] Second handshake failed:", e.response.data || e.message);
522
+ }
523
+ this.session = void 0;
524
+ return void 0;
525
+ });
526
+ }
527
+ sha256(data) {
528
+ return crypto4.createHash("sha256").update(data).digest();
529
+ }
530
+ sha1(data) {
531
+ return crypto4.createHash("sha1").update(data).digest();
532
+ }
533
+ hashAuth(email, password) {
534
+ return this.sha256(Buffer.concat([this.sha1(Buffer.from(email.normalize("NFKC"))), this.sha1(Buffer.from(password.normalize("NFKC")))]));
535
+ }
536
+ };
537
+ _TapoApi.TP_TEST_USER = "test@tp-link.net";
538
+ _TapoApi.TP_TEST_PASSWORD = "test";
539
+ var TapoApi = _TapoApi;
540
+ var DeviceSession = class _DeviceSession {
541
+ constructor(timeout, ip, cookie, cipher) {
542
+ this.ip = ip;
543
+ this.cookie = cookie;
544
+ this.cipher = cipher;
545
+ this.handshakeCompleted = false;
546
+ this.rawTimeout = timeout;
547
+ this.expireAt = new Date(Date.now() + parseInt(timeout) * 1e3);
548
+ if (cipher) {
549
+ this.handshakeCompleted = true;
550
+ }
551
+ }
552
+ get IsExpired() {
553
+ return this.expireAt.getTime() - Date.now() <= 40 * 1e3;
554
+ }
555
+ get Cookie() {
556
+ return this.cookie;
557
+ }
558
+ completeHandshake(ip, cipher) {
559
+ return new _DeviceSession(this.rawTimeout, ip, this.cookie, cipher);
401
560
  }
402
561
  };
403
562
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumiastream/tapo-cove",
3
- "version": "3.11.0",
3
+ "version": "3.12.1",
4
4
  "private": false,
5
5
  "license": "GPL",
6
6
  "main": "dist/index.js",
@@ -11,17 +11,18 @@
11
11
  "lint": "tsc"
12
12
  },
13
13
  "dependencies": {
14
- "@lumiastream/fetch-cove": "^3.11.0",
14
+ "@lumiastream/fetch-cove": "^3.12.0",
15
15
  "@lumiastream/lumia-rgb-types": "^3.11.0",
16
16
  "@lumiastream/lumia-rgb-utils": "^3.11.0",
17
17
  "axios": "^1.4.0",
18
+ "crypto": "1.0.1",
18
19
  "local-devices": "^4.0.0"
19
20
  },
20
21
  "devDependencies": {
21
- "@types/node": "*",
22
+ "@types/node": "20.8.7",
22
23
  "tsconfig": "*",
23
24
  "tsup": "*",
24
25
  "typescript": "*"
25
26
  },
26
- "gitHead": "e1fff4a873e1dd9725e2d22e6ed23fb81569547f"
27
+ "gitHead": "8b0ab0a76cd60e2e5b79e6279033c53406c3667f"
27
28
  }