use super::bounded_response;
use std::fmt;
use log::debug;
use reqwest::Client;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use crate::tapo_vendor::requests::{SecurePassthroughParams, TapoParams, TapoRequest};
use crate::tapo_vendor::responses::{TapoResponse, TapoResponseExt, validate_response};
use crate::tapo_vendor::{Error, TapoResponseError};
use super::aes_ssl_cipher::{
AesSslCipher, compute_password_digest, generate_nonce, validate_device_confirm,
};
use super::crypto;
use super::tapo_protocol::request_error;
pub(super) enum AesSslLogin {
LoggedIn,
Refused(Error),
}
impl AesSslLogin {
pub fn into_result(self) -> Result<(), Error> {
match self {
Self::LoggedIn => Ok(()),
Self::Refused(error) => Err(error),
}
}
}
struct AesSslSession {
cipher: AesSslCipher,
url_with_token: String,
url: String,
}
pub(super) struct AesSslProtocol {
client: Client,
session: Option<AesSslSession>,
}
impl AesSslProtocol {
pub fn new(client: Client) -> Self {
Self {
client,
session: None,
}
}
pub async fn login(
&mut self,
url: String,
username: &str,
password: &str,
) -> Result<AesSslLogin, Error> {
self.handshake(url, username, password).await
}
pub async fn refresh_session(&mut self, username: &str, password: &str) -> Result<(), Error> {
let url = self.session()?.url.clone();
self.handshake(url, username, password).await?.into_result()
}
pub async fn execute_request<R>(&self, request: TapoRequest) -> Result<Option<R>, Error>
where
R: fmt::Debug + DeserializeOwned + TapoResponseExt,
{
let session = self.session()?;
let request_string = serde_json::to_string(&request)?;
let request_encrypted = session.cipher.encrypt(&request_string)?;
let secure_passthrough_request = TapoRequest::SecurePassthrough(TapoParams::new(
SecurePassthroughParams::new(&request_encrypted),
));
let secure_passthrough_request_string = serde_json::to_string(&secure_passthrough_request)?;
let sequence = session.cipher.next_sequence();
let tag = session
.cipher
.generate_tag(&secure_passthrough_request_string, sequence);
let response = self
.client
.post(&session.url_with_token)
.header("Seq", sequence.to_string())
.header("Tapo_tag", tag)
.body(secure_passthrough_request_string)
.send()
.await?;
if !response.status().is_success() {
debug!("Response error: {}", response.status());
return Err(request_error(response.status()));
}
let response_body: serde_json::Value = bounded_response::json(response).await?;
validate_response(extract_error_code(&response_body))?;
let response_body = match response_body
.pointer("/result/response")
.and_then(|v| v.as_str())
{
Some(response_encrypted) => {
let response_decrypted = session.cipher.decrypt(response_encrypted)?;
let response_body: serde_json::Value = serde_json::from_str(&response_decrypted)?;
validate_response(extract_error_code(&response_body))?;
response_body
}
None => response_body,
};
let result: R = serde_json::from_value(response_body)?;
Ok(Some(result))
}
fn session(&self) -> Result<&AesSslSession, Error> {
self.session
.as_ref()
.ok_or_else(|| anyhow::anyhow!("AES SSL session not initialized (login first)").into())
}
async fn handshake(
&mut self,
url: String,
username: &str,
password: &str,
) -> Result<AesSslLogin, Error> {
crate::auth_policy::non_tpap(false)?;
let local_nonce = generate_nonce();
let handshake1_result = match self
.handshake1(&url, username, password, &local_nonce)
.await?
{
Handshake1::Accepted(result) => result,
Handshake1::Refused(error) => return Ok(AesSslLogin::Refused(error)),
};
let handshake2_result = self
.handshake2(
&url,
username,
&local_nonce,
&handshake1_result.server_nonce,
&handshake1_result.password_hash,
)
.await?;
let cipher = AesSslCipher::new(
local_nonce,
&handshake1_result.server_nonce,
handshake1_result.password_hash,
handshake2_result.start_sequence,
);
self.session = Some(AesSslSession {
cipher,
url_with_token: format!("{url}/stok={}/ds", handshake2_result.token),
url,
});
Ok(AesSslLogin::LoggedIn)
}
async fn handshake1(
&self,
url: &str,
username: &str,
password: &str,
local_nonce: &str,
) -> Result<Handshake1, Error> {
debug!("Performing handshake1...");
let body = serde_json::json!({
"method": "login",
"params": {
"cnonce": local_nonce,
"encrypt_type": "3",
"username": username,
}
});
let response = self.client.post(url).json(&body).send().await?;
if !response.status().is_success() {
debug!("Handshake1 error: {}", response.status());
return Err(Error::Tapo(TapoResponseError::HttpError {
status_code: response.status().as_u16(),
description: "Handshake1 failed".to_string(),
}));
}
let response_body =
bounded_response::json::<TapoResponse<serde_json::Value>>(response).await?;
debug!("Handshake1 error code: {}", response_body.error_code);
if response_body.error_code != -40413 {
let description = format!(
"Expected INVALID_NONCE (-40413) during handshake1, got {}",
response_body.error_code
);
let error = Error::Tapo(TapoResponseError::Unauthorized {
kind: "EXPECTED_INVALID_NONCE",
description,
});
if response_body.error_code == -40211 {
return Ok(Handshake1::Refused(error));
}
return Err(error);
}
let result = response_body
.result
.ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult))?;
let data = serde_json::from_value::<Handshake1Response>(result)?.data;
let password_hash_sha256 = crypto::sha256_hex(password.as_bytes());
if validate_device_confirm(
local_nonce,
&data.nonce,
&password_hash_sha256,
&data.device_confirm,
) {
debug!("Handshake1 OK (SHA256)");
return Ok(Handshake1::Accepted(Handshake1Result {
server_nonce: data.nonce,
password_hash: password_hash_sha256,
}));
}
let password_hash_md5 = crypto::md5_hex(password.as_bytes());
if validate_device_confirm(
local_nonce,
&data.nonce,
&password_hash_md5,
&data.device_confirm,
) {
debug!("Handshake1 OK (MD5 fallback)");
return Ok(Handshake1::Accepted(Handshake1Result {
server_nonce: data.nonce,
password_hash: password_hash_md5,
}));
}
debug!("Device confirm hash mismatch in handshake1");
Err(Error::Tapo(TapoResponseError::password_hash_mismatch()))
}
async fn handshake2(
&self,
url: &str,
username: &str,
local_nonce: &str,
server_nonce: &str,
password_hash: &str,
) -> Result<Handshake2Result, Error> {
debug!("Performing handshake2...");
let password_digest = compute_password_digest(local_nonce, server_nonce, password_hash);
let body = serde_json::json!({
"method": "login",
"params": {
"cnonce": local_nonce,
"encrypt_type": "3",
"digest_passwd": password_digest,
"username": username,
}
});
let response = self.client.post(url).json(&body).send().await?;
if !response.status().is_success() {
debug!("Handshake2 error: {}", response.status());
return Err(Error::Tapo(TapoResponseError::HttpError {
status_code: response.status().as_u16(),
description: "Handshake2 failed".to_string(),
}));
}
let response_body =
bounded_response::json::<TapoResponse<Handshake2Result>>(response).await?;
debug!("Handshake2 error code: {}", response_body.error_code);
validate_response(response_body.error_code)?;
let result = response_body
.result
.ok_or_else(|| Error::Tapo(TapoResponseError::EmptyResult))?;
Ok(result)
}
}
pub(super) fn extract_error_code(response_body: &serde_json::Value) -> i64 {
response_body
.get("error_code")
.or_else(|| response_body.get("err_code"))
.and_then(|v| v.as_i64())
.unwrap_or(0)
}
#[derive(Deserialize)]
struct Handshake1Response {
data: Handshake1ResponseData,
}
#[derive(Deserialize)]
struct Handshake1ResponseData {
nonce: String,
device_confirm: String,
}
enum Handshake1 {
Accepted(Handshake1Result),
Refused(Error),
}
struct Handshake1Result {
server_nonce: String,
password_hash: String,
}
#[derive(Deserialize)]
struct Handshake2Result {
#[serde(rename = "stok")]
token: String,
#[serde(rename = "start_seq")]
start_sequence: i32,
}
impl TapoResponseExt for Handshake2Result {}