@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 +16 -0
- package/dist/index.d.mts +44 -9
- package/dist/index.d.ts +44 -9
- package/dist/index.js +315 -156
- package/dist/index.mjs +313 -154
- package/package.json +5 -4
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
375
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
343
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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": "
|
|
27
|
+
"gitHead": "8b0ab0a76cd60e2e5b79e6279033c53406c3667f"
|
|
27
28
|
}
|