tapo 0.9.0

Unofficial Tapo API Client. Works with TP-Link Tapo smart devices. Tested with light bulbs (L510, L520, L530, L535, L610, L630), light strips (L900, L920, L930), plugs (P100, P105, P110, P110M, P115), power strips (P300, P304M, P306, P316M), hubs (H100), switches (S200B, S200D, S210) and sensors (KE100, T100, T110, T300, T310, T315).
Documentation
use std::sync::atomic::{AtomicI32, Ordering};

use super::crypto;

#[derive(Debug)]
pub(super) struct AesSslCipher {
    key: Vec<u8>,
    iv: Vec<u8>,
    tag_prefix: String,
    seq: AtomicI32,
}

impl AesSslCipher {
    pub fn new(
        local_nonce: String,
        server_nonce: &str,
        password_hash: String,
        start_seq: i32,
    ) -> Self {
        let hashed_key =
            crypto::sha256_hex(format!("{local_nonce}{password_hash}{server_nonce}").as_bytes());

        let lsk = crypto::sha256(format!("lsk{local_nonce}{server_nonce}{hashed_key}").as_bytes());
        let ivb = crypto::sha256(format!("ivb{local_nonce}{server_nonce}{hashed_key}").as_bytes());

        let tag_prefix = crypto::sha256_hex(format!("{password_hash}{local_nonce}").as_bytes());

        Self {
            key: lsk[..16].to_vec(),
            iv: ivb[..16].to_vec(),
            tag_prefix,
            seq: AtomicI32::new(start_seq),
        }
    }

    pub fn encrypt(&self, data: &str) -> anyhow::Result<String> {
        crypto::aes128_cbc_encrypt(&self.key, &self.iv, data)
    }

    pub fn generate_tag(&self, request_body: &str, sequence: i32) -> String {
        crypto::sha256_hex(format!("{}{request_body}{sequence}", self.tag_prefix).as_bytes())
    }

    pub fn next_sequence(&self) -> i32 {
        self.seq.fetch_add(1, Ordering::Relaxed)
    }
}

pub(super) fn generate_nonce() -> String {
    use rand::RngExt as _;
    let bytes: [u8; 8] = rand::rng().random();
    base16ct::upper::encode_string(&bytes)
}

pub(super) fn validate_device_confirm(
    local_nonce: &str,
    server_nonce: &str,
    password_hash: &str,
    device_confirm: &str,
) -> bool {
    let expected_hash =
        crypto::sha256_hex(format!("{local_nonce}{password_hash}{server_nonce}").as_bytes());
    let expected = format!("{expected_hash}{server_nonce}{local_nonce}");
    expected == device_confirm
}

pub(super) fn compute_password_digest(
    local_nonce: &str,
    server_nonce: &str,
    password_hash: &str,
) -> String {
    let digest =
        crypto::sha256_hex(format!("{password_hash}{local_nonce}{server_nonce}").as_bytes());
    format!("{digest}{local_nonce}{server_nonce}")
}

#[cfg(test)]
mod tests {
    use super::*;

    impl AesSslCipher {
        fn decrypt(&self, cipher_base64: &str) -> anyhow::Result<String> {
            crypto::aes128_cbc_decrypt(&self.key, &self.iv, cipher_base64)
        }
    }

    #[test]
    fn test_nonce_generation() {
        let nonce = generate_nonce();
        assert_eq!(nonce.len(), 16);
        assert!(nonce.chars().all(|c| c.is_ascii_hexdigit()));
        assert!(nonce.chars().all(|c| !c.is_ascii_lowercase()));
    }

    #[test]
    fn test_sha256_hex() {
        let result = crypto::sha256_hex(b"hello");
        assert_eq!(
            result,
            "2CF24DBA5FB0A30E26E83B2AC5B9E29E1B161E5C1FA7425E73043362938B9824"
        );
    }

    #[test]
    fn test_md5_hex() {
        let result = crypto::md5_hex(b"hello");
        assert_eq!(result, "5D41402ABC4B2A76B9719D911017C592");
    }

    #[test]
    fn test_encrypt_decrypt_round_trip() -> anyhow::Result<()> {
        let cipher = AesSslCipher::new(
            "ABCDEF0123456789".to_string(),
            "1234567890ABCDEF",
            "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855".to_string(),
            0,
        );

        let message = r#"{"method":"get_device_info"}"#;
        let encrypted = cipher.encrypt(message)?;
        let decrypted = cipher.decrypt(&encrypted)?;
        assert_eq!(decrypted, message);

        Ok(())
    }

    #[test]
    fn test_validate_device_confirm() {
        let local_nonce = "AAAA1111BBBB2222";
        let server_nonce = "CCCC3333DDDD4444";
        let pwd_hash = crypto::sha256_hex(b"mypassword");

        let expected_hash =
            crypto::sha256_hex(format!("{local_nonce}{pwd_hash}{server_nonce}").as_bytes());
        let device_confirm = format!("{expected_hash}{server_nonce}{local_nonce}");

        assert!(validate_device_confirm(
            local_nonce,
            server_nonce,
            &pwd_hash,
            &device_confirm
        ));
        assert!(!validate_device_confirm(
            local_nonce,
            server_nonce,
            &pwd_hash,
            "wrong"
        ));
    }

    #[test]
    fn test_compute_digest_passwd() {
        let local_nonce = "AAAA1111BBBB2222";
        let server_nonce = "CCCC3333DDDD4444";
        let pwd_hash = "SOMEHASH";

        let result = compute_password_digest(local_nonce, server_nonce, pwd_hash);

        let expected_digest =
            crypto::sha256_hex(format!("{pwd_hash}{local_nonce}{server_nonce}").as_bytes());
        assert_eq!(
            result,
            format!("{expected_digest}{local_nonce}{server_nonce}")
        );
    }

    #[test]
    fn test_generate_tag() {
        let cipher = AesSslCipher::new(
            "ABCDEF0123456789".to_string(),
            "1234567890ABCDEF",
            "MYPWDHASH".to_string(),
            100,
        );

        let tag = cipher.generate_tag(r#"{"method":"test"}"#, 100);

        let pwd_nonce_hash = crypto::sha256_hex(b"MYPWDHASHABCDEF0123456789");
        let expected =
            crypto::sha256_hex(format!(r#"{pwd_nonce_hash}{{"method":"test"}}100"#).as_bytes());
        assert_eq!(tag, expected);
    }

    #[test]
    fn test_next_seq() {
        let cipher = AesSslCipher::new(
            "ABCDEF0123456789".to_string(),
            "1234567890ABCDEF",
            "HASH".to_string(),
            42,
        );

        assert_eq!(cipher.next_sequence(), 42);
        assert_eq!(cipher.next_sequence(), 43);
        assert_eq!(cipher.next_sequence(), 44);
    }
}