homebridge-tapo-camera
Version:
Homebridge plugin for TP-Link TAPO security cameras
616 lines • 25.2 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TAPOCamera = void 0;
const crypto_1 = __importDefault(require("crypto"));
const onvifCamera_1 = require("./onvifCamera");
const undici_1 = require("undici");
const MAX_LOGIN_RETRIES = 2;
const AES_BLOCK_SIZE = 16;
const ERROR_CODES_MAP = {
"-40401": "Invalid stok value",
"-40210": "Function not supported",
"-64303": "Action cannot be done while camera is in patrol mode.",
"-64324": "Privacy mode is ON, not able to execute",
"-64302": "Preset ID not found",
"-64321": "Preset ID was deleted so no longer exists",
"-40106": "Parameter to get/do does not exist",
"-40105": "Method does not exist",
"-40101": "Parameter to set does not exist",
"-40209": "Invalid login credentials",
"-64304": "Maximum Pan/Tilt range reached",
"-71103": "User ID is not authorized",
};
class TAPOCamera extends onvifCamera_1.OnvifCamera {
log;
config;
kStreamPort = 554;
fetchAgent;
hashedPassword;
hashedSha256Password;
passwordEncryptionMethod = null;
isSecureConnectionValue = null;
stokPromise;
cnonce;
lsk;
ivb;
seq;
stok;
constructor(log, config) {
super(log, config);
this.log = log;
this.config = config;
this.fetchAgent = new undici_1.Agent({
connectTimeout: 5_000,
connect: {
// TAPO devices have self-signed certificates
rejectUnauthorized: false,
ciphers: "AES256-SHA:AES128-GCM-SHA256",
},
});
this.cnonce = this.generateCnonce();
this.hashedPassword = crypto_1.default
.createHash("md5")
.update(config.password)
.digest("hex")
.toUpperCase();
this.hashedSha256Password = crypto_1.default
.createHash("sha256")
.update(config.password)
.digest("hex")
.toUpperCase();
}
getUsername() {
return this.config.username || "admin";
}
getHeaders() {
return {
Host: `https://${this.config.ipAddress}`,
Referer: `https://${this.config.ipAddress}`,
Accept: "application/json",
"Accept-Encoding": "gzip, deflate",
"User-Agent": "Tapo CameraClient Android",
Connection: "close",
requestByApp: "true",
"Content-Type": "application/json; charset=UTF-8",
};
}
getHashedPassword() {
if (this.passwordEncryptionMethod === "md5") {
return this.hashedPassword;
}
else if (this.passwordEncryptionMethod === "sha256") {
return this.hashedSha256Password;
}
else {
throw new Error("Unknown password encryption method");
}
}
fetch(url, data) {
return fetch(url, {
headers: this.getHeaders(),
// @ts-expect-error Dispatcher type not there
dispatcher: this.fetchAgent,
...data,
});
}
generateEncryptionToken(tokenType, nonce) {
const hashedKey = crypto_1.default
.createHash("sha256")
.update(this.cnonce + this.getHashedPassword() + nonce)
.digest("hex")
.toUpperCase();
return crypto_1.default
.createHash("sha256")
.update(tokenType + this.cnonce + nonce + hashedKey)
.digest()
.slice(0, 16);
}
getAuthenticatedStreamUrl(lowQuality = false) {
const prefix = `rtsp://${this.config.streamUser}:${this.config.streamPassword}@${this.config.ipAddress}:${this.kStreamPort}`;
return lowQuality ? `${prefix}/stream2` : `${prefix}/stream1`;
}
generateCnonce() {
return crypto_1.default.randomBytes(8).toString("hex").toUpperCase();
}
validateDeviceConfirm(nonce, deviceConfirm) {
this.passwordEncryptionMethod = null;
const hashedNoncesWithSHA256 = crypto_1.default
.createHash("sha256")
.update(this.cnonce + this.hashedSha256Password + nonce)
.digest("hex")
.toUpperCase();
if (deviceConfirm === hashedNoncesWithSHA256 + nonce + this.cnonce) {
this.passwordEncryptionMethod = "sha256";
return true;
}
const hashedNoncesWithMD5 = crypto_1.default
.createHash("md5")
.update(this.cnonce + this.hashedPassword + nonce)
.digest("hex")
.toUpperCase();
if (deviceConfirm === hashedNoncesWithMD5 + nonce + this.cnonce) {
this.passwordEncryptionMethod = "md5";
return true;
}
this.log.debug('Invalid device confirm, expected "sha256" or "md5" to match, but none found', {
hashedNoncesWithMD5,
hashedNoncesWithSHA256,
deviceConfirm,
nonce,
cnonce: this,
});
return this.passwordEncryptionMethod !== null;
}
async refreshStok(loginRetryCount = 0) {
this.log.debug("refreshStok: Refreshing stok...");
const isSecureConnection = await this.isSecureConnection();
let fetchParams = {};
if (isSecureConnection) {
fetchParams = {
method: "post",
body: JSON.stringify({
method: "login",
params: {
cnonce: this.cnonce,
encrypt_type: "3",
username: this.getUsername(),
},
}),
};
}
else {
fetchParams = {
method: "post",
body: JSON.stringify({
method: "login",
params: {
username: this.getUsername(),
password: this.hashedPassword,
hashed: true,
},
}),
};
}
const responseLogin = await this.fetch(`https://${this.config.ipAddress}`, fetchParams);
const responseLoginData = (await responseLogin.json());
let response, responseData;
if (!responseLoginData) {
this.log.debug("refreshStok: empty response login data, raising exception", responseLogin.status);
throw new Error("Empty response login data");
}
this.log.debug("refreshStok: Login response", responseLogin.status, responseLoginData);
if (responseLogin.status === 401 &&
responseLoginData.result?.data?.code === -40411) {
this.log.debug("refreshStok: invalid credentials, raising exception", responseLogin.status);
throw new Error("Invalid credentials");
}
if (isSecureConnection) {
const nonce = responseLoginData.result?.data?.nonce;
const deviceConfirm = responseLoginData.result?.data?.device_confirm;
if (nonce &&
deviceConfirm &&
this.validateDeviceConfirm(nonce, deviceConfirm)) {
const digestPasswd = crypto_1.default
.createHash("sha256")
.update(this.getHashedPassword() + this.cnonce + nonce)
.digest("hex")
.toUpperCase();
const digestPasswdFull = Buffer.concat([
Buffer.from(digestPasswd, "utf8"),
Buffer.from(this.cnonce, "utf8"),
Buffer.from(nonce, "utf8"),
]).toString("utf8");
this.log.debug("refreshStok: sending start_seq request");
response = await this.fetch(`https://${this.config.ipAddress}`, {
method: "POST",
body: JSON.stringify({
method: "login",
params: {
cnonce: this.cnonce,
encrypt_type: "3",
digest_passwd: digestPasswdFull,
username: this.getUsername(),
},
}),
});
responseData = (await response.json());
if (!responseData) {
this.log.debug("refreshStock: empty response start_seq data, raising exception", response.status);
throw new Error("Empty response start_seq data");
}
this.log.debug("refreshStok: start_seq response", response.status, JSON.stringify(responseData));
if (responseData.result?.start_seq) {
if (responseData.result?.user_group !== "root") {
this.log.debug("refreshStock: Incorrect user_group detected");
// # encrypted control via 3rd party account does not seem to be supported
// # see https://github.com/JurajNyiri/HomeAssistant-Tapo-Control/issues/456
throw new Error("Incorrect user_group detected");
}
this.lsk = this.generateEncryptionToken("lsk", nonce);
this.ivb = this.generateEncryptionToken("ivb", nonce);
this.seq = responseData.result.start_seq;
}
}
else {
if (responseLoginData.error_code === -40413 &&
loginRetryCount < MAX_LOGIN_RETRIES) {
this.log.debug(`refreshStock: Invalid device confirm, retrying: ${loginRetryCount}/${MAX_LOGIN_RETRIES}.`, responseLogin.status, responseLoginData);
return this.refreshStok(loginRetryCount + 1);
}
this.log.debug("refreshStock: Invalid device confirm and loginRetryCount exhausted, raising exception", loginRetryCount, responseLoginData);
throw new Error("Invalid device confirm");
}
}
else {
this.passwordEncryptionMethod = "md5";
response = responseLogin;
responseData = responseLoginData;
}
if (responseData.result?.data?.sec_left &&
responseData.result.data.sec_left > 0) {
this.log.debug("refreshStok: temporary suspension", responseData);
throw new Error(`Temporary Suspension: Try again in ${responseData.result.data.sec_left} seconds`);
}
if (responseData?.data?.code === -40404 &&
responseData?.data?.sec_left &&
responseData.data.sec_left > 0) {
this.log.debug("refreshStok: temporary suspension", responseData);
throw new Error(`refreshStok: Temporary Suspension: Try again in ${responseData.data.sec_left} seconds`);
}
if (responseData?.result?.stok) {
this.stok = responseData.result.stok;
this.log.debug("refreshStok: Success in obtaining STOK", this.stok);
return;
}
if (responseData?.error_code === -40413 &&
loginRetryCount < MAX_LOGIN_RETRIES) {
this.log.debug(`refreshStock: Unexpected response, retrying: ${loginRetryCount}/${MAX_LOGIN_RETRIES}.`, response.status, responseData);
return this.refreshStok(loginRetryCount + 1);
}
this.log.debug("refreshStock: Unexpected end of flow, raising exception");
throw new Error("Invalid authentication data");
}
async isSecureConnection() {
if (this.isSecureConnectionValue === null) {
this.log.debug("isSecureConnection: Checking secure connection...");
const response = await this.fetch(`https://${this.config.ipAddress}`, {
method: "post",
body: JSON.stringify({
method: "login",
params: {
encrypt_type: "3",
username: this.getUsername(),
},
}),
});
const responseData = (await response.json());
this.log.debug("isSecureConnection response", response.status, JSON.stringify(responseData));
this.isSecureConnectionValue =
responseData?.error_code == -40413 &&
String(responseData.result?.data?.encrypt_type || "")?.includes("3");
}
return this.isSecureConnectionValue;
}
getStok(loginRetryCount = 0) {
return new Promise((resolve) => {
if (this.stok) {
return resolve(this.stok);
}
if (!this.stokPromise) {
this.stokPromise = () => this.refreshStok(loginRetryCount);
}
this.stokPromise()
.then(() => {
if (!this.stok) {
throw new Error("STOK not found");
}
resolve(this.stok);
})
.finally(() => {
this.stokPromise = undefined;
});
});
}
async getAuthenticatedAPIURL(loginRetryCount = 0) {
const token = await this.getStok(loginRetryCount);
return `https://${this.config.ipAddress}/stok=${token}/ds`;
}
encryptRequest(request) {
const cipher = crypto_1.default.createCipheriv("aes-128-cbc", this.lsk, this.ivb);
let ct_bytes = cipher.update(this.encryptPad(request, AES_BLOCK_SIZE), "utf-8", "hex");
ct_bytes += cipher.final("hex");
return Buffer.from(ct_bytes, "hex");
}
encryptPad(text, blocksize) {
const padSize = blocksize - (text.length % blocksize);
const padding = String.fromCharCode(padSize).repeat(padSize);
return text + padding;
}
decryptResponse(response) {
const decipher = crypto_1.default.createDecipheriv("aes-128-cbc", this.lsk, this.ivb);
let decrypted = decipher.update(response, "base64", "utf-8");
decrypted += decipher.final("utf-8");
return this.encryptUnpad(decrypted, AES_BLOCK_SIZE);
}
encryptUnpad(text, blockSize) {
const paddingLength = Number(text[text.length - 1]) || 0;
if (paddingLength > blockSize || paddingLength > text.length) {
throw new Error("Invalid padding");
}
for (let i = text.length - paddingLength; i < text.length; i++) {
if (text.charCodeAt(i) !== paddingLength) {
throw new Error("Invalid padding");
}
}
return text.slice(0, text.length - paddingLength).toString();
}
getTapoTag(request) {
const tag = crypto_1.default
.createHash("sha256")
.update(this.getHashedPassword() + this.cnonce)
.digest("hex")
.toUpperCase();
return crypto_1.default
.createHash("sha256")
.update(tag + JSON.stringify(request) + this.seq.toString())
.digest("hex")
.toUpperCase();
}
pendingAPIRequests = new Map();
async apiRequest(req, loginRetryCount = 0) {
const reqJson = JSON.stringify(req);
if (this.pendingAPIRequests.has(reqJson)) {
this.log.debug("API request already pending", reqJson);
return this.pendingAPIRequests.get(reqJson);
}
else {
this.log.debug("New API request", reqJson);
}
this.pendingAPIRequests.set(reqJson, (async () => {
try {
const isSecureConnection = await this.isSecureConnection();
const url = await this.getAuthenticatedAPIURL(loginRetryCount);
const fetchParams = {
method: "post",
};
if (this.seq && isSecureConnection) {
const encryptedRequest = {
method: "securePassthrough",
params: {
request: Buffer.from(this.encryptRequest(JSON.stringify(req))).toString("base64"),
},
};
fetchParams.headers = {
...this.getHeaders(),
Tapo_tag: this.getTapoTag(encryptedRequest),
Seq: this.seq.toString(),
};
fetchParams.body = JSON.stringify(encryptedRequest);
this.seq += 1;
}
else {
fetchParams.body = JSON.stringify(req);
}
const response = await this.fetch(url, fetchParams);
const responseDataTmp = await response.json();
// Apparently the Tapo C200 returns 500 on successful requests,
// but it's indicating an expiring token, therefore refresh the token next time
if (isSecureConnection && response.status === 500) {
this.log.debug("Stok expired, reauthenticating on next request, setting STOK to undefined");
this.stok = undefined;
}
let responseData = null;
if (isSecureConnection) {
const encryptedResponse = responseDataTmp;
if (encryptedResponse?.result?.response) {
const decryptedResponse = this.decryptResponse(encryptedResponse.result.response);
responseData = JSON.parse(decryptedResponse);
}
}
else {
responseData = responseDataTmp;
}
this.log.debug("API response", response.status, JSON.stringify(responseData));
// Log error codes
if (responseData && responseData.error_code !== 0) {
const errorCode = String(responseData.error_code);
const errorMessage = errorCode in ERROR_CODES_MAP
? ERROR_CODES_MAP[errorCode]
: "Unknown error";
this.log.debug(`API request failed with specific error code ${errorCode}: ${errorMessage}`);
}
if (!responseData ||
responseData.error_code === -40401 ||
responseData.error_code === -1) {
this.log.debug("API request failed, reauth now and trying same request again", responseData);
this.stok = undefined;
return this.apiRequest(req, loginRetryCount + 1);
}
// Success
return responseData;
}
finally {
this.pendingAPIRequests.delete(reqJson);
}
})());
return this.pendingAPIRequests.get(reqJson);
}
static SERVICE_MAP = {
eyes: (value) => ({
method: "setLensMaskConfig",
params: {
lens_mask: {
lens_mask_info: {
// Watch out for the inversion
enabled: value ? "off" : "on",
},
},
},
}),
alarm: (value) => ({
method: "setAlertConfig",
params: {
msg_alarm: {
chn1_msg_alarm_info: {
enabled: value ? "on" : "off",
},
},
},
}),
notifications: (value) => ({
method: "setMsgPushConfig",
params: {
msg_push: {
chn1_msg_push_info: {
notification_enabled: value ? "on" : "off",
rich_notification_enabled: value ? "on" : "off",
},
},
},
}),
motionDetection: (value) => ({
method: "setDetectionConfig",
params: {
motion_detection: {
motion_det: {
enabled: value ? "on" : "off",
},
},
},
}),
led: (value) => ({
method: "setLedStatus",
params: {
led: {
config: {
enabled: value ? "on" : "off",
},
},
},
}),
};
async setStatus(service, value) {
const responseData = await this.apiRequest({
method: "multipleRequest",
params: {
requests: [TAPOCamera.SERVICE_MAP[service](value)],
},
});
if (responseData.error_code !== 0) {
throw new Error(`Failed to perform ${service} action`);
}
const method = TAPOCamera.SERVICE_MAP[service](value).method;
const operation = responseData.result.responses.find((e) => e.method === method);
if (operation?.error_code !== 0) {
throw new Error(`Failed to perform ${service} action`);
}
return operation.result;
}
async getBasicInfo() {
const responseData = await this.apiRequest({
method: "multipleRequest",
params: {
requests: [
{
method: "getDeviceInfo",
params: {
device_info: {
name: ["basic_info"],
},
},
},
],
},
});
const info = responseData.result
.responses[0];
return info.result.device_info.basic_info;
}
async getStatus() {
const responseData = await this.apiRequest({
method: "multipleRequest",
params: {
requests: [
{
method: "getAlertConfig",
params: {
msg_alarm: {
name: "chn1_msg_alarm_info",
},
},
},
{
method: "getLensMaskConfig",
params: {
lens_mask: {
name: "lens_mask_info",
},
},
},
{
method: "getMsgPushConfig",
params: {
msg_push: {
name: "chn1_msg_push_info",
},
},
},
{
method: "getDetectionConfig",
params: {
motion_detection: {
name: "motion_det",
},
},
},
{
method: "getLedStatus",
params: {
led: {
name: "config",
},
},
},
],
},
});
const operations = responseData.result.responses;
const alert = operations.find((r) => r.method === "getAlertConfig");
const lensMask = operations.find((r) => r.method === "getLensMaskConfig");
const notifications = operations.find((r) => r.method === "getMsgPushConfig");
const motionDetection = operations.find((r) => r.method === "getDetectionConfig");
const led = operations.find((r) => r.method === "getLedStatus");
if (!alert)
this.log.debug("No alert config found");
if (!lensMask)
this.log.debug("No lens mask config found");
if (!notifications)
this.log.debug("No notifications config found");
if (!motionDetection)
this.log.debug("No motion detection config found");
if (!led)
this.log.debug("No led config found");
return {
alarm: alert
? alert.result.msg_alarm.chn1_msg_alarm_info.enabled === "on"
: undefined,
// Watch out for the inversion
eyes: lensMask
? lensMask.result.lens_mask.lens_mask_info.enabled === "off"
: undefined,
notifications: notifications
? notifications.result.msg_push.chn1_msg_push_info
.notification_enabled === "on"
: undefined,
motionDetection: motionDetection
? motionDetection.result.motion_detection.motion_det.enabled === "on"
: undefined,
led: led ? led.result.led.config.enabled === "on" : undefined,
};
}
}
exports.TAPOCamera = TAPOCamera;
//# sourceMappingURL=tapoCamera.js.map