From 301df3ad0d745f405bb50fb15a04907f7ef26e4c Mon Sep 17 00:00:00 2001 From: Flavio De Stefano Date: Sun, 26 Nov 2023 13:04:57 +0100 Subject: [PATCH] Using encrypted traffic --- .DS_Store | Bin 0 -> 6148 bytes .gitignore | 4 +- package-lock.json | 35 ++- package.json | 3 +- src/cameraAccessory.ts | 3 +- src/onvif.d.ts | 289 ----------------- src/onvifCamera.ts | 13 +- src/tapoCamera.test.ts | 31 ++ src/tapoCamera.ts | 511 +++++++++++++++++++++++++------ src/types/onvif.ts | 286 +++++++++++++++++ src/{tapo.d.ts => types/tapo.ts} | 30 +- 11 files changed, 807 insertions(+), 398 deletions(-) create mode 100644 .DS_Store delete mode 100644 src/onvif.d.ts create mode 100644 src/tapoCamera.test.ts create mode 100644 src/types/onvif.ts rename src/{tapo.d.ts => types/tapo.ts} (83%) diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..50f263e222648c81d7fdb78ee5e090e32ef696f6 GIT binary patch literal 6148 zcmeHKy-EW?5dPL&NYH>O6a<@l0wExF!Wk}Qs#I2nB$5ahFPH=yk?VuLfRA9Wt%aq% zr1Jp;AHeuCv%8wToCy{|B<#TMx4Sd5GxuF?Hv>Qv`;7vS2au)4=qywBgRq`kLYDN( z8lm7lf@Yyo58IuDrCqQLSO)$h1N`ke*g_LW=*7O@^ytOZwL{;p1!0ZWvga2Mw;!eZ zy|}(HQ{QPBXG10;S7g58qJ=(cvFKPmOA5=LX2MuDO zJHZ16JEqReVYj^5`sP9Vn$dsK$KukdNs_}cLW+He1GMF+kAP-flach}&DktlGp}Cm zbvWFY?`P`XW}d&XH}lS{KXKkE;_3u-GfN=Y9&5~;uC52~Z|Z$%KQn`*){F<-aXc&Y zWkCJ&k*rRnv5 z+{w-?1D1h>Vn8^4#V^t$>AN-YaD3Og)T-22*e+5iU8vL7u^jMKyg^M0>0BNVdKx2z Sv_fM42uK=iXBqfY20j7HX|S6B literal 0 HcmV?d00001 diff --git a/.gitignore b/.gitignore index 6c96959..a9146d9 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,6 @@ node_modules/ .env.test # parcel-bundler cache (https://parceljs.org/) -.cache \ No newline at end of file +.cache + +.env \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 481a941..9521a53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.2.3", "license": "ISC", "dependencies": { + "dotenv": "^16.3.1", "homebridge-camera-ffmpeg": "^3.1.4", "node-fetch": "^2.6.7", "onvif": "^0.6.6" @@ -22,7 +23,7 @@ "homebridge": "^1.3.5", "nodemon": "^2.0.15", "rimraf": "^3.0.2", - "ts-node": "^10.5.0", + "ts-node": "^10.9.1", "typescript": "^4.4.3" }, "engines": { @@ -973,11 +974,14 @@ } }, "node_modules/dotenv": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", - "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", "engines": { - "node": ">=10" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/motdotla/dotenv?sponsor=1" } }, "node_modules/duplexer": { @@ -1379,6 +1383,14 @@ "tar": "^6.0.1" } }, + "node_modules/ffmpeg-for-homebridge/node_modules/dotenv": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", + "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", + "engines": { + "node": ">=10" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -4285,9 +4297,9 @@ } }, "dotenv": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", - "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==" + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==" }, "duplexer": { "version": "0.1.2", @@ -4611,6 +4623,13 @@ "mkdirp": "^1.0.3", "simple-get": "^3.1.0", "tar": "^6.0.1" + }, + "dependencies": { + "dotenv": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", + "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==" + } } }, "file-entry-cache": { diff --git a/package.json b/package.json index 49fc9e9..da5086d 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "url": "https://www.paypal.me/kopiro" }, "dependencies": { + "dotenv": "^16.3.1", "homebridge-camera-ffmpeg": "^3.1.4", "node-fetch": "^2.6.7", "onvif": "^0.6.6" @@ -56,7 +57,7 @@ "homebridge": "^1.3.5", "nodemon": "^2.0.15", "rimraf": "^3.0.2", - "ts-node": "^10.5.0", + "ts-node": "^10.9.1", "typescript": "^4.4.3" } } diff --git a/src/cameraAccessory.ts b/src/cameraAccessory.ts index da33c4f..6f577e9 100644 --- a/src/cameraAccessory.ts +++ b/src/cameraAccessory.ts @@ -9,13 +9,14 @@ import { StreamingDelegate } from "homebridge-camera-ffmpeg/dist/streamingDelega import { Logger } from "homebridge-camera-ffmpeg/dist/logger"; import { TAPOCamera } from "./tapoCamera"; import { PLUGIN_ID } from "./pkg"; -import { DeviceInformation } from "onvif"; +import { DeviceInformation } from "./types/onvif"; import { CameraPlatform } from "./cameraPlatform"; import { VideoConfig } from "homebridge-camera-ffmpeg/dist/configTypes"; export type CameraConfig = { name: string; ipAddress: string; + username: string; password: string; streamUser: string; streamPassword: string; diff --git a/src/onvif.d.ts b/src/onvif.d.ts deleted file mode 100644 index 9ac0994..0000000 --- a/src/onvif.d.ts +++ /dev/null @@ -1,289 +0,0 @@ -declare module "onvif" { - import { EventEmitter } from "events"; - - export type VideoSource = { - framerate: number; - resolution: { - width: number; - height: number; - }; - }; - - export interface Bounds { - $: { - height: number; - width: number; - y: number; - x: number; - }; - } - - export interface VideoSourceConfiguration { - $: { - token: string; - }; - name: string; - useCount: number; - sourceToken: string; - bounds: Bounds; - } - - export interface AudioSourceConfiguration { - $: { - token: string; - }; - name: string; - useCount: number; - sourceToken: string; - } - export interface Resolution { - width: number; - height: number; - } - - export interface RateControl { - frameRateLimit: number; - encodingInterval: number; - bitrateLimit: number; - } - - export interface H264 { - govLength: number; - H264Profile: string; - } - - export interface Address { - type: string; - IPv4Address: string; - } - - export interface Multicast { - address: Address; - port: number; - TTL: number; - autoStart: boolean; - } - - export interface VideoEncoderConfiguration { - $: { - token: string; - }; - name: string; - useCount: number; - encoding: string; - resolution: Resolution; - quality: number; - rateControl: RateControl; - H264: H264; - multicast: Multicast; - sessionTimeout: string; - } - - export interface Address2 { - type: string; - IPv4Address: string; - } - - export interface Multicast2 { - address: Address2; - port: number; - TTL: number; - autoStart: boolean; - } - - export interface AudioEncoderConfiguration { - $: { - token: string; - }; - name: string; - useCount: number; - encoding: string; - bitrate: number; - sampleRate: number; - multicast: Multicast2; - sessionTimeout: string; - } - - export interface SimpleItem { - $: { - Value: string | boolean; - Name: string; - }; - } - - export interface Translate { - $: { - y: number; - x: number; - }; - } - - export interface Scale { - $: { - y: number; - x: number; - }; - } - - export interface Transformation { - translate: Translate; - scale: Scale; - } - - export interface CellLayout { - $: { - Rows: number; - Columns: number; - }; - transformation: Transformation; - } - - export interface ElementItem { - $: { - Name: string; - }; - cellLayout: CellLayout; - } - - export interface Parameters { - simpleItem: SimpleItem[]; - elementItem: ElementItem; - } - - export interface AnalyticsModule { - parameters: Parameters; - } - - export interface AnalyticsEngineConfiguration { - analyticsModule: AnalyticsModule[]; - } - - export interface Rule { - $: { - Name: string; - Type: string; - }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - parameters: any; - } - - export interface RuleEngineConfiguration { - rule: Rule[]; - } - - export interface VideoAnalyticsConfiguration { - $: { - token: string; - }; - name: string; - useCount: number; - analyticsEngineConfiguration: AnalyticsEngineConfiguration; - ruleEngineConfiguration: RuleEngineConfiguration; - } - - export interface PanTilt { - $: { - space: string; - y: number; - x: number; - }; - } - - export interface DefaultPTZSpeed { - panTilt: PanTilt; - } - - export interface XRange { - min: number; - max: number; - } - - export interface YRange { - min: number; - max: number; - } - - export interface Range { - URI: string; - XRange: XRange; - YRange: YRange; - } - - export interface PanTiltLimits { - range: Range; - } - - export interface PTZConfiguration { - $: { - token: string; - }; - name: string; - useCount: number; - nodeToken: string; - defaultAbsolutePantTiltPositionSpace: string; - defaultRelativePanTiltTranslationSpace: string; - defaultContinuousPanTiltVelocitySpace: string; - defaultPTZSpeed: DefaultPTZSpeed; - defaultPTZTimeout: string; - panTiltLimits: PanTiltLimits; - } - - export interface Profile { - $: { - fixed: boolean; - token: string; - }; - name: string; - videoSourceConfiguration: VideoSourceConfiguration; - audioSourceConfiguration: AudioSourceConfiguration; - videoEncoderConfiguration: VideoEncoderConfiguration; - audioEncoderConfiguration: AudioEncoderConfiguration; - videoAnalyticsConfiguration: VideoAnalyticsConfiguration; - PTZConfiguration: PTZConfiguration; - } - - export type DeviceInformation = { - manufacturer: string; - model: string; - firmwareVersion: string; - serialNumber: string; - hardwareId: string; - }; - - export type ConnectionCallback = (error?: Error) => void; - - export interface NotificationMessage { - topic: { _: string }; - message: { - message: { - $: object; - source: object; - data: { - simpleItem: SimpleItem; - }; - }; - }; - } - - export interface CamOptions { - hostname: string; - username?: string; - password?: string; - port?: number; - path?: string; - timeout?: number; - preserveAddress?: boolean; - } - export class Cam extends EventEmitter { - constructor(options: CamOptions, callback: ConnectionCallback); - - connect(callback: ConnectionCallback): void; - on(event: "event", listener: (message: NotificationMessage) => void): this; - getDeviceInformation( - callback: (error: Error, deviceInformation: DeviceInformation) => void - ): void; - getProfiles(callback: (error: Error, profiles: Profile[]) => void): void; - - videoSources: VideoSource[]; - } -} diff --git a/src/onvifCamera.ts b/src/onvifCamera.ts index 47b3df4..dcd9b1c 100644 --- a/src/onvifCamera.ts +++ b/src/onvifCamera.ts @@ -1,11 +1,14 @@ import { Logging } from "homebridge"; import { CameraConfig } from "./cameraAccessory"; import { - Cam, DeviceInformation, VideoSource, NotificationMessage, -} from "onvif"; + Cam as ICam, +} from "./types/onvif"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { Cam } from "onvif"; import { EventEmitter } from "stream"; export class OnvifCamera { @@ -19,20 +22,20 @@ export class OnvifCamera { protected readonly config: CameraConfig ) {} - private async getDevice(): Promise { + private async getDevice(): Promise { return new Promise((resolve, reject) => { if (this.device) { return resolve(this.device); } - const device: Cam = new Cam( + const device: ICam = new Cam( { hostname: this.config.ipAddress, username: this.config.streamUser, password: this.config.streamPassword, port: this.kOnvifPort, }, - (err) => { + (err: Error) => { if (err) { return reject(err); } diff --git a/src/tapoCamera.test.ts b/src/tapoCamera.test.ts new file mode 100644 index 0000000..1d3c7ae --- /dev/null +++ b/src/tapoCamera.test.ts @@ -0,0 +1,31 @@ +import "dotenv/config"; + +import { TAPOCamera } from "./tapoCamera"; + +async function main() { + const tapoCamera = new TAPOCamera({ ...console, debug: () => {} } as any, { + name: "Test", + ipAddress: process.env.CAMERA_IP!, + username: process.env.CAMERA_USERNAME!, + password: process.env.CAMERA_PASSWORD!, + streamUser: process.env.CAMERA_STREAM_USERNAME!, + streamPassword: process.env.CAMERA_STREAM_PASSWORD!, + }); + + const basicInfo = await tapoCamera.getBasicInfo(); + console.log("basicInfo :>> ", basicInfo); + + const status = await tapoCamera.getStatus(); + console.log("status :>> ", status); + + const streamUrl = await tapoCamera.getAuthenticatedStreamUrl(); + console.log("streamUrl :>> ", streamUrl); + + await tapoCamera.setLensMaskConfig(false); + setTimeout(async () => { + const status = await tapoCamera.getStatus(); + console.log("status :>> ", status); + }, 5000); +} + +main(); diff --git a/src/tapoCamera.ts b/src/tapoCamera.ts index fbe45e1..385778a 100644 --- a/src/tapoCamera.ts +++ b/src/tapoCamera.ts @@ -1,18 +1,41 @@ import { Logging } from "homebridge"; -import fetch from "node-fetch"; +import fetch, { RequestInit, Response } from "node-fetch"; import https, { Agent } from "https"; import { CameraConfig } from "./cameraAccessory"; import crypto from "crypto"; import { OnvifCamera } from "./onvifCamera"; +import type { + TAPOCameraEncryptedRequest, + TAPOCameraEncryptedResponse, + TAPOCameraRequest, + TAPOCameraResponse, + TAPOCameraResponseDeviceInfo, + TAPOCameraResponseGetAlert, + TAPOCameraResponseGetLensMask, +} from "./types/tapo"; + +const MAX_LOGIN_RETRIES = 3; +const AES_BLOCK_SIZE = 16; export class TAPOCamera extends OnvifCamera { - private readonly kTokenExpiration = 1000 * 60 * 60; // 1h private readonly kStreamPort = 554; private readonly httpsAgent: Agent; - private readonly hashedPassword: string; - private token: [string, number] | undefined; - private tokenPromise: (() => Promise) | undefined; + private readonly hashedMD5Password: string; + private readonly hashedSha256Password: string; + private passwordEncryptionMethod: "md5" | "sha256" | null = null; + + private isSecureConnectionValue: boolean | null = null; + + private stokPromise: (() => Promise) | undefined; + + private readonly cnonce: string; + private lsk: Buffer | undefined; + private ivb: Buffer | undefined; + private seq: number | undefined; + private stok: string | undefined; + + private loginRetryCount = 0; constructor( protected readonly log: Logging, @@ -23,95 +46,366 @@ export class TAPOCamera extends OnvifCamera { this.httpsAgent = new https.Agent({ rejectUnauthorized: false, }); - this.hashedPassword = crypto + + this.cnonce = this.generateCnonce(); + + this.hashedMD5Password = crypto .createHash("md5") .update(config.password) .digest("hex") .toUpperCase(); + this.hashedSha256Password = crypto + .createHash("sha256") + .update(config.password) + .digest("hex") + .toUpperCase(); + } + + private getHeaders() { + const headers: Record = { + 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", + }; + return headers; } - fetch(url: string, data: object) { + private getHashedPassword() { + if (this.passwordEncryptionMethod === "md5") { + return this.hashedMD5Password; + } else if (this.passwordEncryptionMethod === "sha256") { + return this.hashedSha256Password; + } else { + throw new Error("Unknown password encryption method"); + } + } + + private fetch(url: string, data: RequestInit) { return fetch(url, { - ...data, agent: this.httpsAgent, + headers: this.getHeaders(), + ...data, }); } - getTapoAPICredentials() { - return { - username: "admin", - password: this.hashedPassword, - }; + private generateEncryptionToken(tokenType: string, nonce: string): Buffer { + const hashedKey = crypto + .createHash("sha256") + .update(this.cnonce + this.getHashedPassword() + nonce) + .digest("hex") + .toUpperCase(); + return crypto + .createHash("sha256") + .update(tokenType + this.cnonce + nonce + hashedKey) + .digest() + .slice(0, 16); } - getAuthenticatedStreamUrl(lowQuality: boolean) { + getAuthenticatedStreamUrl(lowQuality = false) { const prefix = `rtsp://${this.config.streamUser}:${this.config.streamPassword}@${this.config.ipAddress}:${this.kStreamPort}`; return lowQuality ? `${prefix}/stream2` : `${prefix}/stream1`; } - private async fetchToken(): Promise { - this.log.debug(`[${this.config.name}]`, "Fetching new token"); - - const response = await this.fetch(`https://${this.config.ipAddress}/`, { - method: "post", - body: JSON.stringify({ - method: "login", - params: this.getTapoAPICredentials(), - }), - headers: { - "Content-Type": "application/json", - }, - }); + private generateCnonce() { + return crypto.randomBytes(8).toString("hex").toUpperCase(); + } - const json = (await response.json()) as { - result: { stok: string; user_group: string }; - error_code: number; - }; + private validateDeviceConfirm(nonce: string, deviceConfirm: string) { + const hashedNoncesWithSHA256 = crypto + .createHash("sha256") + .update(this.cnonce + this.hashedSha256Password + nonce) + .digest("hex") + .toUpperCase(); + const hashedNoncesWithMD5 = crypto + .createHash("md5") + .update(this.cnonce + this.hashedMD5Password + nonce) + .digest("hex") + .toUpperCase(); - if (!json.result.stok) { - throw new Error( - "Unable to find token in response, probably your credentials are not valid. Please make sure you set your TAPO Cloud password" - ); + if (deviceConfirm === hashedNoncesWithSHA256 + nonce + this.cnonce) { + this.passwordEncryptionMethod = "sha256"; + return true; + } + + if (deviceConfirm === hashedNoncesWithMD5 + nonce + this.cnonce) { + this.passwordEncryptionMethod = "md5"; + return true; } - return json.result.stok; + return false; } - async getToken(): Promise { - if (this.token && this.token[1] + this.kTokenExpiration > Date.now()) { - return this.token[0]; + async refreshStok(loginRetryCount = 0): Promise { + const isSecureConnection = await this.isSecureConnection(); + + let response = null; + let responseData = null; + + let fetchParams = {}; + if (isSecureConnection) { + this.log.debug("StokRefresh: Using secure connection"); + fetchParams = { + method: "post", + body: JSON.stringify({ + method: "login", + params: { + cnonce: this.cnonce, + encrypt_type: "3", + username: this.config.username, + }, + }), + }; + } else { + this.log.debug("StokRefresh: Using unsecure connection"); + fetchParams = { + method: "post", + body: JSON.stringify({ + method: "login", + params: { + username: this.config.username, + password: this.getHashedPassword(), + hashed: true, + }, + }), + }; } - if (this.tokenPromise) { - return this.tokenPromise(); + response = await this.fetch( + `https://${this.config.ipAddress}`, + fetchParams + ); + responseData = await response.json(); + + this.log.debug( + "StokRefresh: Login response :>> ", + response.status, + responseData + ); + + if (response.status === 401) { + if (responseData?.result?.data?.code === 40411) { + throw new Error("Invalid credentials"); + } } - this.tokenPromise = async () => { - try { + if (isSecureConnection) { + this.log.debug("StokRefresh: Processing secure connection"); + + const nonce = responseData?.result?.data?.nonce; + const deviceConfirm = responseData?.result?.data?.device_confirm; + + if ( + nonce && + deviceConfirm && + this.validateDeviceConfirm(nonce, deviceConfirm) + ) { + this.log.debug("StokRefresh: Signing in with digestPasswd"); + + const digestPasswd = crypto + .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"); + + 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.config.username, + }, + }), + }); + + responseData = await response.json(); + this.log.debug( - `[${this.config.name}]`, - "Token is expired , requesting new one." + "StokRefresh: Start_seq response :>>", + response.status, + responseData ); - const token = await this.fetchToken(); - this.token = [token, Date.now()]; - return token; - } finally { - this.tokenPromise = undefined; + if (responseData?.result?.start_seq) { + if (responseData?.result?.user_group !== "root") { + // # 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; + + this.log.debug( + "StokRefresh: Generated encryption tokens, start_seq :>>", + this.seq + ); + } } - }; - return this.tokenPromise(); + } else { + this.passwordEncryptionMethod = "md5"; + } + + if (responseData?.result?.data?.sec_left > 0) { + throw new Error( + `StokRefresh: Temporary Suspension: Try again in ${responseData.result.data.sec_left} seconds` + ); + } + + if ( + responseData?.data?.code == -40404 && + responseData?.data?.sec_left > 0 + ) { + throw new Error( + `StokRefresh: Temporary Suspension: Try again in ${responseData.data.sec_left} seconds` + ); + } + + if (responseData?.result?.stok) { + this.stok = responseData.result.stok; + this.log.debug("StokRefresh: Success :>>", this.stok); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.stok!; + } + + if ( + responseData?.error_code === -40413 && + loginRetryCount < MAX_LOGIN_RETRIES + ) { + this.log.debug( + `Unexpected response, retrying: ${loginRetryCount}/${MAX_LOGIN_RETRIES}.`, + response.status, + responseData + ); + return this.refreshStok(loginRetryCount + 1); + } + + throw new Error("Invalid authentication data"); + } + + async isSecureConnection() { + if (this.isSecureConnectionValue === null) { + const response = await this.fetch(`https://${this.config.ipAddress}`, { + method: "post", + body: JSON.stringify({ + method: "login", + params: { + encrypt_type: "3", + username: this.config.username, + }, + }), + }); + const json = await response.json(); + + this.log.debug("isSecureConnection response :>> ", response.status, json); + + this.isSecureConnectionValue = + json.error_code == -40413 && + json?.result?.data?.encrypt_type?.includes("3"); + } + + return this.isSecureConnectionValue; } - private async getTAPOCameraAPIUrl() { - const token = await this.getToken(); + getStok(loginRetryCount = 0): Promise { + if (this.stok) { + return new Promise((resolve) => resolve(this.stok!)); + } + + if (!this.stokPromise) { + this.stokPromise = () => this.refreshStok(loginRetryCount); + } + + return this.stokPromise() + .then(() => { + return this.stok!; + }) + .finally(() => { + this.stokPromise = undefined; + }); + } + + private async getAuthenticatedAPIURL(loginRetryCount = 0) { + const token = await this.getStok(loginRetryCount); return `https://${this.config.ipAddress}/stok=${token}/ds`; } + encryptRequest(request: string) { + const cipher = crypto.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"); + } + + private encryptPad(text: string, blocksize: number) { + const padSize = blocksize - (text.length % blocksize); + const padding = String.fromCharCode(padSize).repeat(padSize); + return text + padding; + } + + private decryptResponse(response: string): string { + const decipher = crypto.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); + } + + private encryptUnpad(text: string, blockSize: number): string { + 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(); + } + + private getTapoTag(request: TAPOCameraEncryptedRequest) { + const tag = crypto + .createHash("sha256") + .update(this.getHashedPassword() + this.cnonce) + .digest("hex") + .toUpperCase(); + return crypto + .createHash("sha256") + .update(tag + JSON.stringify(request) + this.seq!.toString()) + .digest("hex") + .toUpperCase(); + } + private pendingAPIRequests: Map> = new Map(); - private async makeTAPOAPIRequest(req: TAPOCameraRequest) { + private async apiRequest( + req: TAPOCameraRequest, + loginRetryCount = 0 + ): Promise { const reqJson = JSON.stringify(req); if (this.pendingAPIRequests.has(reqJson)) { @@ -120,36 +414,71 @@ export class TAPOCamera extends OnvifCamera { ) as Promise; } - this.log.debug( - `[${this.config.name}]`, - "Making new request req =", - req.params.requests.map((e) => e.method) - ); + this.log.debug("API new request", req); this.pendingAPIRequests.set( reqJson, (async () => { try { - const url = await this.getTAPOCameraAPIUrl(); + const isSecureConnection = await this.isSecureConnection(); + const url = await this.getAuthenticatedAPIURL(loginRetryCount); - const response = await this.fetch(url, { + const fetchParams: RequestInit = { method: "post", - body: JSON.stringify(req), - headers: { - "Content-Type": "application/json", - }, - }); - const json = (await response.json()) as TAPOCameraResponse; - this.log.debug( - `makeTAPOAPIRequest url: ${url}, json: ${JSON.stringify(json)}` - ); - if (json.error_code !== 0) { - // Because of the token error when the camera comes back from no response. - this.log.info("Reset token. error_code: ", json.error_code); - this.token = undefined; + }; + + if (this.seq && isSecureConnection) { + const encryptedRequest: TAPOCameraEncryptedRequest = { + 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); + let json = await response.json(); + + this.log.debug(`API response`, reqJson, response.status, json); + + if (isSecureConnection) { + const encryptedResponse = json as TAPOCameraEncryptedResponse; + if (encryptedResponse.result.response) { + const decryptedResponse = this.decryptResponse( + encryptedResponse.result.response + ); + json = JSON.parse(decryptedResponse) as TAPOCameraResponse; + this.log.debug(`API final response`, json); + } + } else { + json = json as TAPOCameraResponse; } - return 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.stok = undefined; + } + + // Check if we have to refresh the token + if (json.error_code === -40401 || json.error_code === -1) { + this.log.debug("API request failed, reauthenticating"); + this.stok = undefined; + return this.apiRequest(req, loginRetryCount + 1); + } + + return json as TAPOCameraResponse; } finally { this.pendingAPIRequests.delete(reqJson); } @@ -160,7 +489,9 @@ export class TAPOCamera extends OnvifCamera { } async setLensMaskConfig(value: boolean) { - const json = await this.makeTAPOAPIRequest({ + this.log.debug("setLensMaskConfig", value); + + const json = await this.apiRequest({ method: "multipleRequest", params: { requests: [ @@ -178,11 +509,15 @@ export class TAPOCamera extends OnvifCamera { }, }); - return json.error_code !== 0; + if (json.error_code !== 0) { + throw new Error("Failed to perform action"); + } } async setAlertConfig(value: boolean) { - const json = await this.makeTAPOAPIRequest({ + this.log.debug("setAlertConfig", value); + + const json = await this.apiRequest({ method: "multipleRequest", params: { requests: [ @@ -200,11 +535,15 @@ export class TAPOCamera extends OnvifCamera { }, }); - return json.error_code !== 0; + if (json.error_code !== 0) { + throw new Error("Failed to perform action"); + } } - async getTAPODeviceInfo() { - const json = await this.makeTAPOAPIRequest({ + async getBasicInfo() { + this.log.debug("getBasicInfo"); + + const json = await this.apiRequest({ method: "multipleRequest", params: { requests: [ @@ -225,7 +564,9 @@ export class TAPOCamera extends OnvifCamera { } async getStatus(): Promise<{ lensMask: boolean; alert: boolean }> { - const json = await this.makeTAPOAPIRequest({ + this.log.debug("getStatus"); + + const json = await this.apiRequest({ method: "multipleRequest", params: { requests: [ @@ -249,10 +590,6 @@ export class TAPOCamera extends OnvifCamera { }, }); - if (json.error_code !== 0) { - throw new Error("Camera replied with error"); - } - const alertConfig = json.result.responses.find( (r) => r.method === "getAlertConfig" ) as TAPOCameraResponseGetAlert; diff --git a/src/types/onvif.ts b/src/types/onvif.ts new file mode 100644 index 0000000..2a3164a --- /dev/null +++ b/src/types/onvif.ts @@ -0,0 +1,286 @@ +import { EventEmitter } from "events"; + +export type VideoSource = { + framerate: number; + resolution: { + width: number; + height: number; + }; +}; + +export interface Bounds { + $: { + height: number; + width: number; + y: number; + x: number; + }; +} + +export interface VideoSourceConfiguration { + $: { + token: string; + }; + name: string; + useCount: number; + sourceToken: string; + bounds: Bounds; +} + +export interface AudioSourceConfiguration { + $: { + token: string; + }; + name: string; + useCount: number; + sourceToken: string; +} +export interface Resolution { + width: number; + height: number; +} + +export interface RateControl { + frameRateLimit: number; + encodingInterval: number; + bitrateLimit: number; +} + +export interface H264 { + govLength: number; + H264Profile: string; +} + +export interface Address { + type: string; + IPv4Address: string; +} + +export interface Multicast { + address: Address; + port: number; + TTL: number; + autoStart: boolean; +} + +export interface VideoEncoderConfiguration { + $: { + token: string; + }; + name: string; + useCount: number; + encoding: string; + resolution: Resolution; + quality: number; + rateControl: RateControl; + H264: H264; + multicast: Multicast; + sessionTimeout: string; +} + +export interface Address2 { + type: string; + IPv4Address: string; +} + +export interface Multicast2 { + address: Address2; + port: number; + TTL: number; + autoStart: boolean; +} + +export interface AudioEncoderConfiguration { + $: { + token: string; + }; + name: string; + useCount: number; + encoding: string; + bitrate: number; + sampleRate: number; + multicast: Multicast2; + sessionTimeout: string; +} + +export interface SimpleItem { + $: { + Value: string | boolean; + Name: string; + }; +} + +export interface Translate { + $: { + y: number; + x: number; + }; +} + +export interface Scale { + $: { + y: number; + x: number; + }; +} + +export interface Transformation { + translate: Translate; + scale: Scale; +} + +export interface CellLayout { + $: { + Rows: number; + Columns: number; + }; + transformation: Transformation; +} + +export interface ElementItem { + $: { + Name: string; + }; + cellLayout: CellLayout; +} + +export interface Parameters { + simpleItem: SimpleItem[]; + elementItem: ElementItem; +} + +export interface AnalyticsModule { + parameters: Parameters; +} + +export interface AnalyticsEngineConfiguration { + analyticsModule: AnalyticsModule[]; +} + +export interface Rule { + $: { + Name: string; + Type: string; + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parameters: any; +} + +export interface RuleEngineConfiguration { + rule: Rule[]; +} + +export interface VideoAnalyticsConfiguration { + $: { + token: string; + }; + name: string; + useCount: number; + analyticsEngineConfiguration: AnalyticsEngineConfiguration; + ruleEngineConfiguration: RuleEngineConfiguration; +} + +export interface PanTilt { + $: { + space: string; + y: number; + x: number; + }; +} + +export interface DefaultPTZSpeed { + panTilt: PanTilt; +} + +export interface XRange { + min: number; + max: number; +} + +export interface YRange { + min: number; + max: number; +} + +export interface Range { + URI: string; + XRange: XRange; + YRange: YRange; +} + +export interface PanTiltLimits { + range: Range; +} + +export interface PTZConfiguration { + $: { + token: string; + }; + name: string; + useCount: number; + nodeToken: string; + defaultAbsolutePantTiltPositionSpace: string; + defaultRelativePanTiltTranslationSpace: string; + defaultContinuousPanTiltVelocitySpace: string; + defaultPTZSpeed: DefaultPTZSpeed; + defaultPTZTimeout: string; + panTiltLimits: PanTiltLimits; +} + +export interface Profile { + $: { + fixed: boolean; + token: string; + }; + name: string; + videoSourceConfiguration: VideoSourceConfiguration; + audioSourceConfiguration: AudioSourceConfiguration; + videoEncoderConfiguration: VideoEncoderConfiguration; + audioEncoderConfiguration: AudioEncoderConfiguration; + videoAnalyticsConfiguration: VideoAnalyticsConfiguration; + PTZConfiguration: PTZConfiguration; +} + +export type DeviceInformation = { + manufacturer: string; + model: string; + firmwareVersion: string; + serialNumber: string; + hardwareId: string; +}; + +export type ConnectionCallback = (error?: Error) => void; + +export interface NotificationMessage { + topic: { _: string }; + message: { + message: { + $: object; + source: object; + data: { + simpleItem: SimpleItem; + }; + }; + }; +} + +export interface CamOptions { + hostname: string; + username?: string; + password?: string; + port?: number; + path?: string; + timeout?: number; + preserveAddress?: boolean; +} + +export interface Cam extends EventEmitter { + connect(callback: ConnectionCallback): void; + on(event: "event", listener: (message: NotificationMessage) => void): this; + getDeviceInformation( + callback: (error: Error, deviceInformation: DeviceInformation) => void + ): void; + getProfiles(callback: (error: Error, profiles: Profile[]) => void): void; + + videoSources: VideoSource[]; +} diff --git a/src/tapo.d.ts b/src/types/tapo.ts similarity index 83% rename from src/tapo.d.ts rename to src/types/tapo.ts index 92ff071..bb0c90d 100644 --- a/src/tapo.d.ts +++ b/src/types/tapo.ts @@ -1,4 +1,4 @@ -declare type TAPOCameraRequest = { +export type TAPOCameraUnencryptedRequest = { method: "multipleRequest"; params: { requests: Array< @@ -69,7 +69,24 @@ declare type TAPOCameraRequest = { }; }; -declare type TAPOCameraResponseGetAlert = { +export type TAPOCameraEncryptedRequest = { + method: "securePassthrough"; + params: { + request: string; + }; +}; + +export type TAPOCameraRequest = + | TAPOCameraUnencryptedRequest + | TAPOCameraEncryptedRequest; + +export type TAPOCameraEncryptedResponse = { + result: { + response: string; + }; +}; + +export type TAPOCameraResponseGetAlert = { method: "getAlertConfig"; result: { msg_alarm: { @@ -86,7 +103,7 @@ declare type TAPOCameraResponseGetAlert = { error_code: number; }; -declare type TAPOCameraResponseGetLensMask = { +export type TAPOCameraResponseGetLensMask = { method: "getLensMaskConfig"; result: { lens_mask: { @@ -100,13 +117,13 @@ declare type TAPOCameraResponseGetLensMask = { error_code: number; }; -declare type TAPOCameraResponseSet = { +export type TAPOCameraResponseSet = { method: "setLensMaskConfig" | "setAlertConfig"; result: object; error_code: number; }; -declare type TAPOCameraResponseDeviceInfo = { +export type TAPOCameraResponseDeviceInfo = { method: "getDeviceInfo"; result: { device_info: { @@ -130,8 +147,9 @@ declare type TAPOCameraResponseDeviceInfo = { error_code: number; }; -declare type TAPOCameraResponse = { +export type TAPOCameraResponse = { result: { + error_code: number; responses: Array< | TAPOCameraResponseGetAlert | TAPOCameraResponseGetLensMask