use aes::Aes128;
use anyhow::{Result, bail};
use cbc::cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyIvInit, block_padding::Pkcs7};
use rand::RngExt;
use sha1::{Digest as Sha1Digest, Sha1};
use sha2::Sha256;
use std::fmt::Write as FmtWrite;
use subtle::ConstantTimeEq;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::{Duration, Instant, timeout_at};
const KLAP_TIMEOUT: Duration = Duration::from_secs(10);
type Aes128CbcEnc = cbc::Encryptor<Aes128>;
type Aes128CbcDec = cbc::Decryptor<Aes128>;
pub struct KlapSession {
key: [u8; 16],
iv_base: [u8; 12],
sig: [u8; 28],
seq: i32,
cookie: String,
host: String,
port: u16,
}
fn sha1_of(data: &[u8]) -> [u8; 20] {
let mut h = Sha1::new();
h.update(data);
h.finalize().into()
}
fn sha256_multi(parts: &[&[u8]]) -> [u8; 32] {
let mut h = Sha256::new();
for p in parts {
h.update(p);
}
h.finalize().into()
}
pub fn auth_hash(username: &str, password: &str) -> [u8; 32] {
let un = sha1_of(username.as_bytes());
let pw = sha1_of(password.as_bytes());
sha256_multi(&[&un, &pw])
}
async fn read_headers(stream: &mut TcpStream) -> Result<Vec<u8>> {
let mut buf = Vec::with_capacity(512);
loop {
let b = stream.read_u8().await?;
buf.push(b);
if buf.ends_with(b"\r\n\r\n") {
return Ok(buf);
}
if buf.len() > 8192 {
bail!("HTTP response headers too large");
}
}
}
async fn http_post(
host: &str,
port: u16,
path: &str,
extra_headers: &[(&str, &str)],
body: &[u8],
) -> Result<(u16, String, Vec<u8>)> {
let deadline = Instant::now() + KLAP_TIMEOUT;
let addr = match host.parse::<std::net::IpAddr>() {
Ok(ip) => std::net::SocketAddr::new(ip, port).to_string(),
Err(_) => format!("{host}:{port}"),
};
let mut stream = timeout_at(deadline, TcpStream::connect(&addr))
.await
.map_err(|_| crate::transport::connect_timeout_error(&addr, KLAP_TIMEOUT.as_secs()))?
.map_err(|e| crate::transport::connect_error(&addr, &e))?;
stream.set_nodelay(true)?;
let mut req = format!(
"POST {path} HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\n",
body.len()
);
for (k, v) in extra_headers {
let _ = write!(req, "{k}: {v}\r\n");
}
req.push_str("\r\n");
timeout_at(deadline, stream.write_all(req.as_bytes()))
.await
.map_err(|_| anyhow::anyhow!("Timed out sending request to {host}"))??;
timeout_at(deadline, stream.write_all(body))
.await
.map_err(|_| anyhow::anyhow!("Timed out sending request body to {host}"))??;
let header_bytes = timeout_at(deadline, read_headers(&mut stream))
.await
.map_err(|_| anyhow::anyhow!("Timed out reading response headers from {host}"))??;
let headers_str = String::from_utf8_lossy(&header_bytes).into_owned();
let status: u16 = headers_str
.lines()
.next()
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|s| s.parse().ok())
.ok_or_else(|| anyhow::anyhow!("Could not parse HTTP status"))?;
let content_length = response_content_length(&headers_str)?;
let mut resp_body = vec![0u8; content_length];
if content_length > 0 {
timeout_at(deadline, stream.read_exact(&mut resp_body))
.await
.map_err(|_| anyhow::anyhow!("Timed out reading response body from {host}"))??;
}
Ok((status, headers_str, resp_body))
}
fn response_content_length(headers: &str) -> Result<usize> {
let mut length = None;
for line in headers.lines() {
let Some((name, value)) = line.split_once(':') else {
continue;
};
if name.eq_ignore_ascii_case("content-length") {
let parsed = value
.trim()
.parse::<usize>()
.map_err(|_| anyhow::anyhow!("Invalid HTTP Content-Length"))?;
crate::transport::validate_response_length(parsed)?;
if length.is_some_and(|previous| previous != parsed) {
bail!("Conflicting HTTP Content-Length headers");
}
length = Some(parsed);
}
}
Ok(length.unwrap_or(0))
}
pub async fn handshake(host: &str, username: &str, password: &str) -> Result<KlapSession> {
handshake_at(host, 80, username, password).await
}
async fn handshake_at(
host: &str,
port: u16,
username: &str,
password: &str,
) -> Result<KlapSession> {
let ah = auth_hash(username, password);
let mut local_seed = [0u8; 16];
rand::rng().fill(&mut local_seed);
let (status1, headers1, body1) =
http_post(host, port, "/app/handshake1", &[], &local_seed).await?;
if status1 != 200 {
bail!("Handshake 1 failed: HTTP {status1}");
}
let cookie = headers1
.lines()
.find_map(|line| {
let lower = line.to_lowercase();
if !lower.starts_with("set-cookie:") {
return None;
}
let value = line[11..].trim();
value
.split(';')
.next()
.filter(|p| p.trim_start().to_lowercase().starts_with("tp_sessionid="))
.map(|p| p.trim().to_string())
})
.ok_or_else(|| anyhow::anyhow!("No TP_SESSIONID cookie from {host}"))?;
if body1.len() < 48 {
bail!("Handshake 1 response too short ({} bytes)", body1.len());
}
let remote_seed: [u8; 16] = body1[..16].try_into()?;
let server_hash = &body1[16..48];
let expected = sha256_multi(&[&local_seed, &remote_seed, &ah]);
if !bool::from(expected.as_slice().ct_eq(server_hash)) {
bail!("Authentication failed for {host} — check TAPO_USER and TAPO_PASS");
}
let client_proof = sha256_multi(&[&remote_seed, &local_seed, &ah]);
let (status2, _, _) = http_post(
host,
port,
"/app/handshake2",
&[("Cookie", &cookie)],
&client_proof,
)
.await?;
if status2 != 200 {
bail!("Handshake 2 failed: HTTP {status2}");
}
let key: [u8; 16] = sha256_multi(&[b"lsk", &local_seed, &remote_seed, &ah])[..16].try_into()?;
let iv_full = sha256_multi(&[b"iv", &local_seed, &remote_seed, &ah]);
let iv_base: [u8; 12] = iv_full[..12].try_into()?;
let seq = i32::from_be_bytes(iv_full[28..32].try_into()?);
let sig: [u8; 28] = sha256_multi(&[b"ldk", &local_seed, &remote_seed, &ah])[..28].try_into()?;
Ok(KlapSession {
key,
iv_base,
sig,
seq,
cookie,
host: host.to_string(),
port,
})
}
impl KlapSession {
pub(crate) fn host(&self) -> &str {
&self.host
}
pub async fn send(&mut self, json: &str) -> Result<serde_json::Value> {
let (payload, seq) = self.encrypt(json.as_bytes());
let path = format!("/app/request?seq={seq}");
let (status, _, resp_body) = http_post(
&self.host,
self.port,
&path,
&[("Cookie", &self.cookie)],
&payload,
)
.await?;
if status != 200 {
bail!("Request failed: HTTP {status}");
}
if resp_body.is_empty() {
bail!("Tapo request returned empty body (missing Content-Length in response)");
}
let plaintext = self.decrypt(&resp_body)?;
Ok(serde_json::from_str(&plaintext)?)
}
fn iv_for_seq(&self, seq: i32) -> [u8; 16] {
let mut iv = [0u8; 16];
iv[..12].copy_from_slice(&self.iv_base);
iv[12..].copy_from_slice(&seq.to_be_bytes());
iv
}
fn encrypt(&mut self, plaintext: &[u8]) -> (Vec<u8>, i32) {
self.seq = self.seq.wrapping_add(1);
let iv = self.iv_for_seq(self.seq);
let ciphertext =
Aes128CbcEnc::new(&self.key.into(), &iv.into()).encrypt_padded_vec::<Pkcs7>(plaintext);
let sig_tag = sha256_multi(&[&self.sig, &self.seq.to_be_bytes(), &ciphertext]);
let mut payload = Vec::with_capacity(32 + ciphertext.len());
payload.extend_from_slice(&sig_tag);
payload.extend_from_slice(&ciphertext);
(payload, self.seq)
}
fn decrypt(&self, data: &[u8]) -> Result<String> {
if data.len() < 48 || !(data.len() - 32).is_multiple_of(16) {
return Err(crate::error::malformed(
"Invalid KLAP response frame length",
));
}
let expected = sha256_multi(&[&self.sig, &self.seq.to_be_bytes(), &data[32..]]);
if !bool::from(expected.as_slice().ct_eq(&data[..32])) {
return Err(crate::error::error(
"integrity_failed",
"KLAP response signature does not match the session and request sequence",
));
}
let iv = self.iv_for_seq(self.seq);
let plaintext = Aes128CbcDec::new(&self.key.into(), &iv.into())
.decrypt_padded_vec::<Pkcs7>(&data[32..])
.map_err(|e| anyhow::anyhow!("AES decrypt error: {e:?}"))?;
Ok(String::from_utf8(plaintext)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transport;
use std::io::ErrorKind;
#[rstest::rstest]
#[case("HTTP/1.1 200 OK\r\n\r\n", Some(0))]
#[case("HTTP/1.1 200 OK\r\ncOnTeNt-LeNgTh: 48\r\n\r\n", Some(48))]
#[case("Content-Length: invalid\r\n", None)]
#[case("Content-Length: -1\r\n", None)]
#[case("Content-Length: 1048577\r\n", None)]
#[case("Content-Length: 48\r\nContent-Length: 16\r\n", None)]
fn bounded_content_length(#[case] headers: &str, #[case] expected: Option<usize>) {
assert_eq!(response_content_length(headers).ok(), expected);
}
#[test]
fn klap_timeout_is_positive() {
assert!(KLAP_TIMEOUT.as_secs() > 0);
}
#[test]
fn auth_hash_is_32_bytes() {
let h = auth_hash("user@example.com", "secret");
assert_eq!(h.len(), 32);
}
#[test]
fn auth_hash_differs_by_credential() {
let h1 = auth_hash("user@example.com", "pass1");
let h2 = auth_hash("user@example.com", "pass2");
assert_ne!(h1, h2);
}
#[test]
fn auth_hash_is_deterministic() {
let h1 = auth_hash("u@x.com", "pw");
let h2 = auth_hash("u@x.com", "pw");
assert_eq!(h1, h2);
}
#[test]
fn klap_connect_error_reuses_transport_hint() {
let err = std::io::Error::new(ErrorKind::NetworkUnreachable, "Network is unreachable");
let msg = transport::connect_error("192.168.4.27:80", &err).to_string();
assert!(msg.contains("192.168.4.27:80"), "{msg}");
assert!(msg.contains("unreachable"), "{msg}");
}
}
#[cfg(test)]
#[path = "klap_tests.rs"]
mod protocol_tests;
#[cfg(test)]
mod integrity_tests {
use super::*;
fn session() -> KlapSession {
KlapSession {
key: [0x11; 16],
iv_base: [0x22; 12],
sig: [0x33; 28],
seq: 7,
cookie: "test".into(),
host: "127.0.0.1".into(),
port: 80,
}
}
fn vector() -> Vec<u8> {
let hex = "fdeff61de989b62a68bc094d95842f06fe91c7297de36fc8ea9a5b401e9b6861820537a8703e0082acf8356b933a1f3231ea0fb429980ea3558201853efecd13a974114c7698b57ad882d96faa4baa50";
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
.collect()
}
#[test]
fn accepts_independent_authenticated_vector() {
assert_eq!(
session().decrypt(&vector()).unwrap(),
r#"{"error_code":0,"result":{"device_on":true}}"#
);
}
#[test]
fn tampering_any_tag_or_ciphertext_byte_fails_before_decryption() {
let original = vector();
for i in 0..original.len() {
let mut altered = original.clone();
altered[i] ^= 1;
assert_eq!(
crate::error::code(&session().decrypt(&altered).unwrap_err()),
"integrity_failed",
"byte {i}"
);
}
}
#[test]
fn rejects_replay_and_cross_session_responses() {
let mut next = session();
next.seq += 1;
assert_eq!(
crate::error::code(&next.decrypt(&vector()).unwrap_err()),
"integrity_failed"
);
let mut other = session();
other.sig[0] ^= 1;
assert_eq!(
crate::error::code(&other.decrypt(&vector()).unwrap_err()),
"integrity_failed"
);
}
#[test]
fn rejects_truncated_or_unaligned_frames() {
let bytes = vector();
for size in [0, 1, 31, 32, 33, 47, 49, 79] {
assert_eq!(
crate::error::code(&session().decrypt(&bytes[..size]).unwrap_err()),
"malformed_response"
);
}
}
}