From 42bba1665475137892e40439914c523c933ce47b 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 +- config.schema.json | 8 +- package-lock.json | 35 ++- package.json | 3 +- src/cameraAccessory.ts | 23 +- src/onvif.d.ts | 289 ------------------ src/onvifCamera.ts | 13 +- src/tapoCamera.test.ts | 31 ++ src/tapoCamera.ts | 501 +++++++++++++++++++++++++------ src/types/onvif.ts | 286 ++++++++++++++++++ src/{tapo.d.ts => types/tapo.ts} | 30 +- 12 files changed, 812 insertions(+), 411 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/config.schema.json b/config.schema.json index b3cda70..488a059 100644 --- a/config.schema.json +++ b/config.schema.json @@ -27,11 +27,17 @@ "description": "Set the camera IP address", "placeholder": "192.168.0.XXX" }, + "username": { + "title": "TAPO username", + "type": "string", + "required": false, + "description": "Most of the time you should leave this empty, defaulting to admin. If it doesn't work, try to use your streaming username (see below)" + }, "password": { "title": "TAPO password", "type": "string", "required": true, - "description": "Password of your TAPO app (not the password for the RTSP configuration)" + "description": "Password of your TAPO Cloud (the one you use to login the application, not the password for the RTSP configuration). If it doesn't work, try to use your streaming password (see below)" }, "streamUser": { "title": "Stream User", 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..372d780 100644 --- a/src/cameraAccessory.ts +++ b/src/cameraAccessory.ts @@ -1,5 +1,6 @@ import { API, + Characteristic, Logging, PlatformAccessory, PlatformAccessoryEvent, @@ -9,13 +10,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; @@ -80,11 +82,8 @@ export class CameraAccessory { private setupAlarmAccessory() { const name = `${this.config.name} - Alarm`; - this.alarmService = this.accessory.addService( - this.api.hap.Service.Switch, - name, - "alarm" - ); + this.alarmService = new this.api.hap.Service.Switch(name, "alarm"); + this.alarmService = this.accessory.addService(this.alarmService); this.alarmService .getCharacteristic(this.api.hap.Characteristic.On) .onGet(() => { @@ -96,7 +95,7 @@ export class CameraAccessory { return this.cameraStatus.alert; }) .onSet((status) => { - this.log.debug(`Setting alarm to ${status ? "on" : "off"}`); + this.log.info(`Setting alarm to ${status ? "on" : "off"}`); this.camera.setAlertConfig(Boolean(status)).catch((err) => { this.log.error( `[${this.config.name}]`, @@ -110,11 +109,9 @@ export class CameraAccessory { private setupPrivacyModeAccessory() { const name = `${this.config.name} - Eyes`; - this.privacyService = this.accessory.addService( - this.api.hap.Service.Switch, - name, - "eyes" - ); + this.privacyService = new this.api.hap.Service.Switch(name, "privacy"); + this.accessory.addService(this.privacyService); + this.privacyService .getCharacteristic(this.api.hap.Characteristic.On) .onGet(async () => { @@ -126,7 +123,7 @@ export class CameraAccessory { return !this.cameraStatus.lensMask; }) .onSet((status) => { - this.log.debug(`Setting privacy to ${status ? "on" : "off"}`); + this.log.info(`Setting privacy to ${status ? "on" : "off"}`); this.camera.setLensMaskConfig(!status).catch((err) => { this.log.error( `[${this.config.name}]`, 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..21237c8 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,361 @@ 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 getUsername() { + return this.config.username || "admin"; + } + + 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; + } + + 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"); + } } - fetch(url: string, data: object) { + 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; } - return json.result.stok; + if (deviceConfirm === hashedNoncesWithMD5 + nonce + this.cnonce) { + this.passwordEncryptionMethod = "md5"; + return true; + } + + 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.getUsername(), + }, + }), + }; + } else { + this.log.debug("StokRefresh: Using unsecure connection"); + fetchParams = { + method: "post", + body: JSON.stringify({ + method: "login", + params: { + username: this.getUsername(), + 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, + JSON.stringify(responseData) + ); + + if (response.status === 401) { + if (responseData?.result?.data?.code === 40411) { + throw new Error("Invalid credentials"); + } } - this.tokenPromise = async () => { - try { + if (isSecureConnection) { + const nonce = responseData?.result?.data?.nonce; + const deviceConfirm = responseData?.result?.data?.device_confirm; + + if ( + nonce && + deviceConfirm && + this.validateDeviceConfirm(nonce, deviceConfirm) + ) { + 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.getUsername(), + }, + }), + }); + + responseData = await response.json(); + this.log.debug( - `[${this.config.name}]`, - "Token is expired , requesting new one." + "StokRefresh: Start_seq response :>>", + response.status, + JSON.stringify(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; + } } - }; - 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, + JSON.stringify(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.getUsername(), + }, + }), + }); + 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 +409,70 @@ 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", reqJson); 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(); + + if (isSecureConnection) { + const encryptedResponse = json as TAPOCameraEncryptedResponse; + if (encryptedResponse.result.response) { + const decryptedResponse = this.decryptResponse( + encryptedResponse.result.response + ); + json = JSON.parse(decryptedResponse) as TAPOCameraResponse; + } + } else { + json = json as TAPOCameraResponse; + } + + this.log.debug(`API response`, response.status, JSON.stringify(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; } - return json; + // 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 +483,9 @@ export class TAPOCamera extends OnvifCamera { } async setLensMaskConfig(value: boolean) { - const json = await this.makeTAPOAPIRequest({ + this.log.debug("Processing setLensMaskConfig", value); + + const json = await this.apiRequest({ method: "multipleRequest", params: { requests: [ @@ -178,11 +503,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("Processing setAlertConfig", value); + + const json = await this.apiRequest({ method: "multipleRequest", params: { requests: [ @@ -200,11 +529,13 @@ 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() { + const json = await this.apiRequest({ method: "multipleRequest", params: { requests: [ @@ -225,7 +556,7 @@ export class TAPOCamera extends OnvifCamera { } async getStatus(): Promise<{ lensMask: boolean; alert: boolean }> { - const json = await this.makeTAPOAPIRequest({ + const json = await this.apiRequest({ method: "multipleRequest", params: { requests: [ @@ -249,10 +580,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