tapo_camera_privacy_control/
lib.rs1#![warn(clippy::pedantic)]
2use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
3use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
4use md5::{Digest as Md5Digest, Md5};
5use rand::RngExt;
6use reqwest::header::{HeaderMap, HeaderValue};
7use sha2::Sha256;
8use std::fmt::Write as _;
9use std::sync::atomic::{AtomicI64, Ordering};
10use std::time::Duration;
11use thiserror::Error;
12
13type Aes128CbcEnc = cbc::Encryptor<aes::Aes128>;
14type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
15
16#[derive(Error, Debug)]
17pub enum TapoError {
18 #[error("HTTP error: {0}")]
19 Http(#[from] reqwest::Error),
20 #[error("JSON error: {0}")]
21 Json(#[from] serde_json::Error),
22 #[error("Authentication failed: {0}")]
23 Auth(String),
24 #[error("Device error (code {code}): {message}")]
25 Device { code: i64, message: String },
26 #[error("Encryption error: {0}")]
27 Crypto(String),
28}
29
30#[derive(Debug, Clone, Copy)]
31pub enum PrivacyMode {
32 On,
33 Off,
34}
35
36impl std::fmt::Display for PrivacyMode {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match self {
39 PrivacyMode::On => write!(f, "on"),
40 PrivacyMode::Off => write!(f, "off"),
41 }
42 }
43}
44
45struct Session {
47 stok: String,
48 crypto: Option<CryptoState>,
50}
51
52struct CryptoState {
53 lsk: [u8; 16],
54 ivb: [u8; 16],
55 seq: AtomicI64,
56 tag_prefix: String, }
58
59pub struct TapoCamera {
60 ip: String,
61 username: String,
62 password: String,
63 client: reqwest::Client,
64 timeout: Option<Duration>,
66 session: Option<Session>,
67}
68
69impl TapoCamera {
70 pub fn new(
81 ip: impl Into<String>,
82 username: impl Into<String>,
83 password: impl Into<String>,
84 ) -> anyhow::Result<Self> {
85 let client = reqwest::Client::builder()
86 .danger_accept_invalid_certs(true)
87 .build()
88 .map_err(|e| anyhow::anyhow!("failed to build HTTP client: {e}"))?;
89
90 Ok(Self {
91 ip: ip.into(),
92 username: username.into(),
93 password: password.into(),
94 client,
95 timeout: None,
96 session: None,
97 })
98 }
99
100 pub fn timeout(&self) -> Option<Duration> {
104 self.timeout
105 }
106
107 pub fn set_timeout(&mut self, timeout: Option<Duration>) -> anyhow::Result<()> {
114 let mut builder = reqwest::Client::builder().danger_accept_invalid_certs(true);
115 if let Some(t) = timeout {
116 builder = builder.timeout(t);
117 }
118 let client = builder
119 .build()
120 .map_err(|e| anyhow::anyhow!("failed to build HTTP client: {e}"))?;
121
122 self.client = client;
123 self.timeout = timeout;
124 Ok(())
125 }
126
127 fn base_url(&self) -> String {
128 format!("https://{}:443", self.ip)
129 }
130
131 fn default_headers() -> HeaderMap {
132 let mut h = HeaderMap::new();
133 h.insert(
134 "User-Agent",
135 HeaderValue::from_static("Tapo CameraClient Android"),
136 );
137 h.insert("requestByApp", HeaderValue::from_static("true"));
138 h
139 }
140
141 fn hash_md5(input: &str) -> String {
142 let mut hasher = Md5::new();
143 hasher.update(input.as_bytes());
144 hasher
145 .finalize()
146 .iter()
147 .fold(String::new(), |mut output, b| {
148 let _ = write!(output, "{b:02X}");
149 output
150 })
151 }
152
153 fn hash_sha256(input: &str) -> String {
154 let mut hasher = Sha256::new();
155 hasher.update(input.as_bytes());
156 hasher
157 .finalize()
158 .iter()
159 .fold(String::new(), |mut output, b| {
160 let _ = write!(output, "{b:02X}");
161 output
162 })
163 }
164
165 fn hash_sha256_bytes(input: &[u8]) -> Vec<u8> {
166 let mut hasher = Sha256::new();
167 hasher.update(input);
168 hasher.finalize().to_vec()
169 }
170
171 fn generate_cnonce() -> String {
172 let bytes: [u8; 8] = rand::rng().random();
173 bytes.iter().fold(String::new(), |mut output, b| {
174 let _ = write!(output, "{b:02X}");
175 output
176 })
177 }
178
179 pub async fn login(&mut self) -> Result<(), TapoError> {
188 let cnonce = Self::generate_cnonce();
189
190 let probe = serde_json::json!({
192 "method": "login",
193 "params": {
194 "encrypt_type": "3",
195 "username": self.username,
196 "cnonce": cnonce
197 }
198 });
199
200 let resp: serde_json::Value = self
201 .client
202 .post(self.base_url())
203 .headers(Self::default_headers())
204 .json(&probe)
205 .send()
206 .await?
207 .json()
208 .await?;
209
210 let error_code = resp["error_code"].as_i64().unwrap_or(0);
211
212 if error_code == -40413 {
213 let encrypt_types = &resp["result"]["data"]["encrypt_type"];
215 let supports_secure = if let Some(arr) = encrypt_types.as_array() {
216 arr.iter().any(|v| v.as_str() == Some("3"))
217 } else {
218 false
219 };
220
221 if supports_secure {
222 self.login_secure(&cnonce, &resp).await
223 } else {
224 self.login_insecure().await
225 }
226 } else {
227 self.login_insecure().await
229 }
230 }
231
232 async fn login_secure(
233 &mut self,
234 cnonce: &str,
235 challenge_resp: &serde_json::Value,
236 ) -> Result<(), TapoError> {
237 let device_nonce = challenge_resp["result"]["data"]["nonce"]
238 .as_str()
239 .ok_or_else(|| TapoError::Auth("missing nonce in challenge".into()))?;
240 let device_confirm = challenge_resp["result"]["data"]["device_confirm"]
241 .as_str()
242 .ok_or_else(|| TapoError::Auth("missing device_confirm".into()))?;
243
244 let md5_hash = Self::hash_md5(&self.password);
246 let sha256_hash = Self::hash_sha256(&self.password);
247
248 let hashed_nonces_sha256 =
249 Self::hash_sha256(&format!("{cnonce}{sha256_hash}{device_nonce}"));
250 let hashed_nonces_md5 = Self::hash_sha256(&format!("{cnonce}{md5_hash}{device_nonce}"));
251
252 let expected_confirm_sha256 = format!("{hashed_nonces_sha256}{device_nonce}{cnonce}");
253 let expected_confirm_md5 = format!("{hashed_nonces_md5}{device_nonce}{cnonce}");
254
255 let hashed_password = if device_confirm == expected_confirm_sha256 {
265 sha256_hash
266 } else if device_confirm == expected_confirm_md5 {
267 md5_hash
268 } else {
269 return Err(TapoError::Auth(
270 "device_confirm mismatch — wrong password?".into(),
271 ));
272 };
273
274 let digest = Self::hash_sha256(&format!("{hashed_password}{cnonce}{device_nonce}"));
276 let digest_passwd = format!("{digest}{cnonce}{device_nonce}");
277
278 let login_req = serde_json::json!({
279 "method": "login",
280 "params": {
281 "cnonce": cnonce,
282 "encrypt_type": "3",
283 "digest_passwd": digest_passwd,
284 "username": self.username
285 }
286 });
287
288 let resp: serde_json::Value = self
289 .client
290 .post(self.base_url())
291 .headers(Self::default_headers())
292 .json(&login_req)
293 .send()
294 .await?
295 .json()
296 .await?;
297
298 let error_code = resp["error_code"].as_i64().unwrap_or(-1);
299 if error_code != 0 {
300 return Err(TapoError::Auth(format!(
301 "login failed with error_code {error_code}"
302 )));
303 }
304
305 let stok = resp["result"]["stok"]
306 .as_str()
307 .ok_or_else(|| TapoError::Auth("missing stok in login response".into()))?
308 .to_string();
309
310 let start_seq = resp["result"]["start_seq"].as_i64().unwrap_or(0);
311
312 let hashed_key = Self::hash_sha256(&format!("{cnonce}{hashed_password}{device_nonce}"));
314
315 let lsk_full =
316 Self::hash_sha256_bytes(format!("lsk{cnonce}{device_nonce}{hashed_key}").as_bytes());
317 let ivb_full =
318 Self::hash_sha256_bytes(format!("ivb{cnonce}{device_nonce}{hashed_key}").as_bytes());
319
320 let mut lsk = [0u8; 16];
321 let mut ivb = [0u8; 16];
322 lsk.copy_from_slice(&lsk_full[..16]);
323 ivb.copy_from_slice(&ivb_full[..16]);
324
325 let tag_prefix = Self::hash_sha256(&format!("{hashed_password}{cnonce}"));
326
327 self.session = Some(Session {
328 stok,
329 crypto: Some(CryptoState {
330 lsk,
331 ivb,
332 seq: AtomicI64::new(start_seq),
333 tag_prefix,
334 }),
335 });
336
337 Ok(())
338 }
339
340 async fn login_insecure(&mut self) -> Result<(), TapoError> {
341 let hashed_password = Self::hash_md5(&self.password);
342
343 let login_req = serde_json::json!({
344 "method": "login",
345 "params": {
346 "hashed": true,
347 "password": hashed_password,
348 "username": self.username
349 }
350 });
351
352 let resp: serde_json::Value = self
353 .client
354 .post(self.base_url())
355 .headers(Self::default_headers())
356 .json(&login_req)
357 .send()
358 .await?
359 .json()
360 .await?;
361
362 let error_code = resp["error_code"].as_i64().unwrap_or(-1);
363 if error_code != 0 {
364 return Err(TapoError::Auth(format!(
365 "insecure login failed with error_code {error_code}"
366 )));
367 }
368
369 let stok = resp["result"]["stok"]
370 .as_str()
371 .ok_or_else(|| TapoError::Auth("missing stok".into()))?
372 .to_string();
373
374 self.session = Some(Session { stok, crypto: None });
375
376 Ok(())
377 }
378
379 fn encrypt(crypto: &CryptoState, plaintext: &[u8]) -> Result<Vec<u8>, TapoError> {
380 let enc = Aes128CbcEnc::new(&crypto.lsk.into(), &crypto.ivb.into());
381 let block_size = 16;
383 let padded_len = (plaintext.len() / block_size + 1) * block_size;
384 let mut buf = vec![0u8; padded_len];
385 buf[..plaintext.len()].copy_from_slice(plaintext);
386 let ct = enc
387 .encrypt_padded_mut::<Pkcs7>(&mut buf, plaintext.len())
388 .map_err(|e| TapoError::Crypto(format!("encryption failed: {e}")))?;
389 Ok(ct.to_vec())
390 }
391
392 fn decrypt(crypto: &CryptoState, ciphertext: &[u8]) -> Result<Vec<u8>, TapoError> {
393 let dec = Aes128CbcDec::new(&crypto.lsk.into(), &crypto.ivb.into());
394 let mut buf = ciphertext.to_vec();
395 let pt = dec
396 .decrypt_padded_mut::<Pkcs7>(&mut buf)
397 .map_err(|e| TapoError::Crypto(format!("decryption failed: {e}")))?;
398 Ok(pt.to_vec())
399 }
400
401 fn compute_tag(crypto: &CryptoState, body: &str, seq: i64) -> String {
402 Self::hash_sha256(&format!("{}{}{}", crypto.tag_prefix, body, seq))
403 }
404
405 async fn send_command(
407 &self,
408 payload: serde_json::Value,
409 ) -> Result<serde_json::Value, TapoError> {
410 let session = self
411 .session
412 .as_ref()
413 .ok_or_else(|| TapoError::Auth("not logged in — call login() first".into()))?;
414
415 let url = format!("{}/stok={}/ds", self.base_url(), session.stok);
416
417 if let Some(crypto) = &session.crypto {
418 let inner_json = serde_json::to_string(&payload)?;
419 let encrypted = Self::encrypt(crypto, inner_json.as_bytes())?;
420 let encoded = BASE64.encode(&encrypted);
421
422 let outer = serde_json::json!({
423 "method": "securePassthrough",
424 "params": {
425 "request": encoded
426 }
427 });
428
429 let seq = crypto.seq.fetch_add(1, Ordering::SeqCst);
430 let outer_str = serde_json::to_string(&outer)?;
431 let tag = Self::compute_tag(crypto, &outer_str, seq);
432
433 let mut headers = Self::default_headers();
434 headers.insert("Seq", HeaderValue::from_str(&seq.to_string()).unwrap());
435 headers.insert("Tapo_tag", HeaderValue::from_str(&tag).unwrap());
436
437 let resp: serde_json::Value = self
438 .client
439 .post(&url)
440 .headers(headers)
441 .json(&outer)
442 .send()
443 .await?
444 .json()
445 .await?;
446
447 let error_code = resp["error_code"].as_i64().unwrap_or(0);
448 if error_code != 0 {
449 return Err(TapoError::Device {
450 code: error_code,
451 message: format!("securePassthrough failed: {resp:?}"),
452 });
453 }
454
455 let encrypted_resp =
456 resp["result"]["response"]
457 .as_str()
458 .ok_or_else(|| TapoError::Device {
459 code: -1,
460 message: "missing encrypted response".into(),
461 })?;
462
463 let decoded = BASE64
464 .decode(encrypted_resp)
465 .map_err(|e| TapoError::Crypto(format!("base64 decode failed: {e}")))?;
466 let decrypted = Self::decrypt(crypto, &decoded)?;
467 let result: serde_json::Value = serde_json::from_slice(&decrypted)?;
468
469 Ok(result)
470 } else {
471 let resp: serde_json::Value = self
473 .client
474 .post(&url)
475 .headers(Self::default_headers())
476 .json(&payload)
477 .send()
478 .await?
479 .json()
480 .await?;
481
482 Ok(resp)
483 }
484 }
485
486 fn wrap_command(method: &str, params: &serde_json::Value) -> serde_json::Value {
487 serde_json::json!({
488 "method": "multipleRequest",
489 "params": {
490 "requests": [{
491 "method": method,
492 "params": params
493 }]
494 }
495 })
496 }
497
498 pub async fn set_privacy_mode(&self, mode: PrivacyMode) -> Result<(), TapoError> {
506 let value = match mode {
507 PrivacyMode::On => "on",
508 PrivacyMode::Off => "off",
509 };
510
511 let cmd = Self::wrap_command(
512 "setLensMaskConfig",
513 &serde_json::json!({
514 "lens_mask": {
515 "lens_mask_info": {
516 "enabled": value
517 }
518 }
519 }),
520 );
521
522 let resp = self.send_command(cmd).await?;
523
524 if let Some(responses) = resp["result"]["responses"].as_array() {
526 if let Some(first) = responses.first() {
527 let code = first["error_code"].as_i64().unwrap_or(0);
528 if code != 0 {
529 return Err(TapoError::Device {
530 code,
531 message: format!("setLensMaskConfig failed: {first:?}"),
532 });
533 }
534 }
535 }
536
537 Ok(())
538 }
539
540 pub async fn get_privacy_mode(&self) -> Result<PrivacyMode, TapoError> {
548 let cmd = Self::wrap_command(
549 "getLensMaskConfig",
550 &serde_json::json!({
551 "lens_mask": {
552 "name": ["lens_mask_info"]
553 }
554 }),
555 );
556
557 let resp = self.send_command(cmd).await?;
558
559 let enabled = resp["result"]["responses"][0]["result"]["lens_mask"]["lens_mask_info"]
560 ["enabled"]
561 .as_str()
562 .unwrap_or("off");
563
564 Ok(if enabled == "on" {
565 PrivacyMode::On
566 } else {
567 PrivacyMode::Off
568 })
569 }
570}