Skip to main content

tapo_camera_privacy_control/
lib.rs

1#![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
45/// Session state after successful authentication.
46struct Session {
47    stok: String,
48    /// Only present for secure (`encrypt_type` 3) connections.
49    crypto: Option<CryptoState>,
50}
51
52struct CryptoState {
53    lsk: [u8; 16],
54    ivb: [u8; 16],
55    seq: AtomicI64,
56    tag_prefix: String, // SHA256(hashed_password + cnonce), uppercased hex
57}
58
59pub struct TapoCamera {
60    ip: String,
61    username: String,
62    password: String,
63    client: reqwest::Client,
64    /// If `None`, the reqwest client's default timeout is used.
65    timeout: Option<Duration>,
66    session: Option<Session>,
67}
68
69impl TapoCamera {
70    /// Create a new `TapoCamera` client.
71    ///
72    /// The client is configured to accept invalid TLS certificates
73    /// (useful for local camera devices that present self-signed certs).
74    ///
75    /// The client uses reqwest's default timeout unless changed with
76    /// `set_timeout`.
77    ///
78    /// # Errors
79    /// Returns an error if the underlying HTTP client cannot be built.
80    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    /// Get the current request timeout used by the HTTP client.
101    ///
102    /// If `None` is returned, the client is using reqwest's default timeout.
103    pub fn timeout(&self) -> Option<Duration> {
104        self.timeout
105    }
106
107    /// Set a new request timeout and rebuild the underlying HTTP client.
108    ///
109    /// Passing `None` resets the client to use reqwest's default timeout.
110    ///
111    /// # Errors
112    /// Returns an error if rebuilding the HTTP client fails.
113    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    /// Authenticate with the camera. Must be called before sending commands.
180    ///
181    /// # Errors
182    /// - Returns `TapoError::Http` if the HTTP request fails.
183    /// - Returns `TapoError::Json` if the camera response cannot be parsed as JSON.
184    /// - Returns `TapoError::Auth` for authentication-related failures (bad password,
185    ///   missing fields in the challenge/response, or non-zero error codes from the
186    ///   device).
187    pub async fn login(&mut self) -> Result<(), TapoError> {
188        let cnonce = Self::generate_cnonce();
189
190        // Step 1: Probe for secure connection support
191        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            // Check if encrypt_type 3 is supported
214            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            // Camera might not support the probe — try insecure
228            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        // Determine password hash method by checking device_confirm
245        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        // eprintln!("[debug] cnonce:          {}", cnonce);
256        // eprintln!("[debug] device_nonce:    {}", device_nonce);
257        // eprintln!("[debug] device_confirm:  {}", device_confirm);
258        // eprintln!("[debug] confirm len:     {}", device_confirm.len());
259        // eprintln!("[debug] expected (sha256): {}", expected_confirm_sha256);
260        // eprintln!("[debug] expected (md5):    {}", expected_confirm_md5);
261        // eprintln!("[debug] sha256(pass):    {}", sha256_hash);
262        // eprintln!("[debug] md5(pass):       {}", md5_hash);
263
264        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        // Compute digest_passwd
275        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        // Derive AES keys
313        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        // Allocate buffer with space for padding (up to one extra block)
382        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    /// Send a command to the camera. Handles encryption if using secure mode.
406    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            // Insecure mode: send plaintext
472            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    /// Set privacy mode (lens mask) on or off.
499    ///
500    /// # Errors
501    /// - Returns `TapoError::Auth` if not logged in.
502    /// - Returns `TapoError::Http`/`TapoError::Json` for transport or parsing errors.
503    /// - Returns `TapoError::Device` if the camera returns a non-zero error code for
504    ///   the `setLensMaskConfig` request.
505    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        // Check inner response error code
525        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    /// Get current privacy mode status.
541    ///
542    /// # Errors
543    /// - Returns `TapoError::Auth` if not logged in.
544    /// - Returns `TapoError::Http`/`TapoError::Json` for transport or parsing errors.
545    /// - Returns `TapoError::Device` if the camera returns a non-zero error code for
546    ///   the `getLensMaskConfig` request or if the expected fields are missing.
547    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}